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/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)