# 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](https://github.com/wlaur/encomp/releases). 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 {py:class}`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: ```python 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: ```python 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 {py:meth}`encomp.units.Quantity.to`, which does accept a `Quantity` and converts to its unit. Quantities can also be constructed from unit registry attributes: ```python 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 {py:data}`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 {py:class}`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*). ```python from encomp.units import Quantity as Q pressure = Q(1, "bar") pressure_kpa = pressure.to("kPa") type(pressure) # fraction = Q(5, "%") type(fraction) # assert type(pressure) is type(pressure_kpa) length = Q(1, "meter") assert type(pressure) is not type(length) ``` To create a subclass of {py:class}`encomp.units.Quantity` with a certain dimensionality, provide a *type parameter* in square brackets. The parameter must be a subclass of {py:class}`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 {py:mod}`encomp.utypes`. The second type parameter is the magnitude container. It defaults to `Numpy1DArray`, so scalar annotations should include `float` explicitly. ```python 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 # # 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 {py:meth}`encomp.units.Quantity.check` for physical-dimensionality checks. Semantic dimensionality enforcement happens in the static type checker and at explicit runtime boundaries: `isinstance()` / {py:func}`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 {py:meth}`encomp.units.Quantity.check`. For semantic dimensionality checks and parameterized types like `list[Quantity[Pressure]]`, use {py:func}`encomp.misc.isinstance_types`. ```python 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 {py:obj}`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: ```python 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: ```python 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 {py:func}`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. ::: {py:func}`encomp.units.define_dimensionality` defines a new base dimensionality with a single unit of the same name. If the dimensionality already exists, {py:class}`encomp.units.DimensionalityRedefinitionError` is raised. ```python 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. ```python 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: ```python 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 {py:class}`encomp.polars.QuantityFrame`. Constructing it normally validates units already present in the Polars schema; {py:meth}`encomp.polars.QuantityFrame.from_untyped` is the explicit operation that assigns declared units to bare input data: ```python 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: ```python 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 = ` 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 {py:meth}`encomp.units.Quantity.to_base_units` to simplify to base SI units, {py:meth}`encomp.units.Quantity.to` when the target unit is known, or {py:meth}`encomp.units.Quantity.to_reduced_units` to cancel units without converting to base SI units. When only the converted magnitude is needed, {py:meth}`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. ```python 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 ({py:class}`encomp.utypes.Temperature`) and temperature difference ({py:class}`encomp.utypes.TemperatureDifference`) are distinct dimensionalities and deliberately not interchangeable: a difference cannot silently be used as an absolute temperature. Do not use {py:meth}`encomp.units.Quantity.check` to distinguish these two cases: it compares physical dimensions, and both classes share `[temperature]`. Use `isinstance()` / {py:func}`encomp.misc.isinstance_types`, typed function boundaries, Pydantic fields, or arithmetic/conversion errors for the semantic distinction. ```python 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 {py:class}`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](https://pint.readthedocs.io/en/stable/advanced/currencies.html) for how to implement a registry context that handles currency conversion correctly. ::: ```python 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")) ``` ### Handling unit-related errors Use `pint.errors.DimensionalityError` to catch all unit-related errors. This error can also be imported from the {py:mod}`encomp.units` module. ```python from typing import Any, cast from encomp.units import DimensionalityError from encomp.units import Quantity as Q from encomp.utypes import Pressure # alternatively, use pint.errors.DimensionalityError # from pint.errors import DimensionalityError try: # a static type checker rejects this addition; the cast lets the runtime # error-handling path be demonstrated. Q(25, "bar") + cast(Any, Q(25, "m")) except DimensionalityError as e: print(f"Error: {e}") try: Q(25, "m").asdim(Pressure) except DimensionalityError as e: print(f"Error: {e}") try: Q(15, "m").to("kg") except DimensionalityError as e: print(f"Error: {e}") ``` ### Integration with Pydantic {py:class}`encomp.units.Quantity` (optionally with a dimensionality type parameter) works as a Pydantic field type. ```python 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 {py:class}`pydantic.TypeAdapter` validation wraps quantity input errors in {py:class}`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 {py:class}`encomp.fluids.Fluid` class represents a fluid at a fixed point. The abstract base class {py:class}`encomp.fluids.CoolPropFluid` implements the CoolProp interface and documents the fluid and property names. All inputs and outputs are {py:class}`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. ```python 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")) # # 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) # # derived properties are nan (and may log a warning about the invalid state) temperature = invalid_inputs.T # nan °C ``` {py:class}`encomp.fluids.Water` omits the fluid name and uses `IAPWS-IF97` (Industrial Formulation 1997). For the `IAPWS-95` reference formulation, use the HEOS backend: {py:class}`encomp.fluids.Fluid` with name `HEOS::Water` (the bare name `water` also resolves to HEOS). The {py:class}`encomp.fluids.HumidAir` class has a different set of input and output properties. ```python 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")) # HumidAir(T=Q(25, "°C"), P=Q(2, "bar"), R=Q(25, "%")) # ``` 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: ```python 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: ```python 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: ```python 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 # (, ) ``` :::{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. ```python 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): ```python 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 {py:meth}`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. ```python 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 {py:class}`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 {py:class}`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). ```python 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 # # different phases phases = Water(T=Q(np.linspace(25, 500, 10), "°C"), P=Q(np.linspace(0.5, 10, 10), "bar")).PHASE # 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] # ``` 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 {py:class}`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). ```python 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 {py:class}`encomp.fluids.Fluid` class (the `encomp.coolprop` package): ```python 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 {py:class}`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 {py:class}`encomp.fluids.Fluid`. `cp.water(output, in1, in2)` is the IF97 water/steam shorthand, standing to `cp.fluid` as {py:class}`encomp.fluids.Water` stands to {py:class}`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 {py:mod}`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 ({py:meth}`encomp.sympy.Symbol.decorate`) These methods return new `sp.Symbol` instances with the same assumptions (*positive*, *real*, *integer*, ...) as the original. ```python 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 {py:meth}`encomp.units.Quantity.from_expr` converts an expression back to a quantity. ```python 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 ``` {py:meth}`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 {py:func}`encomp.units.define_dimensionality`. ::: {py:meth}`encomp.units.Quantity.from_expr` does not support NumPy array magnitudes. Convert the expression to a function with {py:func}`encomp.sympy.get_function` instead: ```python 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). ```python 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. :::