co2-eos 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {co2_eos-0.2.0 → co2_eos-0.3.1}/PKG-INFO +2 -2
  2. {co2_eos-0.2.0 → co2_eos-0.3.1}/README.md +1 -1
  3. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/__init__.py +1 -1
  4. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/core.py +57 -31
  5. co2_eos-0.3.1/co2_eos/data/chi_ref_cheb.npz +0 -0
  6. co2_eos-0.3.1/co2_eos/data/seed_table.npz +0 -0
  7. co2_eos-0.3.1/co2_eos/helmholtz.py +449 -0
  8. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/inversions.py +87 -27
  9. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/transport.py +85 -19
  10. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos.egg-info/PKG-INFO +2 -2
  11. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos.egg-info/SOURCES.txt +4 -0
  12. {co2_eos-0.2.0 → co2_eos-0.3.1}/pyproject.toml +1 -1
  13. co2_eos-0.3.1/tests/test_density_from_PT_branch.py +113 -0
  14. co2_eos-0.3.1/tests/test_fastpath_validation.py +92 -0
  15. co2_eos-0.3.1/tests/test_map_fresh.py +27 -0
  16. co2_eos-0.2.0/co2_eos/data/seed_table.npz +0 -0
  17. co2_eos-0.2.0/co2_eos/helmholtz.py +0 -337
  18. {co2_eos-0.2.0 → co2_eos-0.3.1}/LICENSE +0 -0
  19. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/data/saturation_table.npz +0 -0
  20. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/saturation.py +0 -0
  21. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos/span_wagner.py +0 -0
  22. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos.egg-info/dependency_links.txt +0 -0
  23. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos.egg-info/requires.txt +0 -0
  24. {co2_eos-0.2.0 → co2_eos-0.3.1}/co2_eos.egg-info/top_level.txt +0 -0
  25. {co2_eos-0.2.0 → co2_eos-0.3.1}/setup.cfg +0 -0
  26. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_accuracy_vs_coolprop.py +0 -0
  27. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_analytic_derivs.py +0 -0
  28. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_dome_detection.py +0 -0
  29. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_inversion_gradients.py +0 -0
  30. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_no_coolprop_runtime.py +0 -0
  31. {co2_eos-0.2.0 → co2_eos-0.3.1}/tests/test_rho_u_inversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: co2-eos
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Differentiable CO₂ thermodynamic properties in JAX
5
5
  Author-email: John Patrick Therrien <john@flux-tech.de>
6
6
  License-Expression: Apache-2.0
@@ -83,7 +83,7 @@ But CoolProp is a C++ library with Python bindings. You can't `jax.grad` through
83
83
  CO2-EOS solves this for CO₂ by implementing the same reference EOS (Span-Wagner 1996) directly in JAX:
84
84
 
85
85
  - **Differentiable.** `jax.grad` through any property, any inversion, any combination. No finite differences. The inversions carry hand-written `custom_jvp` rules (implicit function theorem), so forward- and reverse-mode gradients are exact and cheap; validated against central finite differences.
86
- - **Fast.** JIT-compiled and vectorisable, with hand-coded analytic α-derivatives instead of a nested-`jax.grad` chain. For simulation codes that carry `(ρ, u)` as conserved variables, `properties_from_rho_u` fuses the whole primitive recovery — it solves `T(ρ, u)` once (table-seeded, fixed-iteration analytic-Cv Newton) and reuses the derivatives for every property. On an Apple M-class CPU at a 4096-point float64 batch this is **4.8× faster** for the `T(ρ, u)` inversion and **3.6× faster** for the full `T + P + μ + k` recovery than the v0.1 autodiff path. On an OVH Tesla V100S the same comparison is 1.3–2.2× at these (latency-bound) batch sizes and grows to **7.7× / 4.9×** once the GPU is compute-bound at large batches (`bench/compare.py`, `bench/PROFILING.md`).
86
+ - **Fast.** JIT-compiled and vectorisable, with hand-coded, transcendental-economized analytic α-derivatives: the Span-Wagner exponent structure (¼-integer τ-powers, integer δ-powers, shared exponential envelopes) is evaluated by sqrt/multiply ladders, so a full six-derivative bundle costs ~12 exps + 3 pows per point instead of ~150 array-exponent pows — the same arithmetic, re-associated, exact to round-off. For simulation codes that carry `(ρ, u)` as conserved variables, `properties_from_rho_u` fuses the whole primitive recovery: a dome-safe table seed plus three unrolled analytic-Cv Newton steps recover `T(ρ, u)` to 1e-12 K, and one shared derivative bundle feeds every property including the transport critical enhancement. On an Apple M2 Pro (float64) the full recovery runs **123 µs for a 64-point batch and 0.53 µs/point at large batches — 7.6–19.9× faster than the v0.1 autodiff path** and 2.6–6.3× faster than v0.2. On an OVH Tesla V100S the inversion reaches **63.7×** vs v0.1 at a 2²⁰-point batch (2 ns/point; full recovery 16 ns/point), and the launch-latency floor at small batches dropped ~1.7× (`bench/compare.py`, `bench/PROFILING.md`, validation in `docs/validation-fastpath-2026-07.md`).
87
87
  - **Also fast for `(P, T)`.** The `state_from_PT` workflow that mirrors `PropsSI` includes an iterative density solve and flattens to a few tens of microseconds per point at large batches, roughly 2.7× faster than `CoolProp.PropsSI` in a Python loop on the same inputs (10,000-point batch, Apple M2 Pro; see `examples/launch_demo.ipynb`, section 2). On a Colab T4 GPU it runs at 27,800 states/sec on a 10⁶-point batch (36 μs/pt) versus 1,600 states/sec on Colab CPU (615 μs/pt), a 17× speedup. Its ceiling is the density solve's data-dependent `while_loop` (lockstep across GPU warps); the feed-forward `(ρ, u)` hot path above does not have that bottleneck.
88
88
  - **Composable.** `jit`, `vmap`, `grad`, `custom_vjp`: the full JAX transformation stack works. Embed property evaluations inside your own JIT-compiled simulation and differentiate end-to-end.
89
89
  - **Phase-aware.** Robust inversions near the critical point using Halley's method with step damping and bisection fallback. Two-phase dome detection that avoids convergence to thermodynamically unstable spinodal states.
@@ -49,7 +49,7 @@ But CoolProp is a C++ library with Python bindings. You can't `jax.grad` through
49
49
  CO2-EOS solves this for CO₂ by implementing the same reference EOS (Span-Wagner 1996) directly in JAX:
50
50
 
51
51
  - **Differentiable.** `jax.grad` through any property, any inversion, any combination. No finite differences. The inversions carry hand-written `custom_jvp` rules (implicit function theorem), so forward- and reverse-mode gradients are exact and cheap; validated against central finite differences.
52
- - **Fast.** JIT-compiled and vectorisable, with hand-coded analytic α-derivatives instead of a nested-`jax.grad` chain. For simulation codes that carry `(ρ, u)` as conserved variables, `properties_from_rho_u` fuses the whole primitive recovery — it solves `T(ρ, u)` once (table-seeded, fixed-iteration analytic-Cv Newton) and reuses the derivatives for every property. On an Apple M-class CPU at a 4096-point float64 batch this is **4.8× faster** for the `T(ρ, u)` inversion and **3.6× faster** for the full `T + P + μ + k` recovery than the v0.1 autodiff path. On an OVH Tesla V100S the same comparison is 1.3–2.2× at these (latency-bound) batch sizes and grows to **7.7× / 4.9×** once the GPU is compute-bound at large batches (`bench/compare.py`, `bench/PROFILING.md`).
52
+ - **Fast.** JIT-compiled and vectorisable, with hand-coded, transcendental-economized analytic α-derivatives: the Span-Wagner exponent structure (¼-integer τ-powers, integer δ-powers, shared exponential envelopes) is evaluated by sqrt/multiply ladders, so a full six-derivative bundle costs ~12 exps + 3 pows per point instead of ~150 array-exponent pows — the same arithmetic, re-associated, exact to round-off. For simulation codes that carry `(ρ, u)` as conserved variables, `properties_from_rho_u` fuses the whole primitive recovery: a dome-safe table seed plus three unrolled analytic-Cv Newton steps recover `T(ρ, u)` to 1e-12 K, and one shared derivative bundle feeds every property including the transport critical enhancement. On an Apple M2 Pro (float64) the full recovery runs **123 µs for a 64-point batch and 0.53 µs/point at large batches — 7.6–19.9× faster than the v0.1 autodiff path** and 2.6–6.3× faster than v0.2. On an OVH Tesla V100S the inversion reaches **63.7×** vs v0.1 at a 2²⁰-point batch (2 ns/point; full recovery 16 ns/point), and the launch-latency floor at small batches dropped ~1.7× (`bench/compare.py`, `bench/PROFILING.md`, validation in `docs/validation-fastpath-2026-07.md`).
53
53
  - **Also fast for `(P, T)`.** The `state_from_PT` workflow that mirrors `PropsSI` includes an iterative density solve and flattens to a few tens of microseconds per point at large batches, roughly 2.7× faster than `CoolProp.PropsSI` in a Python loop on the same inputs (10,000-point batch, Apple M2 Pro; see `examples/launch_demo.ipynb`, section 2). On a Colab T4 GPU it runs at 27,800 states/sec on a 10⁶-point batch (36 μs/pt) versus 1,600 states/sec on Colab CPU (615 μs/pt), a 17× speedup. Its ceiling is the density solve's data-dependent `while_loop` (lockstep across GPU warps); the feed-forward `(ρ, u)` hot path above does not have that bottleneck.
54
54
  - **Composable.** `jit`, `vmap`, `grad`, `custom_vjp`: the full JAX transformation stack works. Embed property evaluations inside your own JIT-compiled simulation and differentiate end-to-end.
55
55
  - **Phase-aware.** Robust inversions near the critical point using Halley's method with step damping and bisection fallback. Two-phase dome detection that avoids convergence to thermodynamically unstable spinodal states.
@@ -52,7 +52,7 @@ import jax.numpy as jnp
52
52
  # Enable float64 for the entire library (also done by submodules).
53
53
  jax.config.update("jax_enable_x64", True)
54
54
 
55
- __version__ = "0.2.0"
55
+ __version__ = "0.3.1"
56
56
 
57
57
  # ── Phase hint constants ────────────────────────────────────────────────────
58
58
  LIQUID = 0
@@ -14,12 +14,13 @@ Design:
14
14
  * The Newton inner loop touches only τ-derivatives (`helmholtz.residual_tau_*`)
15
15
  with the δ-dependent envelopes precomputed once (they are loop-invariant at
16
16
  fixed ρ) — all u and Cv need at fixed density.
17
- * A precomputed (ρ, u) → T₀ table seeds Newton within a fraction of a kelvin,
18
- so a short FIXED, unrolled iteration (no `lax.while_loop`) reaches float64
19
- round-off. Fixed iteration keeps the GPU kernel uniform and branchless; the
20
- table seed is purely a convergence accelerator (the polish sets accuracy and
21
- the IFT JVP sets gradients), so it carries no accuracy or differentiability
22
- risk.
17
+ * A precomputed (ρ, u) → T₀ table (dome-safe, uniform grid) seeds Newton so
18
+ that a short FIXED, unrolled iteration (no `lax.while_loop`) reaches
19
+ float64 round-off — measured 8.5e-13 K max over the single-phase envelope
20
+ at 3 steps. Fixed iteration keeps the GPU kernel uniform and branchless;
21
+ the table seed is purely a convergence accelerator (the polish sets
22
+ accuracy and the IFT JVP sets gradients), so it carries no accuracy or
23
+ differentiability risk.
23
24
  * Gradients use the implicit function theorem via `custom_jvp`, with the
24
25
  Jacobian entries (Cv, ∂u/∂ρ) taken analytically — correct jvp and vjp.
25
26
 
@@ -39,10 +40,13 @@ from co2_eos.span_wagner import TC, RHOC, R, T_TRIPLE
39
40
  from co2_eos import helmholtz as hz
40
41
  from co2_eos import transport as _tr
41
42
 
42
- # Fixed Newton iterations for the (ρ, u) → T inversion. The table seed lands
43
- # < ~0.5 K from the root across its range; 4 analytic-Newton steps then reach
44
- # float64 round-off, 5 leaves margin. (Validated in tests/test_inversion_*.)
45
- _NEWTON_ITERS = 5
43
+ # Fixed Newton iterations for the (ρ, u) → T inversion. With the dome-filled
44
+ # 256×512 table seed, 3 analytic-Newton steps reach float64 round-off over the
45
+ # single-phase envelope (measured max 8.5e-13 K on 40k samples incl. the
46
+ # critical neighbourhood; see bench/proto_seed_v2.py and tests). The affine
47
+ # fallback seed is coarse (±tens of K), so it gets a longer fixed iteration.
48
+ _NEWTON_ITERS_TABLE = 3
49
+ _NEWTON_ITERS_AFFINE = 8
46
50
  _T_MIN = T_TRIPLE
47
51
  _T_MAX = 800.0
48
52
 
@@ -62,30 +66,51 @@ _AFFINE_A, _AFFINE_B = 255.6, 1.656e-4
62
66
  _HAVE_SEED = False
63
67
  try:
64
68
  _seed = np.load(_SEED_PATH)
65
- _SEED_RHO = jnp.asarray(_seed["rho_grid"])
66
- _SEED_U = jnp.asarray(_seed["u_grid"])
67
- _SEED_T0 = jnp.asarray(_seed["T0_table"])
68
- _SEED_NRHO = int(_SEED_RHO.shape[0])
69
- _SEED_NU = int(_SEED_U.shape[0])
69
+ # Stored float32 (seed-only precision; halves the embedded-constant
70
+ # footprint, which XLA:CPU gathers care about). Kept flat so the four
71
+ # bilinear corners come from ONE gather of (base + static offsets) —
72
+ # four separate gather ops cost ~1.3 µs each per call.
73
+ _SEED_T0_FLAT = jnp.asarray(_seed["T0_table"], dtype=jnp.float32).ravel()
74
+ _np_rho = np.asarray(_seed["rho_grid"])
75
+ _np_u = np.asarray(_seed["u_grid"])
76
+ _SEED_NRHO = int(_np_rho.shape[0])
77
+ _SEED_NU = int(_np_u.shape[0])
78
+ # The grids are uniform (linspace) — index by arithmetic, not searchsorted.
79
+ _SEED_R_LO = float(_np_rho[0])
80
+ _SEED_R_STEP = float(_np_rho[1] - _np_rho[0])
81
+ _SEED_U_LO = float(_np_u[0])
82
+ _SEED_U_STEP = float(_np_u[1] - _np_u[0])
83
+ for _g, _step in ((_np_rho, _SEED_R_STEP), (_np_u, _SEED_U_STEP)):
84
+ if not np.allclose(np.diff(_g), _step, rtol=1e-12):
85
+ raise ValueError("seed table grids must be uniform")
70
86
  _HAVE_SEED = True
71
87
  except FileNotFoundError:
72
88
  pass
73
89
 
74
90
 
75
91
  def _seed_T(rho, u):
76
- """Initial T guess for the (ρ, u) inversion via the bilinear table."""
92
+ """Initial T guess for the (ρ, u) inversion via the bilinear table.
93
+
94
+ Uniform-grid direct indexing (the generator writes linspace grids), with
95
+ the query clamped to the table box — outside it the Newton polish still
96
+ owns the accuracy, the seed is only a starting point.
97
+ """
77
98
  if not _HAVE_SEED:
78
99
  return jnp.clip(_AFFINE_A + _AFFINE_B * u, _T_MIN, _T_MAX)
79
- ri = jnp.clip(jnp.searchsorted(_SEED_RHO, rho) - 1, 0, _SEED_NRHO - 2)
80
- uj = jnp.clip(jnp.searchsorted(_SEED_U, u) - 1, 0, _SEED_NU - 2)
81
- r0, r1 = _SEED_RHO[ri], _SEED_RHO[ri + 1]
82
- u0, u1 = _SEED_U[uj], _SEED_U[uj + 1]
83
- fr = jnp.clip((rho - r0) / (r1 - r0), 0.0, 1.0)
84
- fu = jnp.clip((u - u0) / (u1 - u0), 0.0, 1.0)
85
- c00 = _SEED_T0[ri, uj]
86
- c01 = _SEED_T0[ri, uj + 1]
87
- c10 = _SEED_T0[ri + 1, uj]
88
- c11 = _SEED_T0[ri + 1, uj + 1]
100
+ fi = jnp.clip((rho - _SEED_R_LO) / _SEED_R_STEP, 0.0,
101
+ _SEED_NRHO - 1 - 1e-9)
102
+ fj = jnp.clip((u - _SEED_U_LO) / _SEED_U_STEP, 0.0, _SEED_NU - 1 - 1e-9)
103
+ ri = fi.astype(jnp.int32)
104
+ uj = fj.astype(jnp.int32)
105
+ fr = fi - ri
106
+ fu = fj - uj
107
+ base = ri * _SEED_NU + uj
108
+ corners = _SEED_T0_FLAT[base + jnp.array([0, 1, _SEED_NU, _SEED_NU + 1],
109
+ dtype=jnp.int32)]
110
+ c00, c01, c10, c11 = (corners[0].astype(jnp.float64),
111
+ corners[1].astype(jnp.float64),
112
+ corners[2].astype(jnp.float64),
113
+ corners[3].astype(jnp.float64))
89
114
  return ((c00 * (1.0 - fr) + c10 * fr) * (1.0 - fu)
90
115
  + (c01 * (1.0 - fr) + c11 * fr) * fu)
91
116
 
@@ -148,22 +173,23 @@ def _solve_T(rho, u):
148
173
  """Fixed-iteration Newton solve for T given (ρ, u). Scalar.
149
174
 
150
175
  δ-invariant envelopes are precomputed once; each step evaluates only the
151
- τ-dependent transcendentals.
176
+ τ-dependent transcendentals. The short fixed count is unrolled (no loop
177
+ construct) so XLA fuses the steps and the kernel stays branchless.
152
178
  """
153
179
  delta = rho / RHOC
154
180
  dstate = hz.residual_tau_prep(delta)
155
181
  T = _seed_T(rho, u)
182
+ iters = _NEWTON_ITERS_TABLE if _HAVE_SEED else _NEWTON_ITERS_AFFINE
156
183
 
157
- def step(_, T):
184
+ for _ in range(iters):
158
185
  tau = TC / T
159
186
  a0_t, a0_tt = hz.ideal_tau_only(tau)
160
187
  ar_t, ar_tt = hz.residual_tau_fast(tau, dstate)
161
188
  f = R * TC * (a0_t + ar_t) - u
162
189
  cv = -R * tau ** 2 * (a0_tt + ar_tt)
163
190
  cv = jnp.where(jnp.abs(cv) > 1e-30, cv, 1e-30)
164
- return jnp.clip(T - f / cv, _T_MIN, _T_MAX)
165
-
166
- return jax.lax.fori_loop(0, _NEWTON_ITERS, step, T)
191
+ T = jnp.clip(T - f / cv, _T_MIN, _T_MAX)
192
+ return T
167
193
 
168
194
 
169
195
  # ── Implicit differentiation via custom_jvp ─────────────────────────────────
@@ -0,0 +1,449 @@
1
+ """Span-Wagner (1996) reduced Helmholtz energy with hand-coded analytic
2
+ derivatives — pure JAX, transcendental-economized.
3
+
4
+ This is the numerical core of the EOS hot path. It computes α and all the
5
+ derivatives a property/inversion pass needs — α_δ, α_τ, α_δδ, α_ττ, α_δτ —
6
+ analytically, and exploits the *structure* of the Span-Wagner exponents so the
7
+ per-point transcendental count collapses:
8
+
9
+ * every τ-exponent in the polynomial/exponential/Gaussian terms is a
10
+ multiple of ¼ → all τ-powers come from ``sqrt(sqrt(τ))`` plus a multiply
11
+ ladder (zero ``pow`` calls);
12
+ * every δ-exponent is a small integer (d ≤ 10, l ≤ 6) → multiply ladders;
13
+ * the 27 ``exp(-δ^l)`` envelopes take only six distinct values (l = 1..6);
14
+ * the five Gaussian exponentials share (η, β, γ) pairwise → four distinct;
15
+ * the non-analytic terms share θ (A, β identical across terms) and terms
16
+ 40/41 share Δ; Δ^(7/8) is a sqrt ladder; only Δ^0.925, s^(5/3) and
17
+ s^(2/3) remain as real ``pow`` calls.
18
+
19
+ The structural facts are asserted at import against the coefficient tables in
20
+ ``span_wagner`` (the single source of truth) — if the tables ever change shape,
21
+ import fails loudly rather than computing a stale factorization.
22
+
23
+ The result is the same Span-Wagner sum re-associated: agreement with the
24
+ autodiff derivatives of ``span_wagner.alphar`` / ``alpha0`` is round-off level
25
+ (~1e-12; see ``tests/test_analytic_derivs.py``), and every function remains
26
+ JIT-compilable, vmappable and differentiable to higher order.
27
+
28
+ Convention: τ = Tc/T (reduced inverse temperature), δ = ρ/ρc (reduced
29
+ density). Derivatives are plain partials w.r.t. τ and δ (not the δ·∂/∂δ
30
+ reduced form), matching the formulas in ``span_wagner``.
31
+ """
32
+
33
+ import jax
34
+ import jax.numpy as jnp
35
+ import numpy as np
36
+
37
+ jax.config.update("jax_enable_x64", True)
38
+
39
+ # Pull the coefficient tables straight from span_wagner so there is a single
40
+ # source of truth for the constants.
41
+ from co2_eos.span_wagner import (
42
+ _A0_A1, _A0_A2, _A0_LOGTAU, _A0_PE_N, _A0_PE_T,
43
+ _AR_N, _AR_D, _AR_T, _AR_L,
44
+ _AR_GAUSS_N, _AR_GAUSS_D, _AR_GAUSS_T,
45
+ _AR_GAUSS_ETA, _AR_GAUSS_BETA, _AR_GAUSS_GAMMA, _AR_GAUSS_EPS,
46
+ _NA_N, _NA_A, _NA_B, _NA_BETA, _NA_BIG_A, _NA_BIG_B, _NA_BIG_C, _NA_BIG_D,
47
+ )
48
+
49
+ # ── Static (Python-scalar) views of the tables — folded into the trace ─────
50
+ _PN = np.asarray(_AR_N)
51
+ _PD = np.asarray(_AR_D).astype(int)
52
+ _PT = np.asarray(_AR_T)
53
+ _PL = np.asarray(_AR_L).astype(int)
54
+
55
+ _GN = np.asarray(_AR_GAUSS_N)
56
+ _GD = np.asarray(_AR_GAUSS_D).astype(int)
57
+ _GT = np.asarray(_AR_GAUSS_T)
58
+ _GETA = np.asarray(_AR_GAUSS_ETA)
59
+ _GBETA = np.asarray(_AR_GAUSS_BETA)
60
+ _GGAMMA = np.asarray(_AR_GAUSS_GAMMA)
61
+
62
+ _NAN = np.asarray(_NA_N)
63
+ _NAA = np.asarray(_NA_A)
64
+ _NAB = np.asarray(_NA_B)
65
+ _NABIGB = np.asarray(_NA_BIG_B)
66
+ _NABIGC = np.asarray(_NA_BIG_C)
67
+
68
+ # Structural facts the economization relies on — asserted, not assumed.
69
+ assert np.all(_PD == np.asarray(_AR_D)) and _PD.max() <= 10, "δ-exponent not a small integer"
70
+ assert np.all(_PL == np.asarray(_AR_L)) and _PL.max() <= 6, "l-exponent not a small integer"
71
+ assert np.all(np.abs(_PT * 4 - np.round(_PT * 4)) < 1e-12), "τ-exponent not a multiple of ¼"
72
+ assert np.all(np.abs(_GT * 4 - np.round(_GT * 4)) < 1e-12)
73
+ assert np.all(np.asarray(_AR_GAUSS_EPS) == 1.0), "Gaussian ε ≠ 1"
74
+ _NA_P_SCALAR = float(1.0 / (2.0 * np.asarray(_NA_BETA)[0]))
75
+ assert np.all(np.asarray(_NA_BETA) == np.asarray(_NA_BETA)[0]), "NA β not shared"
76
+ _NA_BIGA_SCALAR = float(np.asarray(_NA_BIG_A)[0])
77
+ assert np.all(np.asarray(_NA_BIG_A) == _NA_BIGA_SCALAR), "NA A not shared"
78
+ _NA_BIGD_SCALAR = float(np.asarray(_NA_BIG_D)[0])
79
+ assert np.all(np.asarray(_NA_BIG_D) == _NA_BIGD_SCALAR), "NA D not shared"
80
+ assert set(np.round(_NAA * 2).astype(int)) <= {6, 7}, "NA a must be 3 or 3.5"
81
+ assert _NAB[0] == 0.875 and _NAB[2] == 0.875, "Δ^(7/8) sqrt ladder assumption"
82
+
83
+ # Reciprocal of (2β) for the non-analytic θ exponent, precomputed (array view
84
+ # kept for external callers/tests; the kernels use the shared scalar).
85
+ _NA_P = 1.0 / (2.0 * _NA_BETA)
86
+
87
+ # Distinct τ-exponents of the polynomial+exponential terms, and per-exponent
88
+ # static weights for the τ-derivative dot products.
89
+ _TPOLY = sorted(set(_PT.tolist()))
90
+ _TP_IDX = {t: j for j, t in enumerate(_TPOLY)}
91
+ _TALL = sorted(set(_TPOLY) | set(_GT.tolist()))
92
+ _W_T = np.array(_TPOLY) # t_j
93
+ _W_TT = np.array([t * (t - 1.0) for t in _TPOLY])
94
+
95
+
96
+ def _tau_ladder(tau):
97
+ """All distinct τ^t needed by the terms, via 2 sqrts + multiplies.
98
+
99
+ Every exponent is a multiple of ¼, so τ^t = τ^int · (τ^¼)^frac with the
100
+ integer parts built by a multiply chain over increasing exponents.
101
+ """
102
+ st = jnp.sqrt(tau)
103
+ qt = jnp.sqrt(st)
104
+ p = {0.0: jnp.ones_like(tau), 0.25: qt, 0.5: st, 0.75: st * qt, 1.0: tau}
105
+ for t in _TALL:
106
+ if t in p:
107
+ continue
108
+ if t == int(t):
109
+ t_int = int(t)
110
+ half = float(t_int // 2)
111
+ other = float(t_int - t_int // 2)
112
+ if half in p and other in p:
113
+ p[t] = p[half] * p[other]
114
+ else:
115
+ avail = max(k for k in p if k == int(k) and 0 < k <= t)
116
+ acc, rem = p[avail], t_int - int(avail)
117
+ while rem > 0:
118
+ step = max(k for k in p if k == int(k) and 0 < k <= rem)
119
+ acc = acc * p[step]
120
+ rem -= int(step)
121
+ p[t] = acc
122
+ else:
123
+ base, frac = float(int(t)), t - int(t)
124
+ p[t] = p[base] * p[frac] # base already built (sorted order)
125
+ return p
126
+
127
+
128
+ def _delta_ladder(delta, dmax=10):
129
+ """δ^0..δ^dmax by successive multiplication."""
130
+ p = {0: jnp.ones_like(delta), 1: delta}
131
+ for k in range(2, dmax + 1):
132
+ p[k] = p[k - 1] * delta
133
+ return p
134
+
135
+
136
+ def _exp_envelopes(dp):
137
+ """The six distinct exponential envelopes E_l = exp(-δ^l), l = 0..6."""
138
+ E = {0: jnp.ones_like(dp[1])}
139
+ for l in range(1, 7):
140
+ E[l] = jnp.exp(-dp[l])
141
+ return E
142
+
143
+
144
+ # ═══════════════════════════════════════════════════════════════════════════
145
+ # Ideal-gas part α⁰ — τ derivatives (δ part is only ln δ, not needed here)
146
+ # ═══════════════════════════════════════════════════════════════════════════
147
+
148
+ def ideal_derivs(tau, delta):
149
+ """Return (α⁰, α⁰_τ, α⁰_ττ) at scalar (τ, δ).
150
+
151
+ α⁰ = a1 + a2·τ + L·ln τ + ln δ + Σ nk·ln(1 - exp(-θk·τ))
152
+ Only the τ-derivatives and the value are used by the property/inversion
153
+ formulas (the ideal δ-dependence cancels out of every measurable property
154
+ except through the additive ln δ in the value, which entropy/Gibbs need).
155
+ """
156
+ L = _A0_LOGTAU
157
+ e = jnp.exp(-_A0_PE_T * tau) # exp(-θk τ)
158
+ one_minus_e = 1.0 - e
159
+
160
+ a0 = (_A0_A1 + _A0_A2 * tau + L * jnp.log(tau) + jnp.log(delta)
161
+ + jnp.sum(_A0_PE_N * jnp.log(one_minus_e)))
162
+
163
+ # d/dτ ln(1-e) = θ·e/(1-e)
164
+ a0_t = (_A0_A2 + L / tau
165
+ + jnp.sum(_A0_PE_N * _A0_PE_T * e / one_minus_e))
166
+
167
+ # d²/dτ² ln(1-e) = -θ²·e/(1-e)²
168
+ a0_tt = (-L / tau ** 2
169
+ - jnp.sum(_A0_PE_N * _A0_PE_T ** 2 * e / one_minus_e ** 2))
170
+
171
+ return a0, a0_t, a0_tt
172
+
173
+
174
+ def ideal_tau_only(tau):
175
+ """Return (α⁰_τ, α⁰_ττ) — the only ideal quantities the Newton needs.
176
+
177
+ Independent of δ, so cheaper than ``ideal_derivs`` for the inner loop.
178
+ """
179
+ L = _A0_LOGTAU
180
+ e = jnp.exp(-_A0_PE_T * tau)
181
+ one_minus_e = 1.0 - e
182
+ a0_t = (_A0_A2 + L / tau
183
+ + jnp.sum(_A0_PE_N * _A0_PE_T * e / one_minus_e))
184
+ a0_tt = (-L / tau ** 2
185
+ - jnp.sum(_A0_PE_N * _A0_PE_T ** 2 * e / one_minus_e ** 2))
186
+ return a0_t, a0_tt
187
+
188
+
189
+ # ═══════════════════════════════════════════════════════════════════════════
190
+ # Non-analytic terms — shared helpers
191
+ # ═══════════════════════════════════════════════════════════════════════════
192
+
193
+ def _pow78(Db):
194
+ """Db^(7/8) by three sqrts and three multiplies (Db ≥ 1e-300 guarded)."""
195
+ r8 = jnp.sqrt(jnp.sqrt(jnp.sqrt(Db)))
196
+ q2 = r8 * r8
197
+ q4 = q2 * q2
198
+ return q4 * q2 * r8
199
+
200
+
201
+ def _na_delta_state(delta):
202
+ """δ-side quantities of the non-analytic terms (loop-invariant at fixed ρ).
203
+
204
+ Returns (s, dme, s_p, Bsa, e_cs) where Bsa[(a, B)] = B·s^a and
205
+ e_cs[C] = exp(-C·s) for the distinct (a, B) / C among the three terms.
206
+ """
207
+ dme = delta - 1.0
208
+ s = dme * dme
209
+ s_p = s ** _NA_P_SCALAR # s^(5/3)
210
+ s3 = s * s * s
211
+ abs_dm1 = jnp.abs(dme)
212
+ sa = {3.5: s3 * abs_dm1, 3.0: s3} # s^a by multiplies
213
+ Bsa = {}
214
+ for k in range(3):
215
+ key = (float(_NAA[k]), float(_NABIGB[k]))
216
+ if key not in Bsa:
217
+ Bsa[key] = key[1] * sa[key[0]]
218
+ e_cs = {float(C): jnp.exp(-float(C) * s) for C in sorted(set(_NABIGC.tolist()))}
219
+ return s, dme, s_p, Bsa, e_cs
220
+
221
+
222
+ # ═══════════════════════════════════════════════════════════════════════════
223
+ # Residual part αʳ — full analytic derivative bundle (one economized pass)
224
+ # ═══════════════════════════════════════════════════════════════════════════
225
+
226
+ def residual_derivs(tau, delta):
227
+ """Return (αʳ, αʳ_δ, αʳ_τ, αʳ_δδ, αʳ_ττ, αʳ_δτ) at scalar (τ, δ).
228
+
229
+ All six are accumulated from the same per-term values; the transcendental
230
+ budget per point is ~12 exps + ~8 sqrts + 3 pows (see module docstring).
231
+ """
232
+ inv_d = 1.0 / delta
233
+ inv_d2 = inv_d * inv_d
234
+ inv_t = 1.0 / tau
235
+ inv_t2 = inv_t * inv_t
236
+
237
+ tp = _tau_ladder(tau)
238
+ dp = _delta_ladder(delta)
239
+ E = _exp_envelopes(dp)
240
+
241
+ ar = ar_d = ar_t = ar_dd = ar_tt = ar_dt = 0.0
242
+
243
+ # ── Polynomial + exponential terms (static unroll; constants folded) ──
244
+ for k in range(len(_PN)):
245
+ n, d, t, l = float(_PN[k]), int(_PD[k]), float(_PT[k]), int(_PL[k])
246
+ Tk = n * dp[d] * tp[t] * E[l]
247
+ ld = l * dp[l] if l else 0.0 # l·δ^l (0 when l = 0)
248
+ D1 = (d - ld) * inv_d
249
+ D1p = (-d - (l * (l - 1) * dp[l] if l else 0.0)) * inv_d2
250
+ Tt = t * inv_t
251
+ ar += Tk
252
+ ar_d += Tk * D1
253
+ ar_t += Tk * Tt
254
+ ar_dd += Tk * (D1 * D1 + D1p)
255
+ ar_tt += Tk * (t * (t - 1.0) * inv_t2)
256
+ ar_dt += Tk * D1 * Tt
257
+
258
+ # ── Gaussian bell-shaped terms (four distinct exponentials) ──
259
+ dme = delta - 1.0 # ε = 1 for all five terms
260
+ dme2 = dme * dme
261
+ e_g = {}
262
+ for k in range(5):
263
+ key = (float(_GETA[k]), float(_GBETA[k]), float(_GGAMMA[k]))
264
+ if key not in e_g:
265
+ tmg = tau - key[2]
266
+ e_g[key] = (tmg, jnp.exp(-key[0] * dme2 - key[1] * tmg * tmg))
267
+ for k in range(5):
268
+ n, d, t = float(_GN[k]), int(_GD[k]), float(_GT[k])
269
+ eta, beta = float(_GETA[k]), float(_GBETA[k])
270
+ tmg, eg = e_g[(eta, beta, float(_GGAMMA[k]))]
271
+ G = n * dp[d] * tp[t] * eg
272
+ Gd = d * inv_d - 2.0 * eta * dme
273
+ Gdp = -d * inv_d2 - 2.0 * eta
274
+ Gt = t * inv_t - 2.0 * beta * tmg
275
+ Gtp = -t * inv_t2 - 2.0 * beta
276
+ ar += G
277
+ ar_d += G * Gd
278
+ ar_t += G * Gt
279
+ ar_dd += G * (Gd * Gd + Gdp)
280
+ ar_tt += G * (Gt * Gt + Gtp)
281
+ ar_dt += G * Gd * Gt
282
+
283
+ # ── Non-analytic terms (shared θ; terms 40/41 share Δ) ──
284
+ s, dme, s_p, Bsa, e_cs = _na_delta_state(delta)
285
+ s_pm1 = s ** (_NA_P_SCALAR - 1.0) # s^(2/3)
286
+ s2 = s * s
287
+ sam1 = {3.5: s2 * jnp.abs(dme), 3.0: s2} # s^(a-1) by multiplies
288
+ tm1 = tau - 1.0
289
+
290
+ theta = -tm1 + _NA_BIGA_SCALAR * s_p
291
+ th_d = 2.0 * _NA_BIGA_SCALAR * _NA_P_SCALAR * dme * s_pm1
292
+ th_dd = 2.0 * _NA_BIGA_SCALAR * _NA_P_SCALAR * (2.0 * _NA_P_SCALAR - 1.0) * s_pm1
293
+ De_t = -2.0 * theta
294
+ De_tt = 2.0
295
+ exp_tm1 = jnp.exp(-_NA_BIGD_SCALAR * tm1 * tm1)
296
+
297
+ Delta_cache = {}
298
+ for k in range(3):
299
+ a, b = float(_NAA[k]), float(_NAB[k])
300
+ B, Cc = float(_NABIGB[k]), float(_NABIGC[k])
301
+ dkey = (a, B)
302
+ if dkey not in Delta_cache:
303
+ Delta = theta * theta + Bsa[dkey]
304
+ Db = jnp.maximum(Delta, 1e-300)
305
+ De_d = 2.0 * theta * th_d + 2.0 * B * a * dme * sam1[a]
306
+ De_dd = (2.0 * th_d * th_d + 2.0 * theta * th_dd
307
+ + 2.0 * B * a * (2.0 * a - 1.0) * sam1[a])
308
+ De_dt = -2.0 * th_d
309
+ Delta_cache[dkey] = (Db, De_d, De_dd, De_dt)
310
+ Db, De_d, De_dd, De_dt = Delta_cache[dkey]
311
+
312
+ F = _pow78(Db) if b == 0.875 else Db ** b
313
+ Fm1 = F / Db
314
+ Fm2 = Fm1 / Db
315
+ F_d = b * Fm1 * De_d
316
+ F_t = b * Fm1 * De_t
317
+ F_dd = b * (b - 1.0) * Fm2 * De_d * De_d + b * Fm1 * De_dd
318
+ F_tt = b * (b - 1.0) * Fm2 * De_t * De_t + b * Fm1 * De_tt
319
+ F_dt = b * (b - 1.0) * Fm2 * De_d * De_t + b * Fm1 * De_dt
320
+
321
+ Psi = e_cs[Cc] * exp_tm1
322
+ Ps_d = -2.0 * Cc * dme * Psi
323
+ Ps_t = -2.0 * _NA_BIGD_SCALAR * tm1 * Psi
324
+ Ps_dd = (4.0 * Cc * Cc * s - 2.0 * Cc) * Psi
325
+ Ps_tt = (4.0 * _NA_BIGD_SCALAR * _NA_BIGD_SCALAR * tm1 * tm1
326
+ - 2.0 * _NA_BIGD_SCALAR) * Psi
327
+ Ps_dt = 4.0 * Cc * _NA_BIGD_SCALAR * dme * tm1 * Psi
328
+
329
+ n = float(_NAN[k])
330
+ W = F * delta * Psi
331
+ W_d = F_d * delta * Psi + F * Psi + F * delta * Ps_d
332
+ W_t = F_t * delta * Psi + F * delta * Ps_t
333
+ W_dd = (F_dd * delta * Psi + 2.0 * F_d * Psi + 2.0 * F_d * delta * Ps_d
334
+ + 2.0 * F * Ps_d + F * delta * Ps_dd)
335
+ W_tt = F_tt * delta * Psi + 2.0 * F_t * delta * Ps_t + F * delta * Ps_tt
336
+ W_dt = (F_dt * delta * Psi + F_d * delta * Ps_t + F_t * Psi + F * Ps_t
337
+ + F_t * delta * Ps_d + F * delta * Ps_dt)
338
+
339
+ ar += n * W
340
+ ar_d += n * W_d
341
+ ar_t += n * W_t
342
+ ar_dd += n * W_dd
343
+ ar_tt += n * W_tt
344
+ ar_dt += n * W_dt
345
+
346
+ return ar, ar_d, ar_t, ar_dd, ar_tt, ar_dt
347
+
348
+
349
+ # ═══════════════════════════════════════════════════════════════════════════
350
+ # τ-only inner-loop pair: δ-invariant prep + per-iteration fast eval
351
+ # ═══════════════════════════════════════════════════════════════════════════
352
+
353
+ def residual_tau_prep(delta):
354
+ """Precompute the δ-invariant state for the τ-derivative inner loop.
355
+
356
+ At fixed ρ (the Newton solves T at fixed density) every δ-dependent power
357
+ and exponential is loop-invariant. The polynomial+exponential terms
358
+ collapse to one coefficient per distinct τ-exponent:
359
+ αʳ_poly(τ; δ) = Σ_j Ct[j]·τ^t_j with Ct[j] = Σ_{terms with t_j} n·δ^d·E_l.
360
+ """
361
+ dp = _delta_ladder(delta)
362
+ E = _exp_envelopes(dp)
363
+ Ct = [0.0] * len(_TPOLY)
364
+ for k in range(len(_PN)):
365
+ Ct[_TP_IDX[float(_PT[k])]] += float(_PN[k]) * dp[int(_PD[k])] * E[int(_PL[k])]
366
+ Ct = jnp.stack([jnp.asarray(c) for c in Ct])
367
+
368
+ dme = delta - 1.0
369
+ dme2 = dme * dme
370
+ Cg = jnp.stack([float(_GN[k]) * dp[int(_GD[k])]
371
+ * jnp.exp(-float(_GETA[k]) * dme2) for k in range(5)])
372
+
373
+ _, _, s_p, Bsa, e_cs = _na_delta_state(delta)
374
+ return Ct, Cg, s_p, Bsa, e_cs, delta
375
+
376
+
377
+ def residual_tau_fast(tau, dstate):
378
+ """Return (αʳ_τ, αʳ_ττ) from precomputed δ-state ``dstate``.
379
+
380
+ Identical result to ``residual_tau_derivs(tau, delta)`` but per call only
381
+ the τ-side transcendentals are evaluated: ~6 exps + 8 sqrts + 1 pow.
382
+ """
383
+ Ct, Cg, s_p, Bsa, e_cs, delta = dstate
384
+ inv_t = 1.0 / tau
385
+ inv_t2 = inv_t * inv_t
386
+
387
+ tp = _tau_ladder(tau)
388
+ taupows = jnp.stack([tp[t] for t in _TPOLY])
389
+ base = Ct * taupows
390
+ p_t = jnp.sum(base * _W_T) * inv_t
391
+ p_tt = jnp.sum(base * _W_TT) * inv_t2
392
+
393
+ g_t = 0.0
394
+ g_tt = 0.0
395
+ e_g = {}
396
+ for k in range(5):
397
+ key = (float(_GBETA[k]), float(_GGAMMA[k]))
398
+ if key not in e_g:
399
+ tmg = tau - key[1]
400
+ e_g[key] = (tmg, jnp.exp(-key[0] * tmg * tmg))
401
+ for k in range(5):
402
+ t, beta = float(_GT[k]), float(_GBETA[k])
403
+ tmg, eg = e_g[(beta, float(_GGAMMA[k]))]
404
+ gk = Cg[k] * tp[t] * eg
405
+ Gt = t * inv_t - 2.0 * beta * tmg
406
+ Gtp = -t * inv_t2 - 2.0 * beta
407
+ g_t += gk * Gt
408
+ g_tt += gk * (Gt * Gt + Gtp)
409
+
410
+ tm1 = tau - 1.0
411
+ theta = -tm1 + _NA_BIGA_SCALAR * s_p
412
+ De_t = -2.0 * theta
413
+ De_tt = 2.0
414
+ exp_tm1 = jnp.exp(-_NA_BIGD_SCALAR * tm1 * tm1)
415
+
416
+ na_t = 0.0
417
+ na_tt = 0.0
418
+ Db_cache = {}
419
+ for k in range(3):
420
+ a, b = float(_NAA[k]), float(_NAB[k])
421
+ B, Cc = float(_NABIGB[k]), float(_NABIGC[k])
422
+ dkey = (a, B)
423
+ if dkey not in Db_cache:
424
+ Db_cache[dkey] = jnp.maximum(theta * theta + Bsa[dkey], 1e-300)
425
+ Db = Db_cache[dkey]
426
+ F = _pow78(Db) if b == 0.875 else Db ** b
427
+ Fm1 = F / Db
428
+ Fm2 = Fm1 / Db
429
+ F_t = b * Fm1 * De_t
430
+ F_tt = b * (b - 1.0) * Fm2 * De_t * De_t + b * Fm1 * De_tt
431
+ Psi = e_cs[Cc] * exp_tm1
432
+ Ps_t = -2.0 * _NA_BIGD_SCALAR * tm1 * Psi
433
+ Ps_tt = (4.0 * _NA_BIGD_SCALAR * _NA_BIGD_SCALAR * tm1 * tm1
434
+ - 2.0 * _NA_BIGD_SCALAR) * Psi
435
+ W_t = F_t * delta * Psi + F * delta * Ps_t
436
+ W_tt = F_tt * delta * Psi + 2.0 * F_t * delta * Ps_t + F * delta * Ps_tt
437
+ na_t += float(_NAN[k]) * W_t
438
+ na_tt += float(_NAN[k]) * W_tt
439
+
440
+ return p_t + g_t + na_t, p_tt + g_tt + na_tt
441
+
442
+
443
+ def residual_tau_derivs(tau, delta):
444
+ """Return (αʳ_τ, αʳ_ττ) — the residual quantities the Newton needs.
445
+
446
+ Computes only the τ-derivatives; the δ-dependent envelopes are
447
+ loop-invariant at fixed ρ and XLA hoists them across an unrolled Newton.
448
+ """
449
+ return residual_tau_fast(tau, residual_tau_prep(delta))