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
TypeErrorat 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 m³ 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 subscriptsp.Symbol.__(): add superscriptsp.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.