Usage

This guide covers the main encomp modules: units, fluids, and symbolic math.

Versioning and stability

encomp does not follow strict semantic versioning: releases keep the documented public APIs stable where possible, and any breaking change to them is called out in the GitHub release notes. Public APIs are the documented modules and objects in the API reference; private helpers, tests, notebooks, generated docs, and Rust internals may change in any release. The top-level encomp package intentionally exposes only __version__; import library APIs from their submodules.

encomp.sympy is legacy and soft-deprecated. It remains available for existing users, but new code should avoid depending on its sympy.Symbol monkey-patching and helper wrappers because the module is planned for removal in a future major release.

The Quantity class

A encomp.units.Quantity stores the magnitude, unit and dimensionality of a physical quantity. Each dimensionality is a separate subclass, so static type checkers catch dimensionality errors before the code runs.

Note

Throughout this guide, Q is the alias created by from encomp.units import Quantity as Q. The library does not export a name Q – create the alias yourself in each module that wants it.

Import the class, then create an instance representing an absolute pressure of 1 bar:

from encomp.units import Quantity as Q

pressure = Q(1, "bar")

Warning

encomp (and the underlying pint library) does not differentiate between absolute and gauge pressure.

Convert the pressure to another unit:

from encomp.units import Quantity as Q

pressure = Q(1, "bar")

# pressure_kpa is a new Quantity instance
pressure_kpa = pressure.to("kPa")

For measured values with uncertainty, Quantity.plus_minus() keeps the unit and stores an uncertainties magnitude. Uncertainty propagation is delegated to pint and uncertainties: the result is a pint.Measurement, not an encomp dimensionality subclass, so it carries no Quantity[DT, MT] typing and leaves the dimensionality-typed system.

The unit definition file (encomp/defs/units.txt) lists the accepted unit names. It is based on the default_en.txt file from pint, with minor modifications. The Nm3/Nm³/nm3 spellings mean normal cubic meter, not nanometer cubed; use nanometer**3 for nanoscale volumes.

The magnitude and the unit are always separate arguments, and each has a closed set of accepted types. A magnitude is a real scalar, a 1-dimensional sequence, a NumPy array, a Polars Series/Expr, or a SymPy atom – a string (Q("24 kg")) and a bool (Q(True)) are rejected, statically where possible and at runtime always. A unit is a string, a Unit, a pint.UnitsContainer, or None; passing a Quantity as the unit is an error, because its magnitude would be silently dropped. Use qty.u to reuse another quantity’s unit, or encomp.units.Quantity.to(), which does accept a Quantity and converts to its unit.

Quantities can also be constructed from unit registry attributes:

from typing import Any, cast

from encomp.units import UNIT_REGISTRY
from encomp.units import Quantity as Q

# the registry attributes are typed for use as Quantity units, not for
# direct arithmetic, so use a dynamic alias when doing registry arithmetic.
ureg: Any = cast(Any, UNIT_REGISTRY)

d = 50 * ureg.m
v = d / ureg.s

mf = Q(25, ureg.kg / ureg.h)

Warning

import encomp installs encomp.units.UNIT_REGISTRY as pint’s process-wide application registry. Every quantity in the process must come from it, or the dimensionality subclasses, the custom [currency] / [normal] dimensions and on_redefinition="raise" would silently not apply. Another pint-based library in the same process therefore gets encomp’s registry (and its unit definitions) after the import. The registry options force_ndarray, force_ndarray_like and autoconvert_offset_to_baseunit are pinned: a write that would change one is discarded and logs a warning.

Quantity types

Each dimensionality is a unique subclass of encomp.units.Quantity. Every instance is one of these dimensionality subclasses; a plain Quantity instance never exists. Calling Quantity(...) still works – the constructor redirects to the subclass matching the unit (a bare number is dimensionless, which is itself a dimensionality, of 1).

from encomp.units import Quantity as Q

pressure = Q(1, "bar")
pressure_kpa = pressure.to("kPa")

type(pressure)  # <class 'encomp.units.Quantity[Pressure, float]'>

fraction = Q(5, "%")
type(fraction)  # <class 'encomp.units.Quantity[Dimensionless, float]'>

assert type(pressure) is type(pressure_kpa)

length = Q(1, "meter")
assert type(pressure) is not type(length)

To create a subclass of encomp.units.Quantity with a certain dimensionality, provide a type parameter in square brackets. The parameter must be a subclass of encomp.utypes.Dimensionality, whose dimensions class attribute holds a pint.unit.UnitsContainer (a combination of the base dimensions).

Note

The type parameter is the subclass itself, not an instance: Q[Power] works, Q[Power()] raises TypeError.

Subclasses for common dimensionalities are defined in encomp.utypes. The second type parameter is the magnitude container. It defaults to Numpy1DArray, so scalar annotations should include float explicitly.

from encomp.units import Quantity as Q
from encomp.utypes import Dimensionality, Length, Power, Pressure

Q[Pressure, float]  # subclass with dimensionality pressure and magnitude float
pressure_scalar: Q[Pressure, float] = Q(1, "bar")
pressure_vector: Q[Pressure] = Q([1, 2], "bar")

pressure_dims = Pressure.dimensions  # <UnitsContainer({'[length]': -1, '[mass]': 1, '[time]': -2})>


# the class name PowerPerLength must be globally unique
class PowerPerLength(Dimensionality):
    dimensions = Power.dimensions / Length.dimensions


Q[PowerPerLength, float]  # new dimensionality

Note

Dimensionality subclasses live in a single process-wide, name-keyed registry:

  • Two subclasses with the same class name and the same dimensions are treated as one type – the first definition wins and is silently reused (this keeps notebook cell re-runs and module reloads working).

  • Two subclasses with the same class name but different dimensions raise TypeError at class-definition time – also across independent packages that both define, say, FuelPerAir. Pick distinctive names for custom dimensionalities in library code.

Important

The typed constructor does not validate the dimensionality at runtime – it redirects. Q[Length](1, "kg") returns a Quantity[Mass, float]: the dimensionality of the created object is always determined by the unit. This is by design (pint constructs arithmetic results through self.__class__(...) with new dimensionalities, so the constructor must accept them). Use encomp.units.Quantity.check() for physical-dimensionality checks. Semantic dimensionality enforcement happens in the static type checker and at explicit runtime boundaries: isinstance() / encomp.misc.isinstance_types(), typeguard.typechecked functions, Pydantic model fields (which raise pydantic.ValidationError), and direct .asdim() calls (which raise ExpectedDimensionalityError for a mismatch). Arithmetic also checks semantic compatibility and may reject two quantities with the same physical dimensions.

Check the physical dimensionality of a quantity with encomp.units.Quantity.check(). For semantic dimensionality checks and parameterized types like list[Quantity[Pressure]], use encomp.misc.isinstance_types().

from typing import Any

from encomp.misc import isinstance_types
from encomp.units import Quantity as Q
from encomp.utypes import Length, Pressure, Temperature, TemperatureDifference

pressure = Q(1, "bar")

pressure.check(Length)  # False
pressure.check("meter")  # False

pressure.check(Pressure)  # True
pressure.check("psi")  # True
pressure.check("[pressure]")  # True

# check() compares physical dimensions only, not semantic sibling classes
Q(1, "degC").check(TemperatureDifference)  # True
Q(1, "delta_degC").check(Temperature)  # True

# isinstance_types works with simple Quantity types and nested containers.
# every element of a container is checked, not just the first

isinstance_types(pressure, Q[Pressure, Any])  # True
isinstance_types(pressure, Q[Length, Any])  # False
isinstance_types([pressure, pressure], list[Q[Pressure, Any]])  # True
isinstance_types([pressure, Q(1, "m")], list[Q[Pressure, Any]])  # False
isinstance_types({1: Q(2, "m"), 2: Q(25, "cm")}, dict[int, Q[Length, Any]])  # True

# all Quantity[...] objects are subclasses of Quantity
isinstance_types(pressure, Q)  # True

Warning

Spell the magnitude parameter when isinstance_types narrows a variable you go on to use: write Q[Pressure, Any] (or the exact magnitude type, Q[Pressure, float]), not a bare Q[Pressure].

At runtime Q[Dim] is magnitude-agnostic, so a bare Q[Pressure] matches a scalar quantity. Statically, Q[Dim] means Quantity[Dim, Numpy1DArray] (the magnitude parameter defaults to Numpy1DArray), and isinstance_types is a typing.TypeIs predicate: a type checker intersects the declared type with the narrowed one, so Quantity[Pressure, float] narrows to Never and every later use of the variable is an error. Q[Pressure, Any] behaves identically at runtime and narrows correctly.

For functions and methods, use the typeguard.typechecked decorator instead of explicit checks in the function body:

from typeguard import typechecked

from encomp.units import Quantity as Q
from encomp.utypes import Length, Power, Pressure


@typechecked
def func(_p1: Q[Pressure, float]) -> tuple[Q[Length, float], Q[Power, float]]:
    return Q(1, "m"), Q(1, "kW")

typeguard.TypeCheckError is raised if the arguments or the return value have incorrect dimensionalities.

Scalar Quantity equality is tolerant (rtol=1e-9, atol=1e-12) and compares after unit conversion, so values that differ only by tiny floating-point noise may compare equal. This is not optional: unit conversion is lossy, and Q(1, "L") and Q(1000, "cm³") differ in the last bit.

The same tolerance folds into every ordering comparison, so the five relations stay consistent: for operands that compare equal, <= and >= are True while < and > are False. Q(1 + 1e-12, "m") <= Q(1, "m") is True, and Q(1 + 1e-12, "m") > Q(1, "m") is False.

Note

Quantities within the tolerance are ties. Because closeness is not transitive (a == b and b == c do not imply a == c), < is a strict partial order rather than a strict weak order. sorted(), min() and max() are therefore exact for any input that does not contain a tolerance chain — a run of values where each adjacent pair is within tolerance but the endpoints are not. Such a chain spans less than the width at which the library already calls the values equal. When a strict total order is required regardless, sort on the raw magnitudes:

from encomp.units import Quantity as Q

quantities = [Q(1.0, "m"), Q(50, "cm"), Q(2000, "mm")]

ordered = sorted(quantities, key=lambda q: q.to("m").m)

Hashing is supported for float magnitudes and uses root units; vector magnitudes are unhashable.

Pickling preserves the dimensionality class for module-global dimensionalities. Dynamically generated dimensionalities round-trip by deriving the dimensionality from the stored unit.

Custom base dimensionalities

By default, the seven SI dimensionalities (and common combinations of these) are defined. The only custom base dimensionalities defined out of the box are normal (used to represent normal volume) and currency. Media dimensionalities such as dry_air or fuel are not predefined – create them yourself with encomp.units.define_dimensionality(), as shown below.

Warning

Do not use water as a media tag: water is inherited from pint as a density unit (1 kg/liter), so a unit like "kg water" silently parses to [mass]²/[length]³ instead of raising. Pick an unambiguous name (for example H2O) and define it explicitly.

encomp.units.define_dimensionality() defines a new base dimensionality with a single unit of the same name. If the dimensionality already exists, encomp.units.DimensionalityRedefinitionError is raised.

from encomp.units import Quantity as Q
from encomp.units import define_dimensionality

define_dimensionality("dry_air")
define_dimensionality("oxygen")

# the new dimensionality [dry_air] has a single unit: "dry_air"
m_air = Q(5, "kg * dry_air")
n_o2 = Q(2.4, "mol * oxygen")
M_O2 = Q(32, "g/mol")

# compute mass fraction
((n_o2 * M_O2) / m_air).to_base_units()  # 0.01536 oxygen/dry_air

Quantities with vector magnitudes

Lists, NumPy arrays and Polars Series objects can also be used as magnitude.

import numpy as np

from encomp.units import Quantity as Q

type(Q([1, 2, 3], "kg").m)  # numpy.ndarray

arr = np.linspace(0, 1, 6)
Q(arr, "bar")
# [0.0 0.2 0.4 0.6000000000000001 0.8 1.0] bar

Quantities with expression magnitudes

Polars Expressions can be used as magnitude:

import polars as pl

from encomp.units import Quantity as Q

type(Q(pl.lit(5), "kg").m)  # pl.Expr

A Quantity with a pl.Expr magnitude is a deferred plan, not data. Only unit algebra (arithmetic, comparison, .to, abs) is meaningful on it; reach the underlying Polars object with .m to compute inside a select / with_columns. This is also how the parallel CoolProp evaluation described below is driven.

Persisting units in Polars schemas

A bare Quantity(pl.col("P"), "bar") carries its unit only in the Python wrapper. The association disappears if another service reads the underlying Parquet file. encomp.polars supplies the missing frame-level layer: UnitDType stores the unit in Arrow field metadata, while the numeric values remain the extension dtype’s storage.

Declare the producer contract once on a encomp.polars.QuantityFrame. Constructing it normally validates units already present in the Polars schema; encomp.polars.QuantityFrame.from_untyped() is the explicit operation that assigns declared units to bare input data:

from pathlib import Path
from tempfile import TemporaryDirectory

import polars as pl

from encomp.polars import QuantityFrame, unit, units_of
from encomp.units import Unit


class Sensors(QuantityFrame):
    pressure = unit("bar", name="P")
    flow = unit("m³/h", name="V")


class Report(QuantityFrame):
    power = unit("kW", name="Hydraulic power")


source = Sensors.from_untyped(pl.DataFrame({"P": [1.0, 2.0], "V": [10.0, 20.0]}))

with TemporaryDirectory() as directory:
    path = Path(directory) / "source.parquet"
    source.lf.sink_parquet(path)
    sensors = Sensors.scan_parquet(path)

    power = sensors.pressure * sensors.flow
    result = Report.derive(sensors, Report.power.assign(power))

    assert units_of(result.lf) == {
        "P": Unit("bar"),
        "V": Unit("m³/h"),
        "Hydraulic power": Unit("kW"),
    }
    assert result.lf.collect()["Hydraulic power"].ext.storage().to_list() == [
        0.2777777777777778,
        1.1111111111111112,
    ]

The declaration pressure = unit("bar", name="P") contains the physical information once. name= is only needed because the external name differs from the attribute; pressure = unit("bar") would target a column named "pressure". Known unit literals make sensors.pressure statically Quantity[Pressure, pl.Expr]. Class access returns the typed target declaration instead, so Report.power.assign(...) rejects a pressure quantity under all three supported type checkers. The lazy magnitude is still an ordinary pl.col("P").ext.storage() expression, so Polars can optimize it normally.

Unit strings are not restricted to the literal autocomplete list. Pint parses other valid expressions and encomp infers their dimensionality at runtime. Since a static checker cannot execute Pint’s registry, an unlisted string produces Column[UnknownDimensionality]; supply the exceptional asdim=Pressure argument when that column needs precise static typing. Compatibility is checked immediately.

Construction safely converts compatible stored units to the declaration inside the lazy plan; incompatible dimensionalities fail before data is read. Schema classes compose through ordinary inheritance when one validated view needs columns from multiple schemas.

The same bridge composes with the high-level fluid API without dropping to raw expressions. Water accepts the descriptor quantities, performs its required SI conversions, and returns a typed expression quantity that can be assigned straight back to a declared output:

import polars as pl

from encomp.fluids import Water
from encomp.polars import QuantityFrame, unit, units_of
from encomp.units import Unit


class States(QuantityFrame):
    pressure = unit("bar", name="P")
    temperature = unit("degC", name="T")


class Properties(QuantityFrame):
    density = unit("kg/m³", name="rho")


states = States.from_untyped(pl.LazyFrame({"P": [5.0], "T": [150.0]}))
water = Water[pl.Expr](P=states.pressure, T=states.temperature)
properties = Properties.derive(states, Properties.density.assign(water.D))

assert units_of(properties.lf)["rho"] == Unit("kg/m³")
assert properties.lf.collect()["rho"].ext.storage()[0] > 900.0

This convenience belongs to Water/Fluid, where units are present in the Quantity inputs. The lower-level encomp.coolprop.water() function accepts bare Polars columns and therefore cannot insert unit conversions: extension-typed inputs must already carry the exact SI unit, or callers must pass an explicitly converted magnitude such as states.pressure.to("Pa").m.

Polars has no extension hook for encomp’s multiplication or supertype rules. It therefore refuses value-producing operations directly on unit-typed columns; computation must explicitly cross into Quantity. Value-preserving operations such as filtering, sorting, aliases, join keys and group keys keep the dtype, and incompatible unit dtypes cannot be concatenated.

Parquet and IPC store ARROW:extension:name = "encomp.unit" and ARROW:extension:metadata = <canonical unit> on each field. The canonical rendering normalizes syntax, not dimensional equivalence: m^3 and match, but Pa and N/m² remain distinct dtype identities. The upstream Polars extension API is unstable, so encomp treats this integration as experimental and pins its required I/O and refusal behavior in tests.

Combining quantities

The output of an operation on quantities is always consistent with the input dimensionalities. Inconsistent or ambiguous operations raise descriptive errors.

Units do not always cancel out automatically. Call encomp.units.Quantity.to_base_units() to simplify to base SI units, encomp.units.Quantity.to() when the target unit is known, or encomp.units.Quantity.to_reduced_units() to cancel units without converting to base SI units.

When only the converted magnitude is needed, encomp.units.Quantity.m_as() is the typed shorthand for .to(unit).m; it preserves the magnitude container type and applies the same dimensionality and temperature checks.

from encomp.units import Quantity as Q

(Q(5, "%") * Q(1, "meter")).to("mm")  # 50.0 mm

Temperature units need extra care. A temperature difference in a degree scale is written with the prefix delta_ (only needed when defining the difference directly). Temperature (encomp.utypes.Temperature) and temperature difference (encomp.utypes.TemperatureDifference) are distinct dimensionalities and deliberately not interchangeable: a difference cannot silently be used as an absolute temperature. Do not use encomp.units.Quantity.check() to distinguish these two cases: it compares physical dimensions, and both classes share [temperature]. Use isinstance() / encomp.misc.isinstance_types(), typed function boundaries, Pydantic fields, or arithmetic/conversion errors for the semantic distinction.

from pint.errors import OffsetUnitCalculusError

from encomp.units import DimensionalityTypeError
from encomp.units import Quantity as Q

temp_diff = Q(5, "delta_degC")  # 5 Δ°C

# a temperature difference cannot be converted to an absolute temperature
try:
    temp_diff.to("degC")
except DimensionalityTypeError as e:
    print(f"Error: {e}")
    # Cannot convert Δ°C (dimensionality TemperatureDifference)
    # to °C (dimensionality Temperature)

Q(25, "degC") - Q(36, "degC")  # -11.0 Δ°C

# multiplying with an offset unit (°C) is ambiguous
try:
    Q(4.19, "kJ/kg/K") * Q(5, "°C")
except OffsetUnitCalculusError as e:
    print(f"Error: {e}")

# this is not the result we're after, °C is offset by 273.15 K
Q(4.19, "kJ/kg/K") * Q(5, "°C").to("K")  # 1165.4485 kJ/kg

Q(4.19, "kJ/kg/K") * Q(5, "delta_degC")  # ≈ 20.95 Δ°C·kJ/K/kg
Q(4.19, "kJ/kg/K") * Q(5, "K")  # ≈ 20.95 kJ/kg

# the units Δ°C and K don't cancel out automatically,
# use the to() method to convert to the desired output unit
(Q(4.19, "kJ/kg/K") * Q(5, "delta_degC")).to("kJ/kg")  # ≈ 20.95 kJ/kg

Note

pint.errors.OffsetUnitCalculusError is raised when doing ambiguous unit conversions. The environment variable ENCOMP_AUTOCONVERT_OFFSET_TO_BASEUNIT can be set to True to disable this error (this is not recommended).

Currency units

The dimensionality encomp.utypes.Currency represents an arbitrary currency. SEK, EUR and USD are defined by default.

Warning

Do not use this system for currency conversions. The scaling factors between the built-in currencies are fixed placeholders (10 SEK = 1 EUR = 1 USD), not exchange rates – converting a quantity from one currency to another silently applies these fabricated factors. Keep all quantities in a single currency, or refer to the pint documentation for how to implement a registry context that handles currency conversion correctly.

from encomp.units import Quantity as Q

mf = Q(25, "kg/s")
t = Q(365, "d")

price = Q(25, "EUR/ton")

yearly_cost = mf * t * price  # Quantity[Currency]

# SI prefixes can be used
print(yearly_cost.to("MEUR"))

# NOTE: this is only an approximation,
# uses the fixed placeholder scaling 10 SEK = 1 EUR
print(yearly_cost.to("MSEK"))

weekly_cost = Q(145, "GWh/year") * Q(1, "week") * Q(25, "EUR/MWh")

print(weekly_cost.to("MEUR"))

Integration with Pydantic

encomp.units.Quantity (optionally with a dimensionality type parameter) works as a Pydantic field type.

from typing import Any, cast

from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError

from encomp.units import Quantity as Q
from encomp.utypes import Dimensionless, Length, Mass


class Model(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True, validate_default=True)

    # a can be any dimensionality
    a: Q[Any, Any]

    m: Q[Mass, float]
    s: Q[Length, float]

    # float is converted to Quantity[Dimensionless]
    r: Q[Dimensionless, float] = Q(0.5)


model = Model(a=Q(25, "cSt"), m=Q(25, "kg"), s=Q(25, "cm"))

# Quantity fields round-trip through JSON, including the magnitude type
Model.model_validate_json(model.model_dump_json())

adapter = TypeAdapter(Q[Mass, float])
adapter.validate_json(adapter.dump_json(Q(2.0, "kg")))

try:
    Model(a=Q(25, "cSt"), m=cast(Any, Q(25, "m")), s=Q(25, "cm"))
except ValidationError as e:
    print(e.errors()[0]["type"])  # quantity_dimensionality

Note

Pydantic model and pydantic.TypeAdapter validation wraps quantity input errors in pydantic.ValidationError, using error types such as quantity_dimensionality, quantity_magnitude_type, and quantity_validation. This lets Pydantic attach field locations and collect multiple invalid fields in one exception.

The Fluid class

The encomp.fluids.Fluid class represents a fluid at a fixed point. The abstract base class encomp.fluids.CoolPropFluid implements the CoolProp interface and documents the fluid and property names. All inputs and outputs are encomp.units.Quantity instances. Input keyword names are checked statically, but the dimensionality of each input quantity is validated at runtime when the property is evaluated.

Pass the CoolProp fluid name and the fixed points (for example P, T) to the constructor. Not every combination of input values can fix the state: with an invalid state, derived properties evaluate to nan and encomp.fluids logs a warning, but no exception is raised. Set ENCOMP_IGNORE_COOLPROP_WARNINGS=true (the default) to suppress these calculation warnings, or false to emit them through Python logging. An invalid property name or output-only state input, on the other hand, raises ValueError. So does an unknown fluid name: it is resolved against CoolProp in the constructor (and the answer is cached per name, so repeated construction of the same fluid costs nothing). The dimensionality of each state-input quantity is still validated lazily, at the first property evaluation.

from typing import Any

from encomp.fluids import Fluid
from encomp.units import Quantity as Q

Fluid("toluene", T=Q(25, "°C"), P=Q(2, "bar"))
# <Fluid "toluene", P=200 kPa, T=25.0 °C, D=862.3 kg/m³, V=0.55 cP>

# Q is vapor quality. The property name is a valid state input, but values
# outside 0-1 cannot fix a physical state.
state: Any = {"P": Q(1, "bar"), "Q": Q(2)}

invalid_inputs = Fluid("water", **state)
# <Fluid "water", P=100 kPa, T=nan °C, D=nan kg/m³, V=nan cP>

# derived properties are nan (and may log a warning about the invalid state)
temperature = invalid_inputs.T  # nan °C

encomp.fluids.Water omits the fluid name and uses IAPWS-IF97 (Industrial Formulation 1997). For the IAPWS-95 reference formulation, use the HEOS backend: encomp.fluids.Fluid with name HEOS::Water (the bare name water also resolves to HEOS).

The encomp.fluids.HumidAir class has a different set of input and output properties.

from encomp.fluids import HumidAir, Water
from encomp.units import Quantity as Q

# input units are converted to SI
Water(P=Q(30, "psi"), T=Q(250, "°F"))
# <Water (Liquid), P=207 kPa, T=121.1 °C, D=942.2 kg/m³, V=0.23 cP>

HumidAir(T=Q(25, "°C"), P=Q(2, "bar"), R=Q(25, "%"))
# <HumidAir, P=200 kPa, T=25.0 °C, R=0.25, Vda=0.4 m³/kg, Vha=0.4 m³/kg, M=0.018 cP>

Property names must match CoolProp’s exactly. An invalid state-input name is rejected statically at the call site, and the constructor raises ValueError at runtime:

from typing import Any

from encomp.fluids import HumidAir
from encomp.units import Quantity as Q

# "Ps" is not a valid property name (the inputs are routed through Any here,
# since HumidAir(T=..., Ps=..., R=...) is also rejected statically)
state: Any = {"T": Q(25, "°C"), "Ps": Q(2, "bar"), "R": Q(25, "%")}

try:
    HumidAir(**state)
except ValueError as e:
    print(f"Error: {e}")
    # Invalid CoolProp property name: Ps
    # Valid names:
    # B, C, CV, CVha, Cha, Conductivity, D, DewPoint, Enthalpy, Entropy, H, Hda, Hha,
    # HumRat, K, M, Omega, P, P_w, R, RH, RelHum, S, Sda, Sha, T, T_db, T_dp, T_wb, Tdb,
    # Tdp, Twb, V, Vda, Vha, Visc, W, WetBulb, Y, Z, cp, cp_ha, cv_ha, k, mu, psi_w

Use the search() and describe() methods to get more information about the properties:

from encomp.fluids import Fluid, HumidAir

HumidAir.search("bulb")
# ['B, Twb, T_wb, WetBulb: Wet-Bulb Temperature [K]',
#  'T, Tdb, T_db: Dry-Bulb Temperature [K]']

Fluid.describe("Z")
# 'Z: Compressibility factor [dimensionless]'

All property synonyms are valid instance attributes:

from encomp.fluids import Water
from encomp.units import Quantity as Q

Water.describe("PCRIT")
# 'PCRIT, P_CRITICAL, Pcrit, p_critical, pcrit: Pressure at the critical point [Pa]'

water = Water(T=Q(25, "°C"), P=Q(1, "atm"))

critical = water.p_critical, water.PCRIT
# (<Quantity(22064.0, 'kilopascal')>, <Quantity(22064.0, 'kilopascal')>)

Tip

Common fluid properties are type hinted with the correct dimensionality and show up in IDE autocomplete.

Mixtures and assumed phase

A mixture is given either by fractions folded into the fluid name or by a composition dict of mole fractions (which must sum to 1). Both spellings resolve to the same state.

from encomp.fluids import Fluid
from encomp.units import Quantity as Q

Fluid("HEOS::CO2[0.7]&O2[0.3]", P=Q(10, "bar"), T=Q(300, "K"))

Fluid("HEOS", P=Q(10, "bar"), T=Q(300, "K"), composition={"CO2": 0.7, "O2": 0.3})

For an incompressible mixture, the concentration is carried in the name instead, on the fluid’s own basis (mass for glycols/brines, volume for the volume-specified antifreezes):

from encomp.fluids import Fluid
from encomp.units import Quantity as Q

Fluid("INCOMP::MEG[0.5]", P=Q(1, "bar"), T=Q(20, "°C"))  # aqueous 50 % ethylene glycol

The MEG and MPG CoolProp incompressible names are aqueous monoethylene-glycol and monopropylene-glycol solutions. Their bracketed fractions are solution concentrations on CoolProp’s documented basis, not the mole fractions used by composition.

The encomp.fluids.Fluid.assume_phase() method pins the phase, skipping CoolProp’s phase-stability search, which dominates the cost for the HEOS/GERG mixture backends. It is a speed tool, not a validation tool: forcing a phase the fluid is not actually in returns NaN or a non-physical metastable root rather than raising.

from encomp.fluids import Fluid
from encomp.units import Quantity as Q

# ~100-1000x faster for mixtures, when the phase is known
density = Fluid("HEOS::CO2[0.7]&O2[0.3]", P=Q(10, "bar"), T=Q(300, "K")).assume_phase("gas").D

IF97 (the default backend for encomp.fluids.Water) is region-explicit and ignores an assumed phase; the call is a no-op there and emits a warning. Use Fluid("HEOS::Water", ...) if you need an assumed phase for water.

Using vector inputs

CoolProp evaluates vector inputs in a single backend call. The inputs are encomp.units.Quantity instances with vector magnitudes: one-dimensional NumPy arrays or pl.Series, all of the same length (or a single scalar, which is repeated).

import numpy as np

from encomp.fluids import Water
from encomp.units import Quantity as Q

Water(T=Q(np.linspace(25, 50, 10), "°C"), P=Q(np.linspace(25, 50, 10), "bar"))
# the repr shows only the head of each vector input
# <Water (Liquid), P=[2500 2778 3056 ...] kPa, T=[25.0 27.8 30.6 ...] °C,
# D=[998.1 997.5 996.8 ...] kg/m³, V=[0.89 0.84 0.79 ...] cP>

# different phases
phases = Water(T=Q(np.linspace(25, 500, 10), "°C"), P=Q(np.linspace(0.5, 10, 10), "bar")).PHASE
# <Quantity([0. 0. 5. 5. 5. 5. 5. 2. 2. 2.], 'dimensionless')>

phase_names = Water.PHASES
# {0.0: 'Liquid',
#  5.0: 'Gas',
#  6.0: 'Two-phase',
#  3.0: 'Supercritical liquid',
#  2.0: 'Supercritical gas',
#  1.0: 'Supercritical fluid',
#  4.0: 'Critical point',
#  7.0: 'Unknown',
#  8.0: 'Not imposed'}

# when one input is constant (float, int, single element array),
# it's repeated as an array
Water(T=Q(np.linspace(25, 500, 10), "°C"), P=Q(5, "bar"))  # ty: ignore[invalid-argument-type]
# <Water (Variable), P=[500 500 500 ...] kPa, T=[25.0 77.8 130.6 ...] °C,
# D=[997.2 973.4 934.5 ...] kg/m³, V=[0.89 0.36 0.21 ...] cP>

Missing or out-of-range results surface as NaN (for a numpy magnitude) or null (for a Polars magnitude), never as a zero or a raised exception, so a partly-invalid batch still returns the valid rows.

Parallel evaluation with Polars

encomp.fluids.Fluid properties also accept Quantity-wrapped Polars expressions (pl.Expr) and return a pl.Expr. Independent property nodes in one select / with_columns / collect() (eager or lazy) are evaluated in parallel by the encomp.coolprop plugin – a native Rust extension over the CoolProp C-API that runs without holding the GIL. pl.Expr (lazy) inputs are evaluated exclusively through this plugin (there is no map_batches fallback). Eager float / NumPy / pl.Series inputs use the Python CoolProp path, except vector magnitudes of at least EAGER_PLUGIN_MIN_SIZE (1000) elements, which also route through the plugin (results are bit-identical when the installed coolprop matches the bundled build, 8.0.0).

import polars as pl

from encomp.fluids import Water
from encomp.units import Quantity as Q

df = pl.DataFrame({"P": [50e5, 60e5], "T": [400.0, 450.0]})  # Pa, K
w: Water[pl.Expr] = Water(P=Q(pl.col("P"), "Pa"), T=Q(pl.col("T"), "K"))

# independent CoolProp properties evaluated in parallel across cores
df.select(w.D.m.alias("rho"), w.H.m.alias("h"), w.S.m.alias("s"))

Each property is a separate plugin node, so selecting K properties of one state (as above) runs K flashes of it – Polars cannot reuse the shared flash across the opaque plugin nodes. They still evaluate in parallel, so this is total work, not wall-clock.

The plugin is also usable directly on any Polars expression, independent of the encomp.fluids.Fluid class (the encomp.coolprop package):

import polars as pl

from encomp import coolprop as cp

df = pl.DataFrame({"P": [1e5, 1e5], "T": [293.15, 313.15], "R": [0.4, 0.6]})  # Pa, K, -

df.select(
    cp.water("DMASS", "P", "T").alias("rho"),  # IF97 water/steam
    cp.water("HMASS", "P", "T").alias("h"),
    cp.humid_air("W", "P", "T", "R").alias("humidity_ratio"),
)

The API mirrors encomp.fluids.Fluid: any CoolProp input pair is supported (in any order), the fluid is given by name (with the backend folded in, e.g. name="HEOS::CarbonDioxide"), mixtures via a composition={species: mole fraction} dict, and a fixed phase via assume_phase="gas".

name is required, exactly as it is for encomp.fluids.Fluid. cp.water(output, in1, in2) is the IF97 water/steam shorthand, standing to cp.fluid as encomp.fluids.Water stands to encomp.fluids.Fluid. See the encomp.coolprop package README in the repository for the full design and thread-safety model.

SymPy functionality

encomp.sympy is legacy and soft-deprecated; it is planned for removal in a future major release. To load additional methods for the sympy.Symbol class, import SymPy via the encomp.sympy module.

Typesetting

The following convenience methods are added to the sp.Symbol class:

  • sp.Symbol._(): add subscript

  • sp.Symbol.__(): add superscript

  • sp.Symbol.decorate(): add sub- and superscript prefixes and suffixes (encomp.sympy.Symbol.decorate())

These methods return new sp.Symbol instances with the same assumptions (positive, real, integer, …) as the original.

from typing import Any, cast

from encomp.sympy import sp

n = sp.Symbol("n", integer=True)

# the _ method is added to sp.Symbol at runtime by encomp.sympy
n_test: Any = cast(Any, n)._("test")
str(n_test)
# n_{\text{test}}

n_test.assumptions0["integer"]  # True

Tip

The assumptions for an sp.Symbol instance are accessed with the attribute assumptions0 (note the 0 at the end).

The _ and __ methods typeset sub- and superscripts automatically:

  • Single-letter lower case with math font: n._("a")\(n_a\)

  • Single-letter upper case with regular font: n._("A")\(n_{\text{A}}\)

  • Chemical formulas: n._("H_2O")\(n_{\text{H}_2\text{O}}\)

  • Strings with two or more characters with regular font: n._("water")\(n_{\text{water}}\)

  • Parts are split with ,: n._("outlet,A,i,H_2SO_4")\(n_{\text{outlet},\text{A},i,\text{H}_2\text{SO}_4}\)

  • Combine sub- and superscript: n._("a").__("in")\(n_{a}^{\text{in}}\)

The decorate method offers more control:

  • n.decorate(prefix="\sum", prefix_sub="2", suffix_sup="i", suffix="\ldots")\({\sum}_{2}n^{i}{\ldots}\)

Integration with quantities

Quantities can be substituted into SymPy expressions; the units are converted to SymPy symbols automatically. The class method encomp.units.Quantity.from_expr() converts an expression back to a quantity.

from encomp.sympy import sp
from encomp.units import Quantity as Q

x, y, z = sp.symbols("x, y, z")  # pyright: ignore[reportUnknownMemberType]

expr = 25 * x * y / z

result_expr = expr.subs({x: Q(235, "yard"), y: Q(2, "m²"), z: Q(0.4, "m³/kg")})

result_qty = Q.from_expr(result_expr)
# ≈ 26860.5 kg

encomp.units.Quantity.from_expr() raises KeyError if residual symbols in the expression are not SI units.

Warning

SymPy integration only works with the seven SI dimensionalities, not with dimensionalities defined via encomp.units.define_dimensionality().

encomp.units.Quantity.from_expr() does not support NumPy array magnitudes. Convert the expression to a function with encomp.sympy.get_function() instead:

import numpy as np

from encomp.sympy import get_function, sp
from encomp.units import Quantity as Q

x, y, z = sp.symbols("x, y, z")  # pyright: ignore[reportUnknownMemberType]

expr = 25 * x * y / z

# units=False by default, since this is faster to evaluate
fcn = get_function(expr, units=True)

result_qty = fcn(
    {
        x: Q(np.array([235, 335]), "yard"),
        y: Q([2, 5], "m²"),  # regular lists will be converted to array
        z: Q(0.4, "m³/kg"),
    }
)
# ≈ [26860.5 95726.25] kg

Quantity objects combine directly with SymPy symbols; the units are converted to their symbolic representations by the Quantity._sympy_ method (the hook sympy.sympify looks for).

from encomp.sympy import sp
from encomp.units import Quantity as Q

x, y, z = sp.symbols("x, y, z")  # pyright: ignore[reportUnknownMemberType]

# the type of the left object determines the output

# output is a Quantity with a symbolic magnitude
Q(1) * x  # 1.0*x dimensionless
Q(10, "%") * x  # 10.0*x percent

# output is a sympy object
x * Q(1)  # 1.0*x
x * Q(10, "%")  # 0.1*x

# when the output is a sympy object,
# all derived units are expanded to the base SI units
x + y / Q(25, "kW")
# x + 4.0e-5*\text{s}**3*y/(\text{kg}*\text{m}**2)

Note

This SymPy dispatch behavior is runtime-only and is not fully encoded in the type hints.