Metadata-Version: 2.4
Name: libsalty
Version: 0.1.0
Summary: Fast JAX thermodynamics of water and electrolyte solutions via automatic differentiation
Keywords: thermodynamics,seawater,desalination,IAPWS,IF97,jax,automatic-differentiation
Author: Tim Leshuk
Author-email: Tim Leshuk <tim@h2nano.ca>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Dist: jax>=0.11
Requires-Dist: numpy>=2.0
Requires-Python: >=3.12
Project-URL: Repository, https://github.com/tleshuk/libsalty
Project-URL: Issues, https://github.com/tleshuk/libsalty/issues
Description-Content-Type: text/markdown

# libsalty

Fast JAX thermodynamics of water and electrolyte solutions via automatic
differentiation.

`libsalty` encodes each thermodynamic potential **once**, as a pure scalar
function, and derives *every* property from it with JAX AD. There are no
hand-coded derivative tables anywhere: density is `∂g/∂p`, heat capacity is
`−T ∂²g/∂T²`, chemical potentials are salinity gradients, and equilibrium
curves are implicitly differentiated root solves. Everything composes with
`jax.jit`, `jax.vmap`, and `jax.grad` end-to-end, so you can differentiate
straight through seawater properties — or through an osmotic-pressure solve —
into a process-model optimization.

The v0 target is desalination modeling: IAPWS industrial formulations for
liquid water, steam, and Standard Seawater, vectorized and differentiable.
The API is formulation-agnostic (`water`, `seawater`, `gibbs.derive`); the
IAPWS-specific pieces live one level down (`water.if97`, `water.region1`).

## Quick start

```python
import jax
import jax.numpy as jnp

jax.config.update("jax_enable_x64", True)  # required (guarded at trace time)

from libsalty import seawater, water

# Pure water (IF97), batched: arrays broadcast like NumPy
T = jnp.linspace(300.0, 500.0, 5)
water.if97.h(T, 3.0e6)            # J/kg, auto-dispatched region 1/2
water.region1.rho(T, 3.0e6)       # kg/m^3, region-explicit
water.region4.p_sat(T)            # Pa, saturation line

# Standard Seawater (T [K], p [Pa], S [kg/kg absolute salinity])
seawater.rho(298.15, 101325.0, 0.035)
seawater.state(298.15, 101325.0, 0.035)   # all 12 properties in one pass
seawater.osmotic_coefficient(298.15, 101325.0, 0.035)

# Colligative equilibria: implicitly differentiated root solves
seawater.osmotic_pressure(298.15, 101325.0, 0.035)     # ~2.6 MPa
seawater.boiling_temperature(3.0e4, 0.035)             # boiling-point elevation
jax.grad(seawater.osmotic_pressure, argnums=2)(298.15, 101325.0, 0.035)
# (atmospheric seawater boiling ~374 K sits above AN5's 353 K validity cap,
#  so boiling_temperature(101325.0, S) is honestly NaN / converged=False)

# Everything composes: batched salinity sensitivities, compiled
sens = jax.jit(jax.vmap(jax.grad(seawater.h, argnums=2)))
```

## What's implemented (v0)

| Module | Formulation | Scope |
|---|---|---|
| `water.region1` | IAPWS-IF97 Region 1 (R7-97, 2012) | liquid, 273.15–623.15 K, up to 100 MPa |
| `water.region2` | IF97 Region 2 | steam, up to 1073.15 K / 100 MPa (B23 boundary included) |
| `water.region4` | IF97 Region 4 | saturation line `p_sat(T)` / `T_sat(p)`, closed-form and smooth |
| `water.if97` | dispatcher | auto region 1/2 selection, `region_id`, `validity_mask`; regions 3/5 identified but unsupported |
| `seawater` | IAPWS AN5 (2013) = IF97 water + R13-08 saline Gibbs | Standard Seawater, 261–353 K, 0.3 kPa–100 MPa, S ≤ 0.12 kg/kg |
| `seawater.equilibria` | AN5 Eqs. 6/14 | vapor pressure, boiling temperature, osmotic pressure with status API |
| `gibbs.derive` | generic | the same 12-property suite for *your* Gibbs potential (e.g. fitted surrogates) |

Every property function: `v`, `rho`, `s`, `h`, `u`, `f`, `cp`, `cv`, `w`,
`alpha_v`, `kappa_T`, `gibbs`, plus a `state` bundle that evaluates all of
them from one derivative pass. `seawater` adds `mu`, `mu_w`, `mu_s`, and
`osmotic_coefficient`.

## Units and conventions

Strict SI, everywhere: T [K], p [Pa], S [kg/kg] (absolute salinity,
Reference Composition), outputs in J/kg, m³/kg, kg/m³, J/(kg K), m/s, 1/K,
1/Pa. Uniform argument order `(T, p[, S])`. No unit switches, no kJ.

Reference states are the formulations' own (IF97: u = s = 0 for liquid at
the triple point; seawater: h = s = 0 at 273.15 K, 101325 Pa, S = 0.03516504).
Only *differences* of g, f, h, u, s are physical.

## Validity and NaN semantics

Public functions enforce their formulation's nominal domain and return
**NaN outside — in the value and in AD derivatives of every order**. A plain
`where(valid, x, nan)` would silently report *zero* gradients for invalid
states; libsalty's masking is built so that an invalid state is loudly NaN
under `grad` and `hessian` too, while valid lanes in the same `vmap` batch
are bit-identical to unmasked evaluation (no NaN cross-poisoning).

- `water.if97.validity_mask(T, p)` and `region_id(T, p)` make status
  inspectable; the saturation line belongs to region 1 (liquid).
- The seawater water part uses IF97 region 1 as a metastable liquid below
  273.15 K and above the pure-water saturation pressure, exactly as AN5
  prescribes — through a private unchecked evaluator, never through the
  public IF97 validation.
- In the high-T/high-S corner (T/K + 450·S > 362, R13-08's "region F") the
  extrapolated density derivatives make `w`, `cv`, `alpha_v`, `kappa_T`
  unreliable (e.g. w ≈ 4015 m/s at 353 K, S = 0.1 — a value AN5's own check
  table prints). Values there reproduce the formulation faithfully; treat
  them with the formulation's caveats.

### Zero-salinity contracts

The saline potential behaves like `A·S·ln S + B·S + C·S^{3/2} + …` near
S = 0, so limits are handled per property, analytically — never clamped:

- Every T/p-derived property at S = 0 equals its pure-water value
  **bit-for-bit**, with correct T/p derivatives.
- `mu_w(T, p, 0) == water.region1.gibbs(T, p)` exactly (the log cancellation
  in g − S·g_S is done analytically); `osmotic_coefficient(T, p, 0) == 1.0`
  exactly; `osmotic_pressure(T, p, 0) == 0.0` exactly.
- `mu` and `mu_s` genuinely diverge (∝ ln S) and are non-finite at S = 0 by
  design; salinity derivatives at *exactly* S = 0 read NaN rather than a
  misleading 0 (φ and Π have finite one-sided S→0⁺ limits — evaluate at
  S > 0).

## Equilibrium solvers

`vapor_pressure(T, S)`, `boiling_temperature(p, S)`, and
`osmotic_pressure(T, p, S)` solve chemical-potential balances (AN5 Eqs. 6
and 14) with a fixed-count safeguarded bracketed Newton wrapped in
`lax.custom_root`: gradients come from the implicit function theorem — exact,
cheap, and independent of the iteration count. Brackets and seeds come from
the pure-water saturation line and van 't Hoff.

Each solver has a `*_result` variant returning an `EquilibriumResult(root,
residual, converged)` pytree (jit/vmap-friendly). The plain functions return
NaN wherever `converged` is False — e.g. boiling above the 353 K seawater
limit, vapor pressure pushed below the triple-point pressure, or osmotic
pressure beyond the 100 MPa cap. Check the residual, not just the value:
`converged` requires a sign-change bracket and |residual| ≤ 1e-6 J/kg.

## Bring your own potential

`gibbs.derive(g)` builds the full property suite for any pure scalar
`g(T, p, *comp) -> J/kg` — nothing assumes a polynomial, so fitted Gibbs
surrogates (the long-term direction of this library) get the identical API,
including `state` bundles and transform composability.

```python
from libsalty import gibbs
props = gibbs.derive(my_potential)   # props.h(T, p, x), props.state(...), ...
```

## Verification

- **IAPWS check tables** at ~1e-8 relative: R7-97 Tables 5/15/35/36, AN5
  Table A1 (water/saline/total columns at all three state points), AN5
  Table A2 (all 63 boiling temperatures), R13-08 saline-part checks.
- **Independent lineage**: every coefficient set was cross-diffed against a
  second transcription and/or the `iapws` package before use; an optional
  grid cross-check against `iapws` (regions 1/2, 8 properties, rtol 1e-7)
  runs when `iapws` is installed.
- **AD discipline**: jit≡eager, vmap≡scalar consistency for every public
  function; first- and second-derivative behavior tested at region
  interiors, boundaries, domain edges, and in mixed-validity batches; named
  tests for every S→0 limit and every intentional singularity; implicit
  solver gradients checked against central differences.

204 tests. Maxwell relations hold by construction — AD guarantees them.

## Performance

`benchmarks/benchmark.py`, Apple Silicon CPU, float64, jax 0.11. Cold JIT
(compile + first call) is a one-time cost per function signature:
0.2–1.5 s here. Warm, batched:

| function | ns/state (batch 100k) |
|---|---|
| `water.region1.h` | ~105 |
| `water.if97.h` (dispatched) | ~235 |
| `seawater.h` | ~290 |
| `seawater.state` (12 properties) | ~710 |
| `grad_S seawater.h` (vmapped) | ~150 |
| `seawater.osmotic_pressure` (full root solve) | ~5,000 |

The `iapws` package's scalar `_Region1` call — a practical pure-Python
baseline, not a claim against optimized IF97 implementations — costs
~26,000 ns per state on the same machine: roughly **250×** for batched
enthalpy, with derivatives included nearly for free. Single scalar calls
through jit are dispatch-dominated (~10–60 µs); batch when you can.

## Development

```sh
uv sync                      # includes dev deps (pytest, ruff, pyrefly, iapws)
uv run pytest                # 204 tests
uv run ruff check src tests && uv run ruff format src tests
uv run pyrefly check src tests
uv run python benchmarks/benchmark.py
```

Requires Python ≥ 3.12 and JAX ≥ 0.11. libsalty never mutates global JAX
config; it *requires* x64 mode and raises an instructive error at trace
time if it is off.

## Roadmap

IF97 regions 3/5 and backward equations; freezing/ice Ih (IAPWS R10);
transport properties; a `helmholtz` module (density solve behind the same
property surface) as the gateway to SAFT-family and fitted surrogate
potentials for arbitrary saline solutions.

## Acknowledgements

The implementation was written by Claude (Fable 5, via
[Claude Code](https://claude.com/claude-code)), working from a
human-authored design plan, with every phase human-reviewed and gated on
the IAPWS check tables and the full test suite before merge.

## References

- IAPWS R7-97(2012), *Revised Release on the IAPWS Industrial Formulation
  1997 for the Thermodynamic Properties of Water and Steam*.
- IAPWS R13-08, *Release on the IAPWS Formulation 2008 for the
  Thermodynamic Properties of Seawater*.
- IAPWS AN5-13, *Advisory Note No. 5: Industrial Calculation of the
  Thermodynamic Properties of Seawater* (2013).
