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
23 changes: 22 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,28 @@ assert pressure.to("kPa").m == 100.0
- `Temperature` and `TemperatureDifference` are distinct dimensionalities with
deliberate arithmetic (`T - T -> ΔT`, `T ± ΔT -> T`, `ΔT - T` is an error). The
comparison and pickling overrides on `Quantity` intentionally narrow pint's
signatures — that's the point of the library, not an LSP bug to fix.
signatures — that's the point of the library, not an LSP bug to fix. Two consequences
that are easy to break:
- **The unit decides the dimensionality**, and the two must never contradict each
other. `degC`/`degF` are absolute, `delta_degC`/`delta_K` are differences, and only
the multiplicative spellings (`K`, `degR`, `mK`) are genuinely ambiguous — those
default to `Temperature`. `asdim` rewrites the unit onto the matching scale in both
directions; a `Quantity[...]` hint that disagrees with the unit is re-resolved from
the unit (as for any dimensionality: `Q[Mass](1, "m")` is a `Length`).
- **A difference must never reach an absolute-temperature API.** ΔT converts to `K` by
scale alone, so `.to()` cannot catch it: CoolProp state inputs and
`ideal_gas_density` check the dimensionality explicitly. numpy dispatch does not go
through the operators, so `__array_ufunc__`/`__array_function__` route `np.add`/
`np.subtract` to `+`/`-` and re-type the difference-producing array functions
(`_DIFFERENCE_ARRAY_FUNCTIONS`). Add to that set rather than duplicating the rules.
- **Missing values follow each container's own sentinel**: `NaN` in the numpy world,
`null` in the polars world (where `NaN` is an ordinary float *value* — polars skips
`null` in `mean()` and propagates `NaN`). encomp never *produces* a `NaN` in a polars
magnitude (an unfixable CoolProp state is `null`); a `NaN` the caller puts in a
`pl.Series` is data and is kept verbatim; IEEE arithmetic (`0.0 / 0.0`) may create one
in either world; and `astype` — the boundary between the worlds — translates the
sentinel both ways. Don't normalize magnitudes anywhere else: it would rewrite the
caller's data and cost an O(n) pass per operation.
- CoolProp evaluation is **one property per DAG node** by design; don't batch multiple
output properties into one plugin call. Scalars detach from the GIL in the direct
PyO3 bridge and own thread-local native states; arrays run through the Rust/Polars
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pip install encomp

`encomp` ships as a single per-platform wheel containing its dual-purpose native Rust artifact and one bundled CoolProp shared library, so supported platforms have nothing to build and do not install the Python `CoolProp` package. Wheels are provided for Windows (x86_64), Linux (x86_64 and arm64), and macOS (Apple Silicon only). PyPI does not publish an sdist; unsupported platforms, including Intel Macs, need a build from the git repository; see [Tests](#tests).

On macOS arm64, the native-only package reduces the resolver's combined wheel download from about 19.6 MB (`encomp` plus the separate Python CoolProp wheel) to about 9 MB for `encomp` alone—roughly a 54% reduction. The `encomp` artifact itself grows only slightly for the scalar bridge and bundled license; removing the separate CoolProp wheel is where the installation-size saving comes from.
On macOS arm64, the native-only package reduces the resolver's combined wheel download from about 19.6 MB (`encomp` plus the separate Python CoolProp wheel) to about 10 MB for `encomp` alone—roughly half. The `encomp` artifact itself grows only slightly for the scalar bridge and bundled license; removing the separate CoolProp wheel is where the installation-size saving comes from.

## The `Quantity` class

Expand All @@ -67,6 +67,8 @@ Q([1, 2, 3], "bar") * 2 # [2.0 4.0 6.0] bar
assert Q(0.1) == Q(10, "%")
```

Give a quantity the whole column: a magnitude may be a `float`, a 1-D NumPy array, a Polars `Series` or a Polars `Expr`, and the dimensionality machinery is paid *per operation*, not per element. A scalar operation carries single-digit-microsecond overhead over raw pint arithmetic, which caps a Python loop over scalar quantities at roughly 10⁴–10⁵ ops/s; the same work on a vector magnitude amortizes to within ~20% of the underlying NumPy or Polars kernel at 10⁶ elements. Port scalar pint loops to array/`Series`/`Expr` magnitudes rather than iterating.

### `Quantity` type system

Each `Quantity` has a `Dimensionality` type parameter, determined at runtime from the unit.
Expand Down Expand Up @@ -306,7 +308,7 @@ Raw arithmetic on a unit-typed column is refused because Polars cannot delegate

The persisted Arrow field contract is `ARROW:extension:name = "encomp.unit"` and `ARROW:extension:metadata = <canonical unit>`. Import `encomp.polars`—or `encomp.coolprop`, which registers the same dtype—before reading with Polars 1.x. For readers that do not import encomp, `POLARS_UNKNOWN_EXTENSION_TYPE_BEHAVIOR=load_as_extension` preserves generic extension metadata; otherwise current Polars 1.x warns and loads the numeric storage type.

The Polars extension API is marked unstable upstream, so this integration is experimental even though encomp pins its required behavior in tests.
The Polars extension API is marked unstable upstream, so this integration is experimental even though encomp pins its required behavior in tests. One upstream limitation is worth knowing: Polars' Parquet *statistics* reader has no implementation for extension dtypes, so a predicate that gets pushed into a `pl.scan_parquet(...)` on a unit-typed column aborts with a Rust panic. `QuantityFrame.scan_parquet` shields the scan (at the cost of row-group skipping on those columns), and eager `pl.read_parquet` is unaffected—filter through one of those rather than a bare lazy scan.

## The `Fluid` class

Expand Down Expand Up @@ -416,6 +418,8 @@ df.select(
# composition={species: mole fraction} dict, and a fixed phase via assume_phase='gas'
```

The fluid name, the output property, the input pair and the input units are all validated where the expression is *built*, so a typo raises there rather than inside a plugin node at `collect()` time. What remains deferred is per-row evaluation: a state CoolProp cannot fix yields `null` for that row (`NaN` on the NumPy path), not an error.

### Implementation

Scalar calls reuse a bounded thread-local cache of native `AbstractState` handles; a handle is never shared between threads. Arrays use the batched C API (`AbstractState_update_and_1_out`), with handle-table locking confined to construction and destruction. The flash loop itself runs lock-free in C++, so independent chunks and property expressions can execute in parallel.
Expand Down Expand Up @@ -472,14 +476,14 @@ The attributes of `encomp.settings.Settings` are overridden with a file named `.
Attribute names are prefixed with `ENCOMP_`.
Settings are loaded when `encomp.settings` is imported; for runtime changes to quantity and unit rendering, use `encomp.units.set_quantity_format()`.

Because the `.env` file is resolved relative to the current working directory, a stray `.env` that sets an invalid `ENCOMP_*` value (for example `ENCOMP_UNITS` pointing at a missing file) makes `import encomp` fail with a `pydantic.ValidationError`, even in an unrelated project. Remove or correct the offending value; unrelated keys in the `.env` are ignored.
Because the `.env` file is resolved relative to the current working directory, a stray `.env` that sets an invalid `ENCOMP_*` value (for example `ENCOMP_UNITS` pointing at a missing file) makes importing any `encomp` submodule (such as `encomp.units`) fail with a `pydantic.ValidationError`, even in an unrelated project. The bare `import encomp` still succeeds—it only exposes `__version__`—so the traceback points at the first real import. Remove or correct the offending value; unrelated keys in the `.env` are ignored.

Importing `encomp.units` also registers a `typeguard` checker for `Quantity`, so `@typeguard.typechecked` and `encomp.misc.isinstance_types` compare dimensionality and magnitude type rather than falling back to a plain `isinstance`.

`import encomp` also installs `encomp.units.UNIT_REGISTRY` as pint's process-wide *application registry*. This is deliberate: every quantity in the process must come from that registry, or the dimensionality subclasses, the custom `[currency]` / `[normal]` dimensions and `on_redefinition="raise"` would silently not apply. The consequence is that another pint-based library in the same process gets encomp's registry (and its unit definitions, including the `Nm³` reinterpretation) after `import encomp`. Registry options that encomp pins — `force_ndarray`, `force_ndarray_like`, `autoconvert_offset_to_baseunit` — cannot be reassigned; a write that would change one is discarded and logs a warning.
Importing `encomp.units` (directly, or via any encomp submodule that uses it) also installs `encomp.units.UNIT_REGISTRY` as pint's process-wide *application registry*. This is deliberate: every quantity in the process must come from that registry, or the dimensionality subclasses, the custom `[currency]` / `[normal]` dimensions and `on_redefinition="raise"` would silently not apply. The consequence is that another pint-based library in the same process gets encomp's registry (and its unit definitions, including the `Nm³` reinterpretation) from that point on. Registry options that encomp pins — `force_ndarray`, `force_ndarray_like`, `autoconvert_offset_to_baseunit` — cannot be reassigned; a write that would change one is discarded and logs a warning.

## Documentation

The usage guide, example notebooks, and API reference are at [encomp.readthedocs.io](https://encomp.readthedocs.io).
The usage guide, example notebooks, and API reference are at [encomp.readthedocs.io](https://encomp.readthedocs.io). The [getting-started notebook](https://encomp.readthedocs.io/en/latest/notebooks/getting-started.html) is the shortest path from installation to working code.

Release notes for each version are published as [GitHub Releases](https://github.com/wlaur/encomp/releases).
119 changes: 113 additions & 6 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ 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
Importing `encomp.units` (directly, or via any `encomp` submodule that uses it) 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) from that point on. 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.
:::
Expand Down Expand Up @@ -291,6 +291,53 @@ Q(arr, "bar")
# [0.0 0.2 0.4 0.6000000000000001 0.8 1.0] bar
```

Missing values follow the convention of whichever container holds them: `NaN` in the NumPy
world, `null` in the Polars world (where `NaN` is an ordinary float value, not a missing
marker — `mean()` propagates it while it skips `null`). `encomp` never *produces* a `NaN`
in a Polars magnitude: a CoolProp state that cannot be fixed yields `null` for that row,
and the corresponding NumPy result is `NaN`. {py:meth}`encomp.units.Quantity.astype`
translates between the two spellings at the boundary:

```python
import numpy as np
import polars as pl

from encomp.units import Quantity as Q

# crossing into the Polars world turns the NumPy missing marker into null
assert Q(np.array([1.0, np.nan]), "bar").astype("pl.Series").m.to_list() == [1.0, None]

# and back again, since NumPy has no other spelling for it
assert np.isnan(Q(pl.Series([1.0, None]), "bar").astype("ndarray").m[1])

# a NaN you put in a Series is kept as-is: it is data, not a missing marker
assert Q(pl.Series([1.0, float("nan")]), "bar").m.null_count() == 0
```

The translation is lossless from NumPy and back, but not the other way around: NumPy has a
single spelling for both meanings, so a *data* `NaN` that goes out to NumPy and comes back
returns as missing. The same one-way collapse happens at the CoolProp boundary, where a
`NaN` input is a state that cannot be fixed and the polars result is therefore `null`:

```python
import polars as pl

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

data_nan = Q(pl.Series([1.0, float("nan")]), "bar")
assert data_nan.astype("ndarray").astype("pl.Series").m.to_list() == [1.0, None]

water = Water[pl.Series](P=Q(pl.Series([1e5, 1e5]), "Pa"), T=Q(pl.Series([300.0, float("nan")]), "K"))
density = water.D
assert density.m.null_count() == 1
assert not density.m.is_nan().any()
```

Arithmetic is the exception the rule names explicitly: `0.0 / 0.0` on a polars magnitude
produces a `NaN` in the polars world, exactly as it does in plain polars, because that is
IEEE float arithmetic on data rather than a missing value encomp introduced.

### Quantities with expression magnitudes

Polars Expressions can be used as magnitude:
Expand Down Expand Up @@ -454,6 +501,66 @@ Q(4.19, "kJ/kg/K") * Q(5, "K") # ≈ 20.95 kJ/kg
The environment variable `ENCOMP_AUTOCONVERT_OFFSET_TO_BASEUNIT` can be set to `True` to disable this error (this is not recommended).
:::

Only the *multiplicative* temperature units (`K`, `degR`, `mK`, ...) are ambiguous between
the two dimensionalities; `degC`/`degF` mean absolute and `delta_degC`/`delta_degF` mean a
difference. The unit is therefore what decides the dimensionality, and
{py:meth}`encomp.units.Quantity.asdim` — the explicit reinterpretation — rewrites the unit
so it can never contradict the dimensionality it is paired with:

```python
from encomp.units import Quantity as Q
from encomp.utypes import Temperature, TemperatureDifference

# K expresses both readings, so the unit is unchanged by the reinterpretation
assert Q(300, "K").dt is Temperature
assert Q(300, "K").asdim(TemperatureDifference).u == Q.get_unit("K")

# an offset or delta unit is not ambiguous, so asdim() moves it onto the matching scale
assert Q(5, "delta_degC").asdim(Temperature).u == Q.get_unit("degC")
assert Q(5, "degC").asdim(TemperatureDifference).u == Q.get_unit("delta_degC")

# a Quantity[...] annotation that disagrees with the unit is re-resolved from the unit
assert Q[Temperature, float](5.0, "delta_degC").dt is TemperatureDifference
```

A temperature difference is refused wherever an absolute temperature is required, even
though it converts to `K` by scale alone: {py:class}`encomp.fluids.Fluid` state inputs,
{py:func}`encomp.gases.ideal_gas_density` and the gas-condition tuples all raise
{py:class}`encomp.units.ExpectedDimensionalityError` rather than flash a difference as an
absolute state.

NumPy functions type their results the same way the operators do: `np.subtract(a, b)` is
exactly `a - b`, `np.add(a, b)` is exactly `a + b`, and the functions that return a
*difference* of their input — `np.diff`, `np.ediff1d`, `np.gradient`, `np.ptp`, `np.std` —
return a `TemperatureDifference` for an absolute-temperature input:

```python
import numpy as np

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

temperatures = Q(np.array([300.0, 600.0, 900.0]), "K")

# a difference of absolute temperatures ...
assert isinstance_types(np.diff(temperatures), Q[TemperatureDifference, Numpy1DArray])

# ... while a mean of them is still an absolute temperature
assert isinstance_types(np.mean(temperatures), Q[Temperature, float])
```

NumPy's own type stubs describe these functions as returning arrays, so the dimensionality
of the result is a runtime guarantee rather than a static one — use
{py:func}`encomp.misc.isinstance_types` (as above) or the operators, which are typed.

:::{note}
Adding two absolute temperatures is refused for the offset spellings (`Q(25, "degC") + Q(25, "degC")`
raises `pint.errors.OffsetUnitCalculusError`) but not for the multiplicative ones: `Q(300, "K") + Q(300, "K")`
returns `600 K`, because `K` is a plain scale that pint cannot object to. Summing absolute
temperatures is rarely what you want in either spelling — subtract them, or add a difference.
:::

### Currency units

The dimensionality {py:class}`encomp.utypes.Currency` represents an arbitrary currency.
Expand Down
18 changes: 12 additions & 6 deletions encomp/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,18 @@ def convert_volume_mass(
Input mass or volume (or flow)
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. 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.
result at that position, spelled the way each magnitude container spells missing:
``NaN`` for float and numpy magnitudes, ``null`` for a Polars Series. Because a
NaN would be the wrong spelling on the Polars side -- and would reach the result
as a float value rather than as missing -- a NaN density is rejected when ``inp``
is a Polars magnitude; spell it as a null in a ``pl.Series`` instead. Every
*present* value must be finite and strictly positive. Polars Expr inputs are
deferred and cannot be data-validated here.

This guards the density argument only, which is where NaN means *missing*. A NaN
inside a Polars ``inp`` is ordinary float data (see :class:`encomp.units.Quantity`)
and propagates through the arithmetic as IEEE requires, so the result can carry a
NaN that came from ``inp``.

Returns
-------
Expand Down
4 changes: 2 additions & 2 deletions encomp/coolprop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ from encomp import coolprop as cp
df = pl.DataFrame({"P": [50e5, 60e5], "T": [400.0, 450.0], "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"), # runs in parallel
cp.water("DMASS", "P", "T").alias("rho"), # IF97 water/steam
cp.water("HMASS", "P", "T").alias("h"), # runs in parallel
cp.humid_air("W", "P", "T", "R").alias("humidity_ratio"),
)
```
Expand Down
Loading