Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,22 @@ jobs:
# Python: a bound may legitimately name a release that predates 3.13 and ships no wheel
# for it (pydantic 2.4 is the floor for the core-schema API, but its pydantic-core has
# no cp313 wheel). Resolving to a version nobody can install would test nothing.
name: dependency floors
#
# The matrix covers the OLDEST and NEWEST supported Python: a pure-Python wheel
# installs on any Python, so --only-binary cannot stop a floor that resolves on
# every supported Python but only works on some of them (e.g. one that uses a
# stdlib API the newest Python removed).
name: dependency floors (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.13", "3.14"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: "3.13"
python-version: ${{ matrix.python-version }}
- name: Build the bundled CoolProp library
run: |
python -m pip install cmake
Expand All @@ -126,9 +135,9 @@ jobs:
# and then run doc snippets against the source tree, which has no compiled plugin.
# Outside it, they skip -- exactly as they do for someone who pip-installed a wheel.
run: |
uv pip compile pyproject.toml --python-version 3.13 --resolution lowest-direct --only-binary=:all: -o floors.txt
uv pip compile pyproject.toml --python-version ${{ matrix.python-version }} --resolution lowest-direct --only-binary=:all: -o floors.txt
cat floors.txt
uv venv "$RUNNER_TEMP/floors-venv" --python 3.13
uv venv "$RUNNER_TEMP/floors-venv" --python ${{ matrix.python-version }}
PY=$RUNNER_TEMP/floors-venv/bin/python
uv pip install --python "$PY" -r floors.txt
uv pip install --python "$PY" --no-deps .
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Documentation](https://readthedocs.org/projects/encomp/badge/?version=latest)](https://encomp.readthedocs.io)
[![License](https://img.shields.io/pypi/l/encomp.svg)](https://github.com/wlaur/encomp/blob/main/LICENSE)

<img src="https://raw.githubusercontent.com/wlaur/encomp/v1.7.0/docs/img/logo.png" alt="encomp logo" width="150">
<img src="https://raw.githubusercontent.com/wlaur/encomp/main/docs/img/logo.png" alt="encomp logo" width="150">

> General-purpose library for *en*gineering *comp*utations.

Expand All @@ -18,7 +18,7 @@ Every physical quantity in `encomp` carries a magnitude, a unit, and a dimension

- **`Quantity`** (`encomp.units`, `encomp.utypes`) extends [pint](https://pypi.org/project/Pint): each dimensionality (pressure, mass flow, density, ...) is a distinct subclass of `Quantity`. Dividing a `Mass` by a `Volume` gives a `Density`, inferred by static type checkers and verified at runtime. Magnitudes can be scalars, NumPy arrays, Polars `Series`, or Polars `Expr`.

- **Static unit checking.** Common dimensionalities and their `*` / `/` / `**` combinations are encoded as `__new__` overloads, so a type checker flags a `Temperature` passed where a `Power` is expected. `@typeguard.typechecked` extends the same checks to runtime.
- **Static unit checking.** Common dimensionalities are encoded as `Quantity.__new__` overloads, and their `*` / `/` / `**` combinations as arithmetic-operator overloads, so a type checker flags a `Temperature` passed where a `Power` is expected. `@typeguard.typechecked` extends the same checks to runtime.

- **`Fluid` / `Water` / `HumidAir`** (`encomp.fluids`) wrap [CoolProp](http://www.coolprop.org): fix a state with two points (three for humid air) and read any property as an attribute. Inputs and outputs are `Quantity` objects, so units are converted and validated automatically.

Expand All @@ -32,7 +32,7 @@ The remaining modules (`encomp.gases`, `encomp.conversion`, `encomp.constants`,

## Versioning and stability

`encomp` uses semantic versioning for documented public APIs. 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` 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide covers the main `encomp` modules: units, fluids, and symbolic math.

## Versioning and stability

`encomp` uses semantic versioning for documented public APIs. 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` 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.

Expand Down
2 changes: 2 additions & 0 deletions encomp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ def standard_conditions_temperature(self) -> Quantity[Temperature, float]:


CONSTANTS = Constants()
"""Singleton :class:`Constants` instance; every attribute access returns a fresh
:class:`~encomp.units.Quantity`."""
20 changes: 18 additions & 2 deletions encomp/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def convert_volume_mass(
rho : Quantity[Density, Any]
Density of the substance. A *missing* density is allowed and yields a missing
result at that position, exactly as a missing ``inp`` does: ``NaN`` for float and
numpy magnitudes, ``null`` for a Polars Series. Every *present* value must be
numpy magnitudes, ``null`` for a Polars Series. A Polars result carries null
(never NaN), so with a Polars ``inp`` a NaN density is rejected -- spell the
missing density as a null in a ``pl.Series``. Every *present* value must be
finite and strictly positive. Polars Expr inputs are deferred and cannot be
data-validated here.

Expand All @@ -62,15 +64,29 @@ def convert_volume_mass(
if not isinstance_types(rho, Quantity[Density, Any]):
raise ExpectedDimensionalityError(f"rho must be a Quantity[Density], passed {rho!r} ({type(rho).__name__})")

# a NaN density means "missing" for float/numpy data, but a polars result carries null,
# never NaN -- and a float/ndarray density has no null spelling. A NaN there would flow
# into the Series/Expr result as NaN, so it is rejected when the input is polars
inp_is_polars = isinstance(inp.m, (pl.Series, pl.Expr))
nan_rho_message = (
"rho must not be NaN when inp has a Polars magnitude; "
"pass rho as a pl.Series with null for a missing density, got {rho!r}"
)

rho_m = rho.to("kg/m³").m
if isinstance(rho_m, float):
if not np.isnan(rho_m) and (not np.isfinite(rho_m) or rho_m <= 0.0):
if np.isnan(rho_m):
if inp_is_polars:
raise ValueError(nan_rho_message.format(rho=rho))
elif not np.isfinite(rho_m) or rho_m <= 0.0:
raise ValueError(f"rho must be finite and positive, got {rho!r}")
elif isinstance(rho_m, np.ndarray):
rho_arr = cast("Numpy1DArray", rho_m)
present = ~np.isnan(rho_arr)
if bool(np.any(present & (~np.isfinite(rho_arr) | (rho_arr <= 0.0)))):
raise ValueError(f"rho must contain only finite positive values, got {rho!r}")
if inp_is_polars and not bool(present.all()):
raise ValueError(nan_rho_message.format(rho=rho))
elif isinstance(rho_m, pl.Series):
# null is the missing sentinel in the polars world and propagates through the
# arithmetic below; a NaN there would leak into the result, where polars magnitudes
Expand Down
2 changes: 1 addition & 1 deletion encomp/coolprop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ PYO3_PYTHON=$(pwd)/.venv/bin/python cargo test --manifest-path rust/Cargo.toml -

## Files

- `encomp/coolprop/__init__.py` — public Python API (`fluid`, `humid_air`).
- `encomp/coolprop/__init__.py` — public Python API (`fluid`, `water`, `humid_air`).
- `rust/src/lib.rs` — the `cp_evaluate` / `ha_evaluate` plugin expressions (crate at repo root).
- `rust/src/coolprop.rs` — the CoolProp C-API bindings + thread-safety model (all `unsafe`).
- `scripts/build_libcoolprop.py` — builds + bundles CoolProp's shared library.
16 changes: 13 additions & 3 deletions encomp/coolprop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
# CoolProp AbstractState backends (HEOS is the general-purpose mixture EOS; IF97 is water/steam).
Backend = Literal["HEOS", "IF97", "REFPROP", "SRK", "PR", "PCSAFT", "VTPR", "INCOMP", "BICUBIC&HEOS", "TTSE&HEOS"]
BACKENDS: frozenset[Backend] = frozenset(get_args(Backend))
"""Runtime set of the CoolProp ``AbstractState`` backend names in :data:`Backend`."""

# A few common CoolProp fluid names surfaced as Literals so editors can suggest them;
# ANY CoolProp name string is still accepted (the union widens to ``str``). The name may
Expand Down Expand Up @@ -131,6 +132,7 @@
"phase_critical_point",
]
PHASES: frozenset[Phase] = frozenset(get_args(Phase))
"""Runtime set of the low-level CoolProp ``phase_*`` strings in :data:`Phase`."""

# User-facing assumed-phase names (matches encomp.fluids.Fluid.assume_phase). Each maps
# to the CoolProp ``phase_*`` string (the ``Phase`` literal) via ``_phase_from_assumed``;
Expand All @@ -144,13 +146,15 @@
"twophase",
]
ASSUMED_PHASES: frozenset[AssumedPhase] = frozenset(get_args(AssumedPhase))
"""Runtime set of the user-facing assumed-phase names in :data:`AssumedPhase`."""

# backends that ignore AbstractState.specify_phase because they are region-explicit: assuming a
# phase has no effect there. Mirrors encomp.fluids._PHASE_IGNORING_BACKENDS.
PHASE_IGNORING_BACKENDS: frozenset[str] = frozenset({"IF97"})
"""Region-explicit backends that ignore ``AbstractState.specify_phase``: assuming a phase has
no effect there, so :func:`fluid` logs a warning when ``assume_phase`` names one of these.
Mirrors the set :class:`encomp.fluids.Fluid` uses."""

# the fluid name behind encomp.fluids.Water, and the one `water()` evaluates
WATER_NAME: CommonFluidName = "IF97::Water"
"""The fluid name behind :class:`encomp.fluids.Water`, and the one :func:`water` evaluates."""

# CoolProp fluid parameters (outputs / the two state-input names): canonical names
# plus the common single-letter aliases.
Expand Down Expand Up @@ -296,6 +300,7 @@
"viscosity",
]
FLUID_PARAMS: frozenset[FluidParam] = frozenset(get_args(FluidParam))
"""Runtime set of the CoolProp fluid parameter names in :data:`FluidParam`."""

# CoolProp fluid STATE-INPUT properties: the subset of FluidParam valid as the two
# inputs that fix a state -- pressure/temperature/quality + density/enthalpy/entropy/
Expand All @@ -309,6 +314,8 @@
"U", "UMASS", "UMOLAR", "Umass", "Umolar",
] # fmt: skip
FLUID_INPUTS: frozenset[FluidInput] = frozenset(get_args(FluidInput))
"""Runtime set of the fluid state-input names in :data:`FluidInput` -- the subset of
:data:`FluidParam` that can fix a state."""

# Common HumidAir (HAPropsSI) parameters.
HumidAirParam = Literal[
Expand Down Expand Up @@ -360,6 +367,7 @@
"psi_w",
]
HUMID_AIR_PARAMS: frozenset[HumidAirParam] = frozenset(get_args(HumidAirParam))
"""Runtime set of the ``HAPropsSI`` parameter names in :data:`HumidAirParam`."""

# HumidAir (HAPropsSI) STATE-INPUT properties: the subset valid as the three inputs.
HumidAirInput = Literal[
Expand All @@ -369,6 +377,8 @@
"S", "Sda", "Sha", "Entropy", "V", "Vda", "Vha", "P", "P_w", "Y",
] # fmt: skip
HUMID_AIR_INPUTS: frozenset[HumidAirInput] = frozenset(get_args(HumidAirInput))
"""Runtime set of the ``HAPropsSI`` state-input names in :data:`HumidAirInput` -- the subset
of :data:`HumidAirParam` that can fix a state."""

_HUMID_AIR_INPUT_ALIASES: tuple[tuple[HumidAirInput, ...], ...] = (
("T", "Tdb", "T_db"),
Expand Down
12 changes: 6 additions & 6 deletions encomp/fluids.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ class HumidAirState(TypedDict, Generic[MT], total=False): # noqa: UP046
# phase has no effect and should not switch evaluation to the slower low-level loop
_PHASE_IGNORING_BACKENDS = frozenset({"IF97"})

# Eager numpy / pl.Series inputs with at least this many elements are evaluated
# through the GIL-free rust plugin (faster and lower peak memory than CoolProp's
# vectorized PropsSI, and much faster than the low-level per-row Python loop for an
# assumed phase / composition). Scalars and smaller arrays stay on the Python
# CoolProp path, where the plugin's fixed dispatch overhead would dominate. The two
# paths are verified to agree on dtype, value, and NaN/inf handling (test_fluids).
EAGER_PLUGIN_MIN_SIZE = 1000
"""Eager numpy / ``pl.Series`` inputs with at least this many elements are evaluated
through the GIL-free rust plugin (faster and lower peak memory than CoolProp's
vectorized ``PropsSI``, and much faster than the low-level per-row Python loop for an
assumed phase / composition). Scalars and smaller arrays stay on the Python CoolProp
path, where the plugin's fixed dispatch overhead would dominate. The two paths are
verified to agree on dtype, value, and NaN/inf handling (``test_fluids``)."""

# Caches the CONSTRUCTED pl.Expr per (fluid config, output, input-expr digests), so
# repeated evaluations of the same property return the IDENTICAL expression object.
Expand Down
6 changes: 3 additions & 3 deletions encomp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class Settings(BaseSettings):
)


# The settings object is initialized the first time the library loads. Some
# consumers read values during their own import/initialization, so do not treat
# attribute assignment on this instance as a general runtime configuration API.
SETTINGS = Settings()
"""Singleton :class:`Settings` instance, initialized the first time the library loads.
Some consumers read values during their own import/initialization, so attribute
assignment on this instance is not a general runtime-configuration API."""
46 changes: 44 additions & 2 deletions encomp/tests/test_comparisons.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,12 @@ def test_values_within_tolerance_are_ties_and_sort_stably() -> None:


_INF = float("inf")
_NAN = float("nan")

# pairs that straddle the tolerance band, plus the non-finite corners
# pairs that straddle the tolerance band, plus the non-finite corners. NaN appears only
# paired with itself: numpy and polars deliberately disagree on ordering NaN against a
# NUMBER (see test_numpy_and_polars_disagree_on_nan_ordering), but nan-vs-nan answers
# identically everywhere (never equal, never ordered)
_COMPARISON_PAIRS = [
(1.0, 1.0),
(0.0, 0.0),
Expand All @@ -178,6 +182,7 @@ def test_values_within_tolerance_are_ties_and_sort_stably() -> None:
(-_INF, -_INF),
(1.0, _INF),
(-_INF, _INF),
(_NAN, _NAN),
]


Expand Down Expand Up @@ -258,15 +263,42 @@ def test_comparing_numpy_and_polars_magnitudes_raises() -> None:
series = Q(pl.Series([1.0]), "m")
expr = Q(pl.col("x"), "m")

for lhs, rhs in ((array, series), (series, array), (array, expr), (expr, array)):
for lhs, rhs in ((array, series), (series, array)):
for op in _OPERATORS.values():
with pytest.raises(TypeError, match="convert one side first"):
op(lhs, rhs)

# when a pl.Expr is involved there is no data to convert on that side, so the
# message points at evaluating the expression or lifting the other operand instead
for lhs, rhs in ((array, expr), (expr, array)):
for op in _OPERATORS.values():
with pytest.raises(TypeError, match="lift the other side"):
op(lhs, rhs)

# the escape hatch is explicit conversion
assert (array.astype("pl.Series") == series).to_list() == [True]


def test_comparing_series_and_expr_magnitudes_raises() -> None:
# Series-vs-Expr is equally outside the typed API: polars' raw operator would lift the
# Series into an Expr literal and compare EXACTLY, skipping the (rtol, atol) tolerance
# every sanctioned path applies, with a length mismatch surfacing only at collect()
# time as a ShapeError pointing into the plan -- so the runtime refuses this pair too
series = Q(pl.Series([1.0]), "m")
expr = Q(pl.col("x"), "m")

for lhs, rhs in ((series, expr), (expr, series)):
for op in _OPERATORS.values():
with pytest.raises(TypeError, match="lift the other side"):
op(lhs, rhs)

# the escape hatch is an explicit literal lift, which states the intent and lands on
# the sanctioned (tolerant) Expr-vs-Expr path
df = pl.DataFrame({"x": [1.0 + 5e-10, 2.0]})
lifted = Q(pl.lit(pl.Series([1.0, 2.0])), "m")
assert df.select((expr == lifted).alias("r"))["r"].to_list() == [True, True]


def test_numpy_and_polars_disagree_on_nan_ordering() -> None:
# numpy follows IEEE: every comparison against NaN is False. polars orders NaN as the largest
# value. Neither is wrong, but they are not interchangeable -- which is why the two worlds are
Expand All @@ -284,6 +316,16 @@ def test_numpy_and_polars_disagree_on_nan_ordering() -> None:
assert (series > one).to_list() == [True]
assert (series <= one).to_list() == [False]

# nan-vs-nan is the one NaN case the worlds AGREE on: polars' raw >= calls NaN equal
# to NaN (total order), but the non-strict operators are derived as `strict or equal`
# with encomp's tolerant __eq__ (NaN is never equal), so every container answers
# False for all four orderings -- `ge == (gt or eq)` holds even here
other_nan = Q(pl.Series([nan]), "m")
assert (series >= other_nan).to_list() == [False]
assert (series <= other_nan).to_list() == [False]
assert (series > other_nan).to_list() == [False]
assert (series == other_nan).to_list() == [False]

# a null magnitude propagates as null in both directions
with_null = Q(pl.Series([1.0, None]), "m")
assert (with_null > one).to_list() == [False, None]
Expand Down
19 changes: 19 additions & 0 deletions encomp/tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,22 @@ def test_convert_volume_mass_polars_series_density() -> None:
# a null density is missing, not invalid: it propagates
nulled = convert_volume_mass(mass, rho=Q(pl.Series([1000.0, None]), "kg/m³"))
assert nulled.to("m³").m.to_list() == [0.002, None]


def test_nan_density_is_rejected_for_polars_inputs() -> None:
# a polars result carries null, never NaN, and a float/ndarray density has no null
# spelling -- so a NaN density cannot express "missing" when the input is polars
mass_series = Q(pl.Series([2.0, 4.0]), "kg")
mass_expr = Q(pl.col("m"), "kg")

with pytest.raises(ValueError, match="null for a missing density"):
convert_volume_mass(mass_series, rho=Q(float("nan"), "kg/m³"))

with pytest.raises(ValueError, match="null for a missing density"):
convert_volume_mass(mass_expr, rho=Q(float("nan"), "kg/m³"))

with pytest.raises(ValueError, match="null for a missing density"):
convert_volume_mass(mass_series, rho=Q(np.array([1000.0, np.nan]), "kg/m³"))

# the same missing density is fine for float/numpy inputs, where NaN IS the sentinel
assert np.isnan(convert_volume_mass(Q(2.0, "kg"), rho=Q(float("nan"), "kg/m³")).m)
Loading
Loading