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
88 changes: 81 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ name: encomp CI/release
# plus source-only doc/static tests
# docs-rtd -> rebuild the docs in a ReadTheDocs-equivalent environment (no project
# install, no compiled plugin) so an RTD-only break fails the PR
# oracle-parity -> install Python CoolProp only as a test oracle and compare it with
# the bundled implementation
# free-threaded-smoke -> visible, non-publishing Python 3.14t readiness probe; allowed
# to fail until the released dependency stack supports it completely
# dependency-floors -> install at the LOWEST versions the declared bounds allow and run the
# suite, so a too-low bound fails here rather than on a user's machine
# rust -> cargo fmt, clippy, and in-crate tests
Expand Down Expand Up @@ -96,6 +100,24 @@ jobs:
READTHEDOCS: "True"
run: uv run --no-sync python -m sphinx -T -W --keep-going -b html docs docs/_build/html

oracle-parity:
name: bundled CoolProp parity oracle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: "3.13"
- name: Install development tools and the test-only Python CoolProp oracle
run: uv sync --locked --no-install-project --group oracle
- name: Build the one bundled CoolProp implementation and native artifact
run: |
python -m pip install cmake
uv run --no-sync python scripts/build_libcoolprop.py
uv run --no-sync maturin develop --release
- name: Compare native scalar/plugin results with Python CoolProp 8.0.0
run: uv run --no-sync pytest encomp/tests/test_coolprop_plugin.py encomp/tests/test_rust_parity.py -q

dependency-floors:
# The `test` job below installs the wheel's dependencies from PyPI at their LATEST
# versions, so it can never notice that a declared lower bound is too low. Resolve the
Expand Down Expand Up @@ -157,12 +179,16 @@ jobs:
import importlib.metadata as metadata
from pathlib import Path

names = ("encomp", "pint", "pydantic", "typeguard", "typing_extensions", "numpy", "polars", "sympy", "uncertainties", "coolprop")
names = ("encomp", "pint", "pydantic", "typeguard", "typing_extensions", "numpy", "polars", "sympy", "uncertainties")
for name in names:
print(f"{name:20s} {metadata.version(name)}")

import importlib.util
import encomp.coolprop as cp

assert importlib.util.find_spec("CoolProp") is None, "Python CoolProp must not be installed"
assert not any(r.lower().startswith("coolprop") for r in (metadata.requires("encomp") or []))

package = Path(cp.__file__).parent
print(f"\nencomp.coolprop imported from {package}")
for path in sorted(package.iterdir()):
Expand Down Expand Up @@ -195,6 +221,44 @@ jobs:
- run: cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings
- run: PYO3_PYTHON="$(which python)" cargo test --manifest-path rust/Cargo.toml --no-default-features

free-threaded-smoke:
name: Python 3.14t smoke (experimental)
runs-on: ubuntu-latest
# This preparatory signal must not block normal-GIL releases. Python 3.15t wheels
# remain gated on a released Polars/PyO3 stack and the full verification in PLAN.md.
continue-on-error: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: "3.14t"
- name: Install and build without the Python CoolProp package
run: |
python -m pip install cmake
uv sync --locked --no-install-project
uv run --no-sync python scripts/build_libcoolprop.py
uv run --no-sync maturin develop --release
- name: Keep the GIL disabled while importing and exercising both native interfaces
env:
PYTHON_GIL: "0"
run: |
uv run --no-sync python -W error - <<'PY'
import importlib.util
import sys

import polars as pl

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

assert not sys._is_gil_enabled()
assert importlib.util.find_spec("CoolProp") is None
assert Water(P=Q(5e6, "Pa"), T=Q(400.0, "K")).D.m > 900.0
assert pl.DataFrame({"P": [5e6], "T": [400.0]}).select(cp.water("DMASS", "P", "T"))[0, 0] > 900.0
assert not sys._is_gil_enabled()
PY

build-wheels:
name: build ${{ matrix.os }}
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -240,16 +304,26 @@ jobs:
shell: bash
run: |
python -m pip install --upgrade pip
# install the PREBUILT encomp wheel (abi3 cp313 installs on 3.14 too) by
# name so it is not rebuilt from source; deps + test deps come from PyPI
version=$(python -c 'from pathlib import Path; wheels = sorted(Path("wheelhouse").glob("encomp-*.whl")); assert len(wheels) == 1, f"expected exactly one encomp wheel, found {len(wheels)}"; print(wheels[0].name.split("-")[1])')
pip install --find-links wheelhouse "encomp==$version" pytest hypothesis
python -c "from encomp import coolprop as cp; assert cp.self_check(), 'plugin self_check failed'"
# Install the downloaded artifact by its exact path. PR builds deliberately
# retain the current released version, so asking pip for `encomp==$version`
# can select that same-version wheel from PyPI instead of this candidate.
wheel=$(python -c 'from pathlib import Path; wheels = sorted(Path("wheelhouse").glob("encomp-*.whl")); assert len(wheels) == 1, f"expected exactly one encomp wheel, found {len(wheels)}"; print(wheels[0])')
pip install "$wheel" pytest hypothesis
python - <<'PY'
import importlib.metadata as metadata
import importlib.util

from encomp import coolprop as cp

assert cp.self_check(), "plugin self_check failed"
assert importlib.util.find_spec("CoolProp") is None, "Python CoolProp was unexpectedly installed"
assert not any(r.lower().startswith("coolprop") for r in (metadata.requires("encomp") or []))
PY
python -m pytest --pyargs encomp.tests -q

publish:
name: publish to PyPI
needs: [typecheck, docs-rtd, dependency-floors, rust, build-wheels, test]
needs: [typecheck, docs-rtd, oracle-parity, dependency-floors, rust, build-wheels, test]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: pypi # configure the Trusted Publisher against this env
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ assert pressure.to("kPa").m == 100.0
comparison and pickling overrides on `Quantity` intentionally narrow pint's
signatures — that's the point of the library, not an LSP bug to fix.
- CoolProp evaluation is **one property per DAG node** by design; don't batch multiple
output properties into one plugin call. All CoolProp paths hold the GIL the Rust
plugin (`rust/`) exists precisely to evaluate without it; don't add Python-side
parallelism around CoolProp.
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
plugin. Don't add Python-side parallelism around CoolProp.

## Tooling

Expand Down
20 changes: 8 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ The remaining modules (`encomp.gases`, `encomp.conversion`, `encomp.constants`,
pip install encomp
```

`encomp` ships as a single per-platform wheel that bundles the compiled Rust plugin and the CoolProp shared library, so supported platforms have nothing to build. 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).
`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 the macOS arm64 wheel measured for the native-only change, the resolver's combined wheel download falls from 19.61 MB (`encomp` plus the separate Python CoolProp wheel) to 9.08 MB for `encomp` alone, a 53.7% reduction. The `encomp` artifact itself grows by about 35 KB for the scalar bridge and bundled license; removing the separate 10.57 MB wheel is where the installation-size saving comes from.

## The `Quantity` class

Expand Down Expand Up @@ -378,7 +380,7 @@ w: Water[pl.Expr] = Water(P=Q(pl.col("P"), "Pa"), T=Q(pl.col("T"), "K"))
df.select(w.D.m.alias("rho"), w.H.m.alias("h"), w.S.m.alias("s"))
```

`pl.Expr` (lazy) inputs are evaluated exclusively through the plugin (there is no `map_batches` fallback). Eager `float` / NumPy / `pl.Series` inputs use the Python CoolProp path, except arrays of at least `EAGER_PLUGIN_MIN_SIZE` (1000) elements, which also route through the plugin. The two paths are verified to agree on value, `NaN`/null handling, and dtype; results are bit-identical when the installed `coolprop` matches the bundled build (8.0.0).
`encomp` contains one thermodynamic implementation: the bundled CoolProp shared library. Scalars call it through a direct PyO3 bridge with a bounded per-thread state cache; NumPy arrays, `pl.Series`, and `pl.Expr` inputs use the batched Polars plugin path at every size. Both private interfaces live in the same `_internal` artifact and share the same loaded library. The Python `CoolProp` package is not installed or imported. `EAGER_PLUGIN_MIN_SIZE` remains temporarily as a deprecated no-op for compatibility.

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

Expand All @@ -402,15 +404,9 @@ df.select(

### Implementation

The GIL is not the only serialization point: the CoolProp C-API takes a global handle-table lock on every call, so per-row calls serialize even in Rust. The plugin uses the batched C-API (`AbstractState_update_and_1_out`): one call per chunk, the handle lock taken once at construction, then the flash loop runs lock-free in C++. Independent chunks and independent property expressions parallelize.

Benchmarks (CoolProp 8.0, 14-thread pool):
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.

| workload | vs `map_batches` | notes |
| --- | --- | --- |
| single property `D`, 1M rows | ~2.1x | also ~2x faster than vectorized `PropsSI` |
| 4 independent properties, one `collect()`, 1M rows | ~4.6x | `map_batches` is serial on the GIL; the plugin runs ~4 cores |
| 8 enthalpy evaluations, 1M rows | ~4.9x | ~6 cores vs 1, roughly half the peak memory |
On the normal-GIL macOS benchmark used for this change, public scalar calls improved by 4-33%. The existing 100k-row HEOS array path changed from 470.49 ms to 469.92 ms, while three independent properties changed from 534.06 ms to 537.51 ms (+0.6%). See `encomp/coolprop/README.md` and `scripts/benchmark_coolprop.py` for the full reproducible measurements.

Each `fluid(...)` / `humid_air(...)` is an independent plugin node, so selecting *K* properties of one state runs *K* flashes of it — Polars cannot reuse the shared flash across opaque plugin nodes. Independent properties still parallelize, so this is total work, not wall-clock. See `encomp/coolprop/README.md` for the design, thread-safety model, and caveats.

Expand Down Expand Up @@ -439,15 +435,15 @@ For array magnitudes, convert the expression to a NumPy-aware function with `enc

## Tests

Development checkouts build the native CoolProp plugin locally. Install Rust, CMake, git, and a C++ compiler, then from the repository root run:
Development checkouts build the dual-purpose native CoolProp artifact locally. Install Rust, CMake, git, and a C++ compiler, then from the repository root run:

```bash
python scripts/build_libcoolprop.py
uv sync --all-extras --all-groups
uv run pytest
```

See `encomp/coolprop/README.md` for the plugin build details.
See `encomp/coolprop/README.md` for native build details.

The test suite ships inside the wheel, so an installed `encomp` doubles as its own post-install smoke test (it needs only `pytest` and `hypothesis`):

Expand Down
4 changes: 2 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ density = Fluid("HEOS::CO2[0.7]&O2[0.3]", P=Q(10, "bar"), T=Q(300, "K")).assume_

### Using vector inputs

CoolProp evaluates vector inputs in a single backend call.
CoolProp evaluates vector inputs through encomp's batched native plugin path.
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
Expand Down Expand Up @@ -757,7 +757,7 @@ Missing or out-of-range results surface as `NaN` (for a numpy magnitude) or `nul
### 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).
`encomp` bundles CoolProp once. Scalar inputs use a direct PyO3 bridge with per-thread cached native states; NumPy arrays, `pl.Series`, and `pl.Expr` inputs use the batched plugin at every size (there is no Python `CoolProp` or `map_batches` fallback). `EAGER_PLUGIN_MIN_SIZE` is retained only as a deprecated no-op.

```python
import polars as pl
Expand Down
21 changes: 21 additions & 0 deletions encomp/coolprop/LICENSE.CoolProp
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2012-2018 Ian H. Bell and other CoolProp developers

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading