co2-eos 0.3.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 (29) hide show
  1. {co2_eos-0.3.0 → co2_eos-0.3.1}/PKG-INFO +1 -1
  2. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/__init__.py +1 -1
  3. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/inversions.py +87 -27
  4. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos.egg-info/PKG-INFO +1 -1
  5. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos.egg-info/SOURCES.txt +1 -0
  6. {co2_eos-0.3.0 → co2_eos-0.3.1}/pyproject.toml +1 -1
  7. co2_eos-0.3.1/tests/test_density_from_PT_branch.py +113 -0
  8. {co2_eos-0.3.0 → co2_eos-0.3.1}/LICENSE +0 -0
  9. {co2_eos-0.3.0 → co2_eos-0.3.1}/README.md +0 -0
  10. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/core.py +0 -0
  11. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/data/chi_ref_cheb.npz +0 -0
  12. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/data/saturation_table.npz +0 -0
  13. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/data/seed_table.npz +0 -0
  14. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/helmholtz.py +0 -0
  15. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/saturation.py +0 -0
  16. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/span_wagner.py +0 -0
  17. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos/transport.py +0 -0
  18. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos.egg-info/dependency_links.txt +0 -0
  19. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos.egg-info/requires.txt +0 -0
  20. {co2_eos-0.3.0 → co2_eos-0.3.1}/co2_eos.egg-info/top_level.txt +0 -0
  21. {co2_eos-0.3.0 → co2_eos-0.3.1}/setup.cfg +0 -0
  22. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_accuracy_vs_coolprop.py +0 -0
  23. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_analytic_derivs.py +0 -0
  24. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_dome_detection.py +0 -0
  25. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_fastpath_validation.py +0 -0
  26. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_inversion_gradients.py +0 -0
  27. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_map_fresh.py +0 -0
  28. {co2_eos-0.3.0 → co2_eos-0.3.1}/tests/test_no_coolprop_runtime.py +0 -0
  29. {co2_eos-0.3.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.3.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
@@ -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.3.0"
55
+ __version__ = "0.3.1"
56
56
 
57
57
  # ── Phase hint constants ────────────────────────────────────────────────────
58
58
  LIQUID = 0
@@ -54,6 +54,23 @@ _du_drho = jax.grad(sw._scalar_internal_energy, argnums=1)
54
54
 
55
55
  # ── Phase-aware initial guess ─────────────────────────────────────────────
56
56
 
57
+ def _subcritical_branch(T, P):
58
+ """Which stable branch a (T, P) state below TC lies on, and its bracket.
59
+
60
+ Below TC the Span-Wagner isotherm is an analytic continuation through the
61
+ dome: it loops between the spinodals and can swing far above P_sat (at
62
+ 294 K it reaches 12 MPa near 550 kg/m³), so P(T, ρ) = P_target has spurious
63
+ roots inside the dome for any P_target below that swing. The only way to
64
+ pick the physical root is by P against P_sat(T): P ≥ P_sat is compressed
65
+ liquid, ρ ≥ ρ_l(T); P < P_sat is vapor, ρ ≤ ρ_v(T). Returns
66
+ (liquid_side, rho_l_T, rho_v_T); the saturation table is used only where
67
+ T < TC, so its behaviour above TC does not matter here.
68
+ """
69
+ rho_l_T, rho_v_T = sat.saturation_densities(T)
70
+ liquid_side = P >= sat.saturation_pressure(T)
71
+ return liquid_side, rho_l_T, rho_v_T
72
+
73
+
57
74
  def _initial_guess(T, P, phase_hint):
58
75
  """Return an initial density guess based on phase_hint.
59
76
 
@@ -61,10 +78,7 @@ def _initial_guess(T, P, phase_hint):
61
78
  Uses jnp.where for JIT traceability.
62
79
  """
63
80
  subcritical_T = T < sw.TC
64
-
65
- # T-based saturation densities — good for compressed liquid (rho_l at T)
66
- # and low-pressure vapor (rho_v at T)
67
- rho_l_T, rho_v_T = sat.saturation_densities(T)
81
+ liquid_side, rho_l_T, rho_v_T = _subcritical_branch(T, P)
68
82
 
69
83
  # Liquid: saturation liquid density at T (good for compressed liquid),
70
84
  # fall back to 2*RHOC for supercritical T
@@ -72,23 +86,22 @@ def _initial_guess(T, P, phase_hint):
72
86
  # Vapor: saturation vapor density at T, fall back to ideal gas
73
87
  ideal_gas_rho = P / (sw.R * T)
74
88
  vapor_guess = jnp.where(subcritical_T, rho_v_T, ideal_gas_rho)
75
- # Supercritical: pressure-aware guess near the critical region.
76
- # Near PC and TC, use RHOC. Otherwise, vapor-like (P < PC) uses ideal gas,
77
- # liquid-like (P > 1.5*PC) uses 2*RHOC.
78
- # When T is well below TC (liquid-like regardless of pressure), use
79
- # saturated liquid density at T — same logic as the LIQUID branch.
89
+ # Auto, T >= TC: the isotherm is monotone, so any pressure-aware seed
90
+ # converges. Near PC and TC use RHOC; liquid-like (P > 1.5*PC) 2*RHOC;
91
+ # otherwise ideal gas.
80
92
  near_critical = jnp.logical_and(
81
93
  jnp.abs(P - sw.PC) < 0.2 * sw.PC,
82
94
  jnp.abs(T - sw.TC) < 15.0,
83
95
  )
84
- clearly_liquid = T < sw.TC - 10.0
85
- sc_guess = jnp.where(
86
- clearly_liquid, rho_l_T,
87
- jnp.where(
88
- near_critical, sw.RHOC,
89
- jnp.where(P > 1.5 * sw.PC, 2.0 * sw.RHOC, ideal_gas_rho)
90
- )
96
+ sc_guess_above_TC = jnp.where(
97
+ near_critical, sw.RHOC,
98
+ jnp.where(P > 1.5 * sw.PC, 2.0 * sw.RHOC, ideal_gas_rho)
91
99
  )
100
+ # Auto, T < TC: seed on the stable branch P_sat(T) selects. A seed inside
101
+ # the dome (RHOC, or the ideal-gas density) lands Halley on a spurious
102
+ # root of the isotherm's loop, with dP/dρ > 0 and no sign of trouble.
103
+ sc_guess_below_TC = jnp.where(liquid_side, rho_l_T, rho_v_T)
104
+ sc_guess = jnp.where(subcritical_T, sc_guess_below_TC, sc_guess_above_TC)
92
105
 
93
106
  rho0 = jnp.where(
94
107
  phase_hint == LIQUID, liquid_guess,
@@ -144,16 +157,16 @@ def _halley_cond(state):
144
157
  _BISECT_LO = 1.0 # kg/m³ — lower bound
145
158
  _BISECT_HI = 1200.0 # kg/m³ — upper bound
146
159
  _BISECT_ITERS = 60 # ~10⁻¹⁸ relative precision
160
+ _BRANCH_TOL = 1e-4 # relative slack around ρ_l(T), ρ_v(T) for the branch tests
147
161
 
148
162
 
149
163
  def _bisection_body(state):
150
164
  """One bisection step on f(ρ) = P(T,ρ) − P_target.
151
165
 
152
- Monotonicity assumption: P(T,ρ) is monotonically increasing in ρ for
153
- single-phase stable fluid (∂P/∂ρ > 0). This does NOT hold inside the
154
- spinodal (unstable) region, but that's fine — the bisection is a safety
155
- net for near-critical supercritical states where Halley oscillates, not
156
- for subcritical dome-crossing (handled by dome detection in state_from_Ph).
166
+ Monotonicity assumption: P(T,ρ) is monotonically increasing in ρ on the
167
+ bracket. That holds on any single-phase stable branch (∂P/∂ρ > 0) and
168
+ fails inside the spinodal loop, so below TC the caller brackets the
169
+ stable branch (`_bisection_bracket`) instead of the whole [1, 1200] range.
157
170
  """
158
171
  lo, hi, T, P_target, i = state
159
172
  mid = 0.5 * (lo + hi)
@@ -170,16 +183,61 @@ def _bisection_cond(state):
170
183
  return jnp.logical_and(i < _BISECT_ITERS, (hi - lo) > 1e-14 * hi)
171
184
 
172
185
 
173
- def _bisection_solve(T, P):
174
- """Bisection solve for ρ given (T, P). Scalar inputs. Bounds [1, 1200] kg/m³."""
175
- init_state = (_BISECT_LO, _BISECT_HI, T, P, jnp.int32(0))
186
+ def _bisection_bracket(T, P, phase_hint):
187
+ """Density bracket [lo, hi] on which P(T, ρ) is monotone for this state.
188
+
189
+ Above TC the whole range. Below TC the stable branch: [ρ_l(T), 1200] on
190
+ the liquid side, [1, ρ_v(T)] on the vapor side, the side taken from the
191
+ explicit hint or, under auto, from P against P_sat(T).
192
+ """
193
+ subcritical_T = T < sw.TC
194
+ liquid_side, rho_l_T, rho_v_T = _subcritical_branch(T, P)
195
+ liquid_side = jnp.where(
196
+ phase_hint == LIQUID, True,
197
+ jnp.where(phase_hint == VAPOR, False, liquid_side)
198
+ )
199
+ # A slack of _BRANCH_TOL keeps the saturation point itself inside the
200
+ # bracket (the spline carries ~6e-8 relative error) while excluding the
201
+ # dome interior, whose roots sit tens of percent away.
202
+ lo = jnp.where(jnp.logical_and(subcritical_T, liquid_side),
203
+ rho_l_T * (1.0 - _BRANCH_TOL), _BISECT_LO)
204
+ hi = jnp.where(jnp.logical_and(subcritical_T, jnp.logical_not(liquid_side)),
205
+ rho_v_T * (1.0 + _BRANCH_TOL), _BISECT_HI)
206
+ return lo, hi
207
+
208
+
209
+ def _bisection_solve(T, P, lo=_BISECT_LO, hi=_BISECT_HI):
210
+ """Bisection solve for ρ given (T, P) on [lo, hi]. Scalar inputs."""
211
+ init_state = (jnp.asarray(lo, dtype=jnp.float64), jnp.asarray(hi, dtype=jnp.float64),
212
+ T, P, jnp.int32(0))
176
213
  final_state = jax.lax.while_loop(_bisection_cond, _bisection_body, init_state)
177
214
  lo, hi, _, _, _ = final_state
178
215
  return 0.5 * (lo + hi)
179
216
 
180
217
 
218
+ def _on_requested_branch(T, P, rho, phase_hint):
219
+ """Under the auto hint below TC, is the converged root on the stable branch?
220
+
221
+ A root inside the dome satisfies P(T, ρ) = P_target with ∂P/∂ρ > 0 and
222
+ passes every residual test, so the only check that catches it is its
223
+ position relative to the saturation densities. Explicit LIQUID/VAPOR hints
224
+ are the caller's assertion (a metastable request is legitimate) and are
225
+ not checked.
226
+ """
227
+ subcritical_T = T < sw.TC
228
+ liquid_side, rho_l_T, rho_v_T = _subcritical_branch(T, P)
229
+ on_branch = jnp.where(liquid_side, rho >= rho_l_T * (1.0 - _BRANCH_TOL),
230
+ rho <= rho_v_T * (1.0 + _BRANCH_TOL))
231
+ checked = jnp.logical_and(subcritical_T, phase_hint == SUPERCRITICAL)
232
+ return jnp.logical_or(jnp.logical_not(checked), on_branch)
233
+
234
+
181
235
  def _solve_density(T, P, phase_hint):
182
- """Halley solve for ρ given (T, P, phase_hint), with bisection fallback. Scalar inputs."""
236
+ """Halley solve for ρ given (T, P, phase_hint), with bisection fallback. Scalar inputs.
237
+
238
+ A root on the wrong side of the dome under the auto hint is returned as
239
+ NaN, the package's failure signal, never as a number.
240
+ """
183
241
  rho0 = _initial_guess(T, P, phase_hint)
184
242
  init_state = (rho0, T, P, jnp.int32(0), jnp.bool_(False))
185
243
  final_state = jax.lax.while_loop(_halley_cond, _halley_body, init_state)
@@ -187,17 +245,19 @@ def _solve_density(T, P, phase_hint):
187
245
 
188
246
  # Bisection safety net — only runs when Halley did not converge
189
247
  def _bisect_branch(_):
190
- rho_bisect = _bisection_solve(T, P)
248
+ lo, hi = _bisection_bracket(T, P, phase_hint)
249
+ rho_bisect = _bisection_solve(T, P, lo, hi)
191
250
  bisect_residual = jnp.abs(sw._scalar_pressure(T, rho_bisect) - P)
192
251
  bisect_ok = bisect_residual < 1e-3 * jnp.maximum(jnp.abs(P), 1.0)
193
252
  return jnp.where(bisect_ok, rho_bisect, jnp.nan)
194
253
 
195
- return jax.lax.cond(
254
+ rho = jax.lax.cond(
196
255
  converged,
197
256
  lambda _: rho_halley,
198
257
  _bisect_branch,
199
258
  None,
200
259
  )
260
+ return jnp.where(_on_requested_branch(T, P, rho, phase_hint), rho, jnp.nan)
201
261
 
202
262
 
203
263
  # ── Implicit differentiation via custom_jvp ───────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: co2-eos
3
- Version: 0.3.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
@@ -18,6 +18,7 @@ co2_eos/data/saturation_table.npz
18
18
  co2_eos/data/seed_table.npz
19
19
  tests/test_accuracy_vs_coolprop.py
20
20
  tests/test_analytic_derivs.py
21
+ tests/test_density_from_PT_branch.py
21
22
  tests/test_dome_detection.py
22
23
  tests/test_fastpath_validation.py
23
24
  tests/test_inversion_gradients.py
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "co2-eos"
9
- version = "0.3.0"
9
+ version = "0.3.1"
10
10
  description = "Differentiable CO₂ thermodynamic properties in JAX"
11
11
  readme = "README.md"
12
12
  license = "Apache-2.0"
@@ -0,0 +1,113 @@
1
+ """
2
+ density_from_PT below TC must land on the stable branch under the auto hint.
3
+
4
+ Below TC the Span-Wagner isotherm is an analytic continuation through the dome
5
+ and loops between the spinodals; at 294 K it swings up to about 12 MPa near
6
+ 550 kg/m³, so P(T, ρ) = P_target has roots inside the dome for any target below
7
+ that swing, each with ∂P/∂ρ > 0 and a zero residual. A solve seeded inside the
8
+ dome converges to one of them and reports nothing wrong. The regression that
9
+ exposed this: T = 294.15 K (0.02 K above the old `T < TC - 10` liquid guard),
10
+ P = 8.12 and 9.04 MPa, auto hint, returned 499 and 506 kg/m³ against the
11
+ compressed-liquid 821 and 836.
12
+
13
+ The contract tested here: under the auto hint the seed, the bisection bracket
14
+ and the branch guard all follow P against P_sat(T), so the result matches
15
+ CoolProp on the liquid side (P ≥ P_sat) and on the vapor side (P < P_sat) at
16
+ every subcritical temperature, through jit and vmap, and a root on the wrong
17
+ side of the dome is never returned as a number.
18
+ """
19
+
20
+ import numpy as np
21
+ import pytest
22
+ import jax
23
+ import jax.numpy as jnp
24
+
25
+ jax.config.update("jax_enable_x64", True)
26
+
27
+ import co2_eos
28
+ from co2_eos import span_wagner as sw
29
+ from co2_eos import saturation as sat
30
+ from co2_eos.inversions import _on_requested_branch
31
+
32
+ CP = pytest.importorskip("CoolProp.CoolProp")
33
+
34
+ RTOL = 1e-8
35
+
36
+
37
+ def _ref(T, P):
38
+ return CP.PropsSI("D", "T", float(T), "P", float(P), "CO2")
39
+
40
+
41
+ # ── The regression points ───────────────────────────────────────────────────
42
+
43
+ @pytest.mark.parametrize("T,P", [
44
+ (294.15, 8.12e6),
45
+ (294.15, 9.04e6),
46
+ (294.15, 6.5e6),
47
+ (296.15, 6.5e6),
48
+ ])
49
+ def test_regression_points_match_liquid_hint_and_coolprop(T, P):
50
+ rho_auto = float(co2_eos.density_from_PT(P, T, co2_eos.AUTO))
51
+ rho_liq = float(co2_eos.density_from_PT(P, T, co2_eos.LIQUID))
52
+ ref = _ref(T, P)
53
+ assert rho_auto == pytest.approx(ref, rel=RTOL)
54
+ assert rho_liq == pytest.approx(ref, rel=RTOL)
55
+
56
+
57
+ # ── Both sides of the dome, every subcritical temperature ───────────────────
58
+
59
+ def _subcritical_grid():
60
+ for T in np.linspace(220.0, 304.0, 22):
61
+ P_sat = CP.PropsSI("P", "T", T, "Q", 0, "CO2")
62
+ for P in np.geomspace(P_sat * 1.0005, 30e6, 8):
63
+ yield T, P, "liquid"
64
+ for P in np.geomspace(0.1e6, P_sat * 0.9995, 5):
65
+ yield T, P, "vapor"
66
+
67
+
68
+ @pytest.mark.parametrize("T,P,side", list(_subcritical_grid()))
69
+ def test_auto_hint_follows_P_sat_below_TC(T, P, side):
70
+ rho = float(co2_eos.density_from_PT(P, T, co2_eos.AUTO))
71
+ ref = _ref(T, P)
72
+ assert np.isfinite(rho), f"NaN at T={T}, P={P} ({side})"
73
+ assert rho == pytest.approx(ref, rel=RTOL)
74
+ rho_l, rho_v = sat.saturation_densities(jnp.float64(T))
75
+ if side == "liquid":
76
+ assert rho >= float(rho_l) * (1.0 - 1e-4)
77
+ else:
78
+ assert rho <= float(rho_v) * (1.0 + 1e-4)
79
+
80
+
81
+ # ── jit and vmap through the public surface, mixed hints and sides ──────────
82
+
83
+ def test_jit_vmap_mixed_hints():
84
+ P = jnp.array([8.12e6, 9.04e6, 6.5e6, 9.04e6, 12e6, 3.0e6])
85
+ T = jnp.array([294.15, 294.15, 296.15, 320.0, 310.0, 290.0])
86
+ hint = jnp.array([co2_eos.AUTO, co2_eos.AUTO, co2_eos.AUTO,
87
+ co2_eos.AUTO, co2_eos.LIQUID, co2_eos.AUTO])
88
+ f = jax.jit(jax.vmap(co2_eos.density_from_PT, in_axes=(0, 0, 0)))
89
+ rho = np.asarray(f(P, T, hint))
90
+ ref = np.array([_ref(t, p) for p, t in zip(np.asarray(P), np.asarray(T))])
91
+ np.testing.assert_allclose(rho, ref, rtol=RTOL)
92
+
93
+
94
+ def test_gradient_on_the_liquid_branch_near_the_old_hole():
95
+ T, P = 294.15, 9.04e6
96
+ g = float(jax.grad(lambda t: co2_eos.density_from_PT(P, t, co2_eos.AUTO))(T))
97
+ ref = CP.PropsSI("d(D)/d(T)|P", "T", T, "P", P, "CO2")
98
+ assert g == pytest.approx(ref, rel=1e-6)
99
+
100
+
101
+ # ── The branch guard itself ────────────────────────────────────────────────
102
+
103
+ def test_branch_guard_rejects_a_dome_root_under_auto_only():
104
+ T, P = jnp.float64(294.15), jnp.float64(8.12e6)
105
+ dome_root = jnp.float64(499.33) # the old spurious answer
106
+ liquid_root = jnp.float64(820.72)
107
+ assert not bool(_on_requested_branch(T, P, dome_root, jnp.int32(co2_eos.AUTO)))
108
+ assert bool(_on_requested_branch(T, P, liquid_root, jnp.int32(co2_eos.AUTO)))
109
+ # explicit hints are the caller's assertion and are not checked
110
+ assert bool(_on_requested_branch(T, P, dome_root, jnp.int32(co2_eos.LIQUID)))
111
+ # above TC nothing is checked
112
+ assert bool(_on_requested_branch(jnp.float64(sw.TC + 5.0), P, dome_root,
113
+ jnp.int32(co2_eos.AUTO)))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes