co2-eos 0.1.0__py3-none-any.whl
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.
- co2_eos/__init__.py +265 -0
- co2_eos/data/saturation_table.npz +0 -0
- co2_eos/inversions.py +695 -0
- co2_eos/saturation.py +156 -0
- co2_eos/span_wagner.py +501 -0
- co2_eos/transport.py +286 -0
- co2_eos-0.1.0.dist-info/METADATA +193 -0
- co2_eos-0.1.0.dist-info/RECORD +11 -0
- co2_eos-0.1.0.dist-info/WHEEL +5 -0
- co2_eos-0.1.0.dist-info/licenses/LICENSE +201 -0
- co2_eos-0.1.0.dist-info/top_level.txt +1 -0
co2_eos/inversions.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermodynamic inversions — pure JAX, JIT-compilable, vmappable,
|
|
3
|
+
with correct gradients via implicit differentiation (custom_jvp).
|
|
4
|
+
|
|
5
|
+
Defines custom_jvp rules so that both forward-mode (jvp) and reverse-mode
|
|
6
|
+
(vjp, via transposition) AD work through the Newton solvers.
|
|
7
|
+
|
|
8
|
+
- density_from_PT(T, P, phase_hint): 1D inversion P(T,ρ) = P_target
|
|
9
|
+
- state_from_Ph(P, h, phase_hint): 2D inversion [P(T,ρ), h(T,ρ)] = [P_target, h_target]
|
|
10
|
+
- temperature_from_Du(rho, u, phase_hint): 1D inversion u(T,ρ) = u_target at fixed ρ
|
|
11
|
+
- state_from_Du(rho, u, phase_hint): convenience wrapper returning (T, P, h)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
|
|
17
|
+
from co2_eos import span_wagner as sw
|
|
18
|
+
from co2_eos import saturation as sat
|
|
19
|
+
|
|
20
|
+
# Enable float64
|
|
21
|
+
jax.config.update("jax_enable_x64", True)
|
|
22
|
+
|
|
23
|
+
# Eagerly load the saturation table at module-import time (outside any trace
|
|
24
|
+
# context). If the table is loaded for the first time inside a JIT trace,
|
|
25
|
+
# the arrays it materialises end up tagged as tracers, breaking reverse-mode
|
|
26
|
+
# differentiation through density_from_PT. Loading it here pins them as
|
|
27
|
+
# concrete constants. If the file is missing we tolerate it — first
|
|
28
|
+
# inversion call will surface a clear FileNotFoundError.
|
|
29
|
+
try:
|
|
30
|
+
sat._ensure_loaded()
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
# Phase hint constants
|
|
35
|
+
LIQUID = 0
|
|
36
|
+
VAPOR = 1
|
|
37
|
+
SUPERCRITICAL = 2
|
|
38
|
+
|
|
39
|
+
# Newton solver parameters
|
|
40
|
+
MAX_ITER = 50
|
|
41
|
+
TOL = 1e-10
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Pressure and its density-derivative (scalar) ─────────────────────────
|
|
45
|
+
|
|
46
|
+
_dP_drho = jax.grad(sw._scalar_pressure, argnums=1)
|
|
47
|
+
_d2P_drho2 = jax.grad(jax.grad(sw._scalar_pressure, argnums=1), argnums=1)
|
|
48
|
+
_dP_dT = jax.grad(sw._scalar_pressure, argnums=0)
|
|
49
|
+
_dh_dT = jax.grad(sw._scalar_enthalpy, argnums=0)
|
|
50
|
+
_dh_drho = jax.grad(sw._scalar_enthalpy, argnums=1)
|
|
51
|
+
_du_dT = jax.grad(sw._scalar_internal_energy, argnums=0)
|
|
52
|
+
_du_drho = jax.grad(sw._scalar_internal_energy, argnums=1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Phase-aware initial guess ─────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def _initial_guess(T, P, phase_hint):
|
|
58
|
+
"""Return an initial density guess based on phase_hint.
|
|
59
|
+
|
|
60
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical/auto.
|
|
61
|
+
Uses jnp.where for JIT traceability.
|
|
62
|
+
"""
|
|
63
|
+
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)
|
|
68
|
+
|
|
69
|
+
# Liquid: saturation liquid density at T (good for compressed liquid),
|
|
70
|
+
# fall back to 2*RHOC for supercritical T
|
|
71
|
+
liquid_guess = jnp.where(subcritical_T, rho_l_T, 2.0 * sw.RHOC)
|
|
72
|
+
# Vapor: saturation vapor density at T, fall back to ideal gas
|
|
73
|
+
ideal_gas_rho = P / (sw.R * T)
|
|
74
|
+
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.
|
|
80
|
+
near_critical = jnp.logical_and(
|
|
81
|
+
jnp.abs(P - sw.PC) < 0.2 * sw.PC,
|
|
82
|
+
jnp.abs(T - sw.TC) < 15.0,
|
|
83
|
+
)
|
|
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
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
rho0 = jnp.where(
|
|
94
|
+
phase_hint == LIQUID, liquid_guess,
|
|
95
|
+
jnp.where(phase_hint == VAPOR, vapor_guess, sc_guess)
|
|
96
|
+
)
|
|
97
|
+
return rho0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Sign-preserving denominator guard ─────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _safe_denom(x, eps=1e-30):
|
|
103
|
+
"""Guard a denominator: preserve sign for small values, fall back to +eps for NaN."""
|
|
104
|
+
return jnp.where(
|
|
105
|
+
jnp.isnan(x), eps,
|
|
106
|
+
jnp.where(jnp.abs(x) > eps, x, jnp.where(x >= 0, eps, -eps))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Halley solver via lax.while_loop ─────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _halley_body(state):
|
|
113
|
+
"""One Halley step (Householder order 2) for cubic convergence.
|
|
114
|
+
|
|
115
|
+
ρ_{n+1} = ρ_n − 2·f·f' / (2·f'² − f·f'')
|
|
116
|
+
"""
|
|
117
|
+
rho, T, P_target, i, converged = state
|
|
118
|
+
f = sw._scalar_pressure(T, rho) - P_target
|
|
119
|
+
fp = _dP_drho(T, rho)
|
|
120
|
+
fpp = _d2P_drho2(T, rho)
|
|
121
|
+
# Halley denominator: 2·f'² − f·f''
|
|
122
|
+
halley_denom = 2.0 * fp * fp - f * fpp
|
|
123
|
+
step = 2.0 * f * fp / _safe_denom(halley_denom)
|
|
124
|
+
# Step damping: |Δρ/ρ| ≤ 50% per iteration
|
|
125
|
+
step = jnp.clip(step, -0.5 * rho, 0.5 * rho)
|
|
126
|
+
rho_new = rho - step
|
|
127
|
+
# Clamp to stay positive
|
|
128
|
+
rho_new = jnp.maximum(rho_new, 1.0)
|
|
129
|
+
converged_new = jnp.logical_or(
|
|
130
|
+
jnp.abs(f) < TOL,
|
|
131
|
+
jnp.abs(step) < 1e-13 * jnp.maximum(jnp.abs(rho_new), 1.0)
|
|
132
|
+
)
|
|
133
|
+
return (rho_new, T, P_target, i + 1, converged_new)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _halley_cond(state):
|
|
137
|
+
"""Continue while not converged and under max iterations."""
|
|
138
|
+
rho, T, P_target, i, converged = state
|
|
139
|
+
return jnp.logical_and(i < MAX_ITER, jnp.logical_not(converged))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Bisection fallback via lax.while_loop ────────────────────────────────
|
|
143
|
+
|
|
144
|
+
_BISECT_LO = 1.0 # kg/m³ — lower bound
|
|
145
|
+
_BISECT_HI = 1200.0 # kg/m³ — upper bound
|
|
146
|
+
_BISECT_ITERS = 60 # ~10⁻¹⁸ relative precision
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _bisection_body(state):
|
|
150
|
+
"""One bisection step on f(ρ) = P(T,ρ) − P_target.
|
|
151
|
+
|
|
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).
|
|
157
|
+
"""
|
|
158
|
+
lo, hi, T, P_target, i = state
|
|
159
|
+
mid = 0.5 * (lo + hi)
|
|
160
|
+
f_mid = sw._scalar_pressure(T, mid) - P_target
|
|
161
|
+
# P(T,ρ) is monotonically increasing in ρ for single-phase fluid,
|
|
162
|
+
# so f_mid > 0 means mid is too high.
|
|
163
|
+
lo_new = jnp.where(f_mid < 0.0, mid, lo)
|
|
164
|
+
hi_new = jnp.where(f_mid < 0.0, hi, mid)
|
|
165
|
+
return (lo_new, hi_new, T, P_target, i + 1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _bisection_cond(state):
|
|
169
|
+
lo, hi, T, P_target, i = state
|
|
170
|
+
return jnp.logical_and(i < _BISECT_ITERS, (hi - lo) > 1e-14 * hi)
|
|
171
|
+
|
|
172
|
+
|
|
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))
|
|
176
|
+
final_state = jax.lax.while_loop(_bisection_cond, _bisection_body, init_state)
|
|
177
|
+
lo, hi, _, _, _ = final_state
|
|
178
|
+
return 0.5 * (lo + hi)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _solve_density(T, P, phase_hint):
|
|
182
|
+
"""Halley solve for ρ given (T, P, phase_hint), with bisection fallback. Scalar inputs."""
|
|
183
|
+
rho0 = _initial_guess(T, P, phase_hint)
|
|
184
|
+
init_state = (rho0, T, P, jnp.int32(0), jnp.bool_(False))
|
|
185
|
+
final_state = jax.lax.while_loop(_halley_cond, _halley_body, init_state)
|
|
186
|
+
rho_halley, _, _, _, converged = final_state
|
|
187
|
+
|
|
188
|
+
# Bisection safety net — only runs when Halley did not converge
|
|
189
|
+
def _bisect_branch(_):
|
|
190
|
+
rho_bisect = _bisection_solve(T, P)
|
|
191
|
+
bisect_residual = jnp.abs(sw._scalar_pressure(T, rho_bisect) - P)
|
|
192
|
+
bisect_ok = bisect_residual < 1e-3 * jnp.maximum(jnp.abs(P), 1.0)
|
|
193
|
+
return jnp.where(bisect_ok, rho_bisect, jnp.nan)
|
|
194
|
+
|
|
195
|
+
return jax.lax.cond(
|
|
196
|
+
converged,
|
|
197
|
+
lambda _: rho_halley,
|
|
198
|
+
_bisect_branch,
|
|
199
|
+
None,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Implicit differentiation via custom_jvp ───────────────────────────────
|
|
204
|
+
|
|
205
|
+
@jax.custom_jvp
|
|
206
|
+
def _density_from_PT(T, P, phase_hint):
|
|
207
|
+
"""Find ρ such that P(T,ρ) = P_target. Scalar inputs.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
T: Temperature [K] (scalar)
|
|
211
|
+
P: Pressure [Pa] (scalar)
|
|
212
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical (integer scalar)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
rho: Density [kg/m³] (scalar)
|
|
216
|
+
"""
|
|
217
|
+
return _solve_density(T, P, phase_hint)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@_density_from_PT.defjvp
|
|
221
|
+
def _density_from_PT_jvp(primals, tangents):
|
|
222
|
+
"""JVP via implicit function theorem.
|
|
223
|
+
|
|
224
|
+
P(T, ρ) = P_target → (∂P/∂T)dT + (∂P/∂ρ)dρ = dP
|
|
225
|
+
⇒ dρ = (dP − (∂P/∂T)·dT) / (∂P/∂ρ)
|
|
226
|
+
"""
|
|
227
|
+
T, P, phase_hint = primals
|
|
228
|
+
dT, dP, _ = tangents
|
|
229
|
+
|
|
230
|
+
rho_star = _density_from_PT(T, P, phase_hint)
|
|
231
|
+
|
|
232
|
+
dP_drho_val = _dP_drho(T, rho_star)
|
|
233
|
+
dP_dT_val = _dP_dT(T, rho_star)
|
|
234
|
+
|
|
235
|
+
drho = (dP - dP_dT_val * dT) / _safe_denom(dP_drho_val)
|
|
236
|
+
|
|
237
|
+
return rho_star, drho
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
@jax.jit
|
|
243
|
+
def density_from_PT(T, P, phase_hint):
|
|
244
|
+
"""Find ρ [kg/m³] such that P(T,ρ) = P_target.
|
|
245
|
+
|
|
246
|
+
All inputs broadcast: scalar or 1-D arrays of the same length.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
T: Temperature [K]
|
|
250
|
+
P: Pressure [Pa]
|
|
251
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical/auto
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
rho: Density [kg/m³]
|
|
255
|
+
"""
|
|
256
|
+
T = jnp.asarray(T, dtype=jnp.float64)
|
|
257
|
+
P = jnp.asarray(P, dtype=jnp.float64)
|
|
258
|
+
phase_hint = jnp.asarray(phase_hint, dtype=jnp.int32)
|
|
259
|
+
return jax.vmap(_density_from_PT)(T, P, phase_hint)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ═════════════════════════════════════════════════════════════════════════
|
|
263
|
+
# 2D inversion: state_from_Ph — find (T, ρ) given (P, h)
|
|
264
|
+
# ═════════════════════════════════════════════════════════════════════════
|
|
265
|
+
|
|
266
|
+
# ── Initial guess for (T, ρ) from (P, h) ──────────────────────────────
|
|
267
|
+
|
|
268
|
+
def _initial_guess_Ph(P, h, phase_hint):
|
|
269
|
+
"""Estimate (T, ρ) from (P, h) for the 2D Newton solver.
|
|
270
|
+
|
|
271
|
+
Strategy:
|
|
272
|
+
- Sub-critical P: use T_sat(P) as temperature estimate.
|
|
273
|
+
Compare h to saturation enthalpies to decide phase.
|
|
274
|
+
- Super-critical P: estimate T from ideal-gas enthalpy inversion
|
|
275
|
+
(h ≈ cp0 * T → T ≈ h / cp0), clamped to a reasonable range.
|
|
276
|
+
- Then call the 1D density solver to get ρ from (T_guess, P).
|
|
277
|
+
"""
|
|
278
|
+
subcritical = P < sw.PC
|
|
279
|
+
|
|
280
|
+
# Sub-critical path: T_sat from saturation table
|
|
281
|
+
T_sat = sat.saturation_temperature(P)
|
|
282
|
+
h_l, h_v = sat.saturation_enthalpies(P)
|
|
283
|
+
|
|
284
|
+
# If h > h_v → superheated vapor, estimate T above T_sat
|
|
285
|
+
# If h < h_l → compressed liquid, use T_sat as starting point
|
|
286
|
+
# Otherwise → near saturation, use T_sat
|
|
287
|
+
T_sub = jnp.where(
|
|
288
|
+
h > h_v,
|
|
289
|
+
T_sat + (h - h_v) / (sw.R * 3.5), # rough Cp estimate
|
|
290
|
+
jnp.where(h < h_l, T_sat - 1.0, T_sat)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Super-critical path: anchor on pseudocritical temperature.
|
|
294
|
+
# Near Pc, the Clausius-Clapeyron slope dT/dP ≈ 0.028 K/kPa ≈ 28 K/MPa.
|
|
295
|
+
# This extrapolation gives T_pc ≈ Tc + (P - Pc) * slope, which tracks
|
|
296
|
+
# the Cp peak (Widom line) much better than an ideal-gas estimate.
|
|
297
|
+
dTdP_sat = 2.8e-5 # K/Pa — Clausius-Clapeyron slope near Tc
|
|
298
|
+
T_pseudo = sw.TC + (P - sw.PC) * dTdP_sat
|
|
299
|
+
|
|
300
|
+
# Ideal-gas fallback for pressures far above Pc where the pseudocritical
|
|
301
|
+
# extrapolation becomes less relevant
|
|
302
|
+
T_ideal = h / (sw.R * 5.0)
|
|
303
|
+
|
|
304
|
+
# Use pseudocritical anchor when P < 2*Pc (near-critical), else ideal-gas
|
|
305
|
+
T_super = jnp.where(P < 2.0 * sw.PC, T_pseudo, T_ideal)
|
|
306
|
+
|
|
307
|
+
# Pseudocritical-aware override: when P is close to PC and h is in the
|
|
308
|
+
# pseudocritical enthalpy band, start from TC. The Widom-line Cp peak
|
|
309
|
+
# causes the Newton iteration to oscillate wildly if the initial T guess
|
|
310
|
+
# is on the wrong side; anchoring to TC lets the damped Newton converge.
|
|
311
|
+
h_crit = sw._scalar_enthalpy(sw.TC, sw.RHOC) # enthalpy at critical point
|
|
312
|
+
near_pc = jnp.abs(P - sw.PC) < 0.5 * sw.PC
|
|
313
|
+
h_in_band = jnp.abs(h - h_crit) < 100.0e3 # ±100 kJ/kg
|
|
314
|
+
T_super = jnp.where(jnp.logical_and(near_pc, h_in_band), sw.TC, T_super)
|
|
315
|
+
|
|
316
|
+
# Compressed-liquid override: when P > PC but h is well below the
|
|
317
|
+
# critical-point enthalpy, the fluid is subcooled liquid. Estimate T
|
|
318
|
+
# from a rough liquid Cp (~2500 J/kg/K) below TC rather than anchoring
|
|
319
|
+
# on the pseudocritical line which overshoots into the supercritical region.
|
|
320
|
+
Cp_liq = 2500.0 # J/(kg·K) — rough subcooled CO₂ liquid heat capacity
|
|
321
|
+
T_liq_est = sw.TC - (h_crit - h) / Cp_liq
|
|
322
|
+
h_clearly_liquid = h < h_crit - 50.0e3 # h at least 50 kJ/kg below h_crit
|
|
323
|
+
T_super = jnp.where(h_clearly_liquid, T_liq_est, T_super)
|
|
324
|
+
|
|
325
|
+
T_super = jnp.clip(T_super, 250.0, 800.0)
|
|
326
|
+
|
|
327
|
+
T0 = jnp.where(subcritical, T_sub, T_super)
|
|
328
|
+
# Clamp T to physical range
|
|
329
|
+
T0 = jnp.clip(T0, sw.T_TRIPLE, 800.0)
|
|
330
|
+
|
|
331
|
+
rho0 = _solve_density(T0, P, phase_hint)
|
|
332
|
+
return T0, rho0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── 2D Newton solver ──────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
def _newton_body_2d(state):
|
|
338
|
+
"""One 2D Newton step on F = [P(T,ρ) - P_target, h(T,ρ) - h_target]."""
|
|
339
|
+
T, rho, P_target, h_target, i, converged = state
|
|
340
|
+
|
|
341
|
+
# Residual
|
|
342
|
+
fP = sw._scalar_pressure(T, rho) - P_target
|
|
343
|
+
fh = sw._scalar_enthalpy(T, rho) - h_target
|
|
344
|
+
|
|
345
|
+
# Jacobian: J = [[dP/dT, dP/drho], [dh/dT, dh/drho]]
|
|
346
|
+
j00 = _dP_dT(T, rho)
|
|
347
|
+
j01 = _dP_drho(T, rho)
|
|
348
|
+
j10 = _dh_dT(T, rho)
|
|
349
|
+
j11 = _dh_drho(T, rho)
|
|
350
|
+
|
|
351
|
+
# Analytic 2×2 inverse: J^{-1} = (1/det) * [[j11, -j01], [-j10, j00]]
|
|
352
|
+
det = j00 * j11 - j01 * j10
|
|
353
|
+
|
|
354
|
+
dT = (j11 * fP - j01 * fh) / _safe_denom(det)
|
|
355
|
+
drho = (-j10 * fP + j00 * fh) / _safe_denom(det)
|
|
356
|
+
|
|
357
|
+
# Step damping: prevent overshooting across Cp peak near Widom line
|
|
358
|
+
dT = jnp.clip(dT, -20.0, 20.0)
|
|
359
|
+
drho = jnp.clip(drho, -0.5 * rho, 0.5 * rho)
|
|
360
|
+
|
|
361
|
+
T_new = T - dT
|
|
362
|
+
rho_new = rho - drho
|
|
363
|
+
|
|
364
|
+
# Clamp to physical bounds
|
|
365
|
+
T_new = jnp.maximum(T_new, sw.T_TRIPLE)
|
|
366
|
+
rho_new = jnp.maximum(rho_new, 1.0)
|
|
367
|
+
|
|
368
|
+
residual_small = jnp.maximum(jnp.abs(fP), jnp.abs(fh)) < TOL
|
|
369
|
+
step_small = jnp.logical_and(
|
|
370
|
+
jnp.abs(dT) < 1e-13 * jnp.maximum(jnp.abs(T_new), 1.0),
|
|
371
|
+
jnp.abs(drho) < 1e-13 * jnp.maximum(jnp.abs(rho_new), 1.0)
|
|
372
|
+
)
|
|
373
|
+
converged_new = jnp.logical_or(residual_small, step_small)
|
|
374
|
+
return (T_new, rho_new, P_target, h_target, i + 1, converged_new)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _newton_cond_2d(state):
|
|
378
|
+
"""Continue while not converged and under max iterations."""
|
|
379
|
+
T, rho, P_target, h_target, i, converged = state
|
|
380
|
+
return jnp.logical_and(i < MAX_ITER, jnp.logical_not(converged))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _solve_state_Ph(P, h, phase_hint):
|
|
384
|
+
"""2D Newton solve for (T, ρ) given (P, h, phase_hint). Scalar inputs."""
|
|
385
|
+
T0, rho0 = _initial_guess_Ph(P, h, phase_hint)
|
|
386
|
+
init_state = (T0, rho0, P, h, jnp.int32(0), jnp.bool_(False))
|
|
387
|
+
final_state = jax.lax.while_loop(_newton_cond_2d, _newton_body_2d, init_state)
|
|
388
|
+
T_out, rho_out, _, _, _, converged = final_state
|
|
389
|
+
return (jnp.where(converged, T_out, jnp.nan),
|
|
390
|
+
jnp.where(converged, rho_out, jnp.nan))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ── Implicit differentiation via custom_jvp ──────────────────────────
|
|
394
|
+
|
|
395
|
+
@jax.custom_jvp
|
|
396
|
+
def _state_from_Ph(P, h, phase_hint):
|
|
397
|
+
"""Find (T, ρ) such that P(T,ρ) = P and h(T,ρ) = h. Scalar inputs.
|
|
398
|
+
|
|
399
|
+
For subcritical P where h falls inside the two-phase dome (h_l ≤ h ≤ h_v),
|
|
400
|
+
returns saturation-boundary properties instead of solving the single-phase
|
|
401
|
+
Newton: T = T_sat(P), ρ = ρ_l(P) or ρ_v(P) depending on phase_hint.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
P: Pressure [Pa] (scalar)
|
|
405
|
+
h: Specific enthalpy [J/kg] (scalar)
|
|
406
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical (integer scalar)
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
(T, rho): Temperature [K] and density [kg/m³] (scalars)
|
|
410
|
+
"""
|
|
411
|
+
# --- Dome detection ---
|
|
412
|
+
# Saturation lookups (always evaluated; JAX semantics)
|
|
413
|
+
T_sat = sat.saturation_temperature(P)
|
|
414
|
+
h_l, h_v = sat.saturation_enthalpies(P)
|
|
415
|
+
rho_l, rho_v = sat.saturation_densities_P(P)
|
|
416
|
+
|
|
417
|
+
in_dome = jnp.logical_and(P < sw.PC, jnp.logical_and(h >= h_l, h <= h_v))
|
|
418
|
+
|
|
419
|
+
# Dome-branch density: respect phase_hint
|
|
420
|
+
# LIQUID → ρ_l, VAPOR → ρ_v, SUPERCRITICAL → pick by enthalpy midpoint
|
|
421
|
+
h_mid = 0.5 * (h_l + h_v)
|
|
422
|
+
rho_dome = jnp.where(
|
|
423
|
+
phase_hint == LIQUID, rho_l,
|
|
424
|
+
jnp.where(phase_hint == VAPOR, rho_v,
|
|
425
|
+
jnp.where(h < h_mid, rho_l, rho_v))
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# --- Single-phase path (Newton solver, unchanged) ---
|
|
429
|
+
T_sp, rho_sp = _solve_state_Ph(P, h, phase_hint)
|
|
430
|
+
|
|
431
|
+
# Merge: dome wins when inside
|
|
432
|
+
T_out = jnp.where(in_dome, T_sat, T_sp)
|
|
433
|
+
rho_out = jnp.where(in_dome, rho_dome, rho_sp)
|
|
434
|
+
return T_out, rho_out
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@_state_from_Ph.defjvp
|
|
438
|
+
def _state_from_Ph_jvp(primals, tangents):
|
|
439
|
+
"""JVP via implicit function theorem on F(T,ρ; P,h) = 0.
|
|
440
|
+
|
|
441
|
+
Single-phase path:
|
|
442
|
+
F = [P(T,ρ) - P_target, h(T,ρ) - h_target]
|
|
443
|
+
J = [[∂P/∂T, ∂P/∂ρ], [∂h/∂T, ∂h/∂ρ]]
|
|
444
|
+
[dT, dρ] = J⁻¹ · [dP, dh]
|
|
445
|
+
|
|
446
|
+
Dome path:
|
|
447
|
+
T = T_sat(P), so dT = (dT_sat/dP)·dP, dT/dh = 0
|
|
448
|
+
ρ = ρ_branch(P), so dρ = (dρ_branch/dP)·dP, dρ/dh = 0
|
|
449
|
+
"""
|
|
450
|
+
P, h, phase_hint = primals
|
|
451
|
+
dP, dh, _ = tangents
|
|
452
|
+
|
|
453
|
+
T_star, rho_star = _state_from_Ph(P, h, phase_hint)
|
|
454
|
+
|
|
455
|
+
# --- Dome detection (same logic as primal) ---
|
|
456
|
+
h_l, h_v = sat.saturation_enthalpies(P)
|
|
457
|
+
in_dome = jnp.logical_and(P < sw.PC, jnp.logical_and(h >= h_l, h <= h_v))
|
|
458
|
+
|
|
459
|
+
# --- Dome tangents via autodiff through saturation splines ---
|
|
460
|
+
dTsat_dP = jax.grad(sat.saturation_temperature)(P)
|
|
461
|
+
|
|
462
|
+
# Differentiate the selected density branch w.r.t. P
|
|
463
|
+
def _rho_dome_of_P(P_):
|
|
464
|
+
rho_l_, rho_v_ = sat.saturation_densities_P(P_)
|
|
465
|
+
h_l_, h_v_ = sat.saturation_enthalpies(P_)
|
|
466
|
+
h_mid_ = 0.5 * (h_l_ + h_v_)
|
|
467
|
+
return jnp.where(
|
|
468
|
+
phase_hint == LIQUID, rho_l_,
|
|
469
|
+
jnp.where(phase_hint == VAPOR, rho_v_,
|
|
470
|
+
jnp.where(h < h_mid_, rho_l_, rho_v_))
|
|
471
|
+
)
|
|
472
|
+
drhodome_dP = jax.grad(_rho_dome_of_P)(P)
|
|
473
|
+
|
|
474
|
+
dT_dome = dTsat_dP * dP
|
|
475
|
+
drho_dome = drhodome_dP * dP
|
|
476
|
+
|
|
477
|
+
# --- Single-phase tangents (implicit function theorem) ---
|
|
478
|
+
j00 = _dP_dT(T_star, rho_star)
|
|
479
|
+
j01 = _dP_drho(T_star, rho_star)
|
|
480
|
+
j10 = _dh_dT(T_star, rho_star)
|
|
481
|
+
j11 = _dh_drho(T_star, rho_star)
|
|
482
|
+
|
|
483
|
+
det = j00 * j11 - j01 * j10
|
|
484
|
+
det_safe = _safe_denom(det)
|
|
485
|
+
|
|
486
|
+
# J⁻¹ = (1/det) * [[j11, -j01], [-j10, j00]]
|
|
487
|
+
dT_sp = (j11 * dP - j01 * dh) / det_safe
|
|
488
|
+
drho_sp = (-j10 * dP + j00 * dh) / det_safe
|
|
489
|
+
|
|
490
|
+
# Merge tangents
|
|
491
|
+
dT = jnp.where(in_dome, dT_dome, dT_sp)
|
|
492
|
+
drho = jnp.where(in_dome, drho_dome, drho_sp)
|
|
493
|
+
|
|
494
|
+
return (T_star, rho_star), (dT, drho)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
@jax.jit
|
|
500
|
+
def state_from_Ph(P, h, phase_hint):
|
|
501
|
+
"""Find (T [K], ρ [kg/m³]) such that P(T,ρ) = P and h(T,ρ) = h.
|
|
502
|
+
|
|
503
|
+
All inputs broadcast: scalar or 1-D arrays of the same length.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
P: Pressure [Pa]
|
|
507
|
+
h: Specific enthalpy [J/kg]
|
|
508
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical/auto
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
(T, rho): Temperature [K] and density [kg/m³]
|
|
512
|
+
"""
|
|
513
|
+
P = jnp.asarray(P, dtype=jnp.float64)
|
|
514
|
+
h = jnp.asarray(h, dtype=jnp.float64)
|
|
515
|
+
phase_hint = jnp.asarray(phase_hint, dtype=jnp.int32)
|
|
516
|
+
return jax.vmap(_state_from_Ph)(P, h, phase_hint)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ═════════════════════════════════════════════════════════════════════════
|
|
520
|
+
# 1D inversion: temperature_from_Du — find T given (ρ, u)
|
|
521
|
+
# ═════════════════════════════════════════════════════════════════════════
|
|
522
|
+
|
|
523
|
+
# ── Initial guess for T from (ρ, u) ──────────────────────────────────
|
|
524
|
+
|
|
525
|
+
def _initial_guess_Du(rho, u):
|
|
526
|
+
"""Estimate T from (ρ, u) using u ≈ Cv_approx * R * T.
|
|
527
|
+
|
|
528
|
+
Cv_approx ~ 3.5 is a rough dimensionless heat capacity for CO₂.
|
|
529
|
+
"""
|
|
530
|
+
T0 = u / (3.5 * sw.R)
|
|
531
|
+
return jnp.clip(T0, sw.T_TRIPLE, 800.0)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── 1D Newton solver for T(ρ, u) ─────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
def _newton_body_Du(state):
|
|
537
|
+
"""One Newton step: T_{n+1} = T_n - f(T)/f'(T) where f = u(T,ρ) - u_target."""
|
|
538
|
+
T, rho, u_target, i, converged = state
|
|
539
|
+
f = sw._scalar_internal_energy(T, rho) - u_target
|
|
540
|
+
fp = _du_dT(T, rho) # = Cv > 0
|
|
541
|
+
step = f / _safe_denom(fp)
|
|
542
|
+
T_new = T - step
|
|
543
|
+
T_new = jnp.clip(T_new, sw.T_TRIPLE, 800.0)
|
|
544
|
+
converged_new = jnp.logical_or(
|
|
545
|
+
jnp.abs(f) < TOL,
|
|
546
|
+
jnp.abs(step) < 1e-13 * jnp.maximum(jnp.abs(T_new), 1.0)
|
|
547
|
+
)
|
|
548
|
+
return (T_new, rho, u_target, i + 1, converged_new)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _newton_cond_Du(state):
|
|
552
|
+
"""Continue while not converged and under max iterations."""
|
|
553
|
+
T, rho, u_target, i, converged = state
|
|
554
|
+
return jnp.logical_and(i < MAX_ITER, jnp.logical_not(converged))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _solve_temperature_Du(rho, u, phase_hint):
|
|
558
|
+
"""Newton solve for T given (ρ, u, phase_hint). Scalar inputs."""
|
|
559
|
+
T0 = _initial_guess_Du(rho, u)
|
|
560
|
+
init_state = (T0, rho, u, jnp.int32(0), jnp.bool_(False))
|
|
561
|
+
final_state = jax.lax.while_loop(_newton_cond_Du, _newton_body_Du, init_state)
|
|
562
|
+
T_out, _, _, _, converged = final_state
|
|
563
|
+
return jnp.where(converged, T_out, jnp.nan)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ── Implicit differentiation via custom_jvp ──────────────────────────
|
|
567
|
+
|
|
568
|
+
@jax.custom_jvp
|
|
569
|
+
def _temperature_from_Du(rho, u, phase_hint):
|
|
570
|
+
"""Find T such that u(T, ρ) = u_target at fixed ρ. Scalar inputs.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
rho: Density [kg/m³] (scalar)
|
|
574
|
+
u: Specific internal energy [J/kg] (scalar)
|
|
575
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical (integer scalar)
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
T: Temperature [K] (scalar)
|
|
579
|
+
"""
|
|
580
|
+
return _solve_temperature_Du(rho, u, phase_hint)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@_temperature_from_Du.defjvp
|
|
584
|
+
def _temperature_from_Du_jvp(primals, tangents):
|
|
585
|
+
"""JVP via implicit function theorem.
|
|
586
|
+
|
|
587
|
+
u(T, ρ) = u_target → (∂u/∂T)dT + (∂u/∂ρ)dρ = du
|
|
588
|
+
⇒ dT = (du − (∂u/∂ρ)·dρ) / (∂u/∂T)
|
|
589
|
+
"""
|
|
590
|
+
rho, u, phase_hint = primals
|
|
591
|
+
drho, du, _ = tangents
|
|
592
|
+
|
|
593
|
+
T_star = _temperature_from_Du(rho, u, phase_hint)
|
|
594
|
+
|
|
595
|
+
du_dT_val = _du_dT(T_star, rho) # = Cv
|
|
596
|
+
du_drho_val = _du_drho(T_star, rho)
|
|
597
|
+
|
|
598
|
+
dT = (du - du_drho_val * drho) / _safe_denom(du_dT_val)
|
|
599
|
+
|
|
600
|
+
return T_star, dT
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
@jax.jit
|
|
606
|
+
def temperature_from_Du(rho, u, phase_hint):
|
|
607
|
+
"""Find T [K] such that u(T, ρ) = u_target at fixed ρ.
|
|
608
|
+
|
|
609
|
+
All inputs broadcast: scalar or 1-D arrays of the same length.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
rho: Density [kg/m³]
|
|
613
|
+
u: Specific internal energy [J/kg]
|
|
614
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical/auto
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
T: Temperature [K]
|
|
618
|
+
"""
|
|
619
|
+
rho = jnp.asarray(rho, dtype=jnp.float64)
|
|
620
|
+
u = jnp.asarray(u, dtype=jnp.float64)
|
|
621
|
+
phase_hint = jnp.asarray(phase_hint, dtype=jnp.int32)
|
|
622
|
+
return jax.vmap(_temperature_from_Du)(rho, u, phase_hint)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ── Convenience wrapper: state_from_Du ────────────────────────────────
|
|
626
|
+
|
|
627
|
+
@jax.custom_jvp
|
|
628
|
+
def _state_from_Du(rho, u, phase_hint):
|
|
629
|
+
"""Find (T, P, h) given (ρ, u). Scalar inputs.
|
|
630
|
+
|
|
631
|
+
Solves for T via 1D Newton, then forward-evaluates P and h.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
rho: Density [kg/m³] (scalar)
|
|
635
|
+
u: Specific internal energy [J/kg] (scalar)
|
|
636
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical (integer scalar)
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
(T, P, h): Temperature [K], Pressure [Pa], Enthalpy [J/kg]
|
|
640
|
+
"""
|
|
641
|
+
T = _solve_temperature_Du(rho, u, phase_hint)
|
|
642
|
+
P = sw._scalar_pressure(T, rho)
|
|
643
|
+
h = sw._scalar_enthalpy(T, rho)
|
|
644
|
+
return T, P, h
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@_state_from_Du.defjvp
|
|
648
|
+
def _state_from_Du_jvp(primals, tangents):
|
|
649
|
+
"""JVP via implicit differentiation + chain rule.
|
|
650
|
+
|
|
651
|
+
The implicit solve gives T*(ρ, u):
|
|
652
|
+
dT = (du − (∂u/∂ρ)·dρ) / (∂u/∂T)
|
|
653
|
+
|
|
654
|
+
Then chain rule for P and h:
|
|
655
|
+
dP = (∂P/∂T)·dT + (∂P/∂ρ)·dρ
|
|
656
|
+
dh = (∂h/∂T)·dT + (∂h/∂ρ)·dρ
|
|
657
|
+
"""
|
|
658
|
+
rho, u, phase_hint = primals
|
|
659
|
+
drho, du, _ = tangents
|
|
660
|
+
|
|
661
|
+
T_star, P_star, h_star = _state_from_Du(rho, u, phase_hint)
|
|
662
|
+
|
|
663
|
+
# IFT for T*: u(T,ρ) = u_target
|
|
664
|
+
Cv = _du_dT(T_star, rho)
|
|
665
|
+
du_drho_val = _du_drho(T_star, rho)
|
|
666
|
+
dT = (du - du_drho_val * drho) / _safe_denom(Cv)
|
|
667
|
+
|
|
668
|
+
# Chain rule for P and h
|
|
669
|
+
dP = _dP_dT(T_star, rho) * dT + _dP_drho(T_star, rho) * drho
|
|
670
|
+
dh = _dh_dT(T_star, rho) * dT + _dh_drho(T_star, rho) * drho
|
|
671
|
+
|
|
672
|
+
return (T_star, P_star, h_star), (dT, dP, dh)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@jax.jit
|
|
676
|
+
def state_from_Du(rho, u, phase_hint):
|
|
677
|
+
"""Find (T [K], P [Pa], h [J/kg]) given (ρ, u) at fixed density.
|
|
678
|
+
|
|
679
|
+
Solves for T via 1D Newton on u(T,ρ) = u_target, then forward-evaluates
|
|
680
|
+
pressure and enthalpy.
|
|
681
|
+
|
|
682
|
+
All inputs broadcast: scalar or 1-D arrays of the same length.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
rho: Density [kg/m³]
|
|
686
|
+
u: Specific internal energy [J/kg]
|
|
687
|
+
phase_hint: 0=liquid, 1=vapor, 2=supercritical/auto
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
(T, P, h): Temperature [K], Pressure [Pa], Enthalpy [J/kg]
|
|
691
|
+
"""
|
|
692
|
+
rho = jnp.asarray(rho, dtype=jnp.float64)
|
|
693
|
+
u = jnp.asarray(u, dtype=jnp.float64)
|
|
694
|
+
phase_hint = jnp.asarray(phase_hint, dtype=jnp.int32)
|
|
695
|
+
return jax.vmap(_state_from_Du)(rho, u, phase_hint)
|