SVDSBTL backend — validation against HEOS#
This page compares the SVDSBTL surrogate backend against the HEOS Helmholtz-energy reference across four input pairs. For (P, T), (H, P), and (P, S) each panel shows the density error log10 | (rho_SVDSBTL - rho_HEOS) / rho_HEOS |. For (rho, T) density is the input (exact by construction), so the DT-indexed surface is checked on pressure instead: log10 | (p_SVDSBTL - p_HEOS) / p_HEOS | over a (T, rho) rectangle.
The heatmaps are interactive — hover for the local error value, drag to pan, scroll to zoom, double-click to reset. The figures are static HTML (no kernel, no Binder, no JupyterLite); the interactivity is plotly.js embedded by nbsphinx.
HEOS (reference) vs SVDSBTL&HEOS (rank-truncated SVD surrogate over HEOS).PT_INPUTS, HmassP_INPUTS, PSmass_INPUTS (validate rhomass()) and DmassT_INPUTS (validates p()).Water, Argon, Hydrogen, R1234yf, R245fa, D6, CO2, Helium.[1]:
import os
from pathlib import Path
# Route SVDSBTL surface I/O to a docs-dedicated cache directory so the
# build does not collide with developer interactive work in
# ~/.CoolProp/SVDTables/. Setting this *before* importing CoolProp lets
# the C++ config-init read the env var on first access, and joblib's
# loky workers inherit the env so they hit the same cache.
_cache = os.environ.setdefault(
'COOLPROP_ALTERNATIVE_SVDTABLES_DIRECTORY',
str(Path.home() / '.CoolProp' / 'SVDTables-docs'),
)
Path(_cache).mkdir(parents=True, exist_ok=True)
import numpy as np
import plotly.io as pio
import plotly.graph_objects as go
import CoolProp.CoolProp as CP
from CoolProp.CoolProp import PT_INPUTS, HmassP_INPUTS, QT_INPUTS, DmassT_INPUTS, PSmass_INPUTS
# Also set via the explicit config setter in the parent process. NOTE:
# this only mutates the parent's Configuration singleton; joblib's loky
# workers spawn fresh interpreters with their own singletons and rely
# entirely on env-var inheritance (which is why os.environ.setdefault
# above MUST stay before the CoolProp import — do not remove it).
CP.set_config_string(CP.ALTERNATIVE_SVDTABLES_DIRECTORY, _cache)
pio.renderers.default = 'notebook_connected'
print(f'SVDSBTL cache directory: {_cache}')
FLUIDS = ['Water', 'Argon', 'Hydrogen', 'R1234yf', 'R245fa', 'D6', 'CO2', 'Helium']
INPUT_PAIRS = ['PT', 'HP', 'DT', 'PS']
N_X, N_Y = 300, 300 # grid resolution per axis (90k cells per panel)
BAND_REL = 0.02 # half-width band around saturation line to mask (PT only)
SVDSBTL cache directory: /__w/CoolProp/CoolProp/.svdtables-cache
[2]:
def _hp_axes(ref, Tmin, Tmax, pmin, pmax, N_Y):
"""Estimate an enthalpy range covering the single-phase region for
the HP plot by sampling a coarse (T, p) grid across the rectangle.
Sampling the interior — not just the four corners — matters for fluids
like CO2 whose high triple-point pressure and steep melting line put
most corners outside HEOS validity: 3 of CO2's 4 corners throw (p below
p_triple, or T below T_melt(p)). A corners-only range would then
collapse to a single enthalpy and render a degenerate sliver of a panel.
"""
hs = []
for T_q in np.linspace(Tmin, Tmax, 8):
for p_q in np.geomspace(pmin, pmax, 8):
try:
ref.update(PT_INPUTS, p_q, T_q)
hs.append(ref.hmass())
except Exception:
pass
if len(hs) < 2:
# Almost nothing valid in the rectangle even after sampling.
# Return a benign ~1 J/kg-wide axis so the panel still renders;
# the surface queries there will mostly be NaN.
h0 = hs[0] if hs else 0.0
return np.linspace(h0 - 1.0, h0 + 1.0, N_Y)
return np.linspace(min(hs), max(hs), N_Y)
def _ps_axes(ref, Tmin, Tmax, pmin, pmax, N_Y):
"""Estimate a specific-entropy range covering the single-phase region
for the PS plot by sampling a coarse (T, p) grid across the rectangle.
Entropy twin of _hp_axes — entropy has no natural reduced form, so the
axis stays in absolute J/(kg.K). Interior sampling matters for the
same reason as _hp_axes (CO2's corners fall outside HEOS validity).
"""
ss = []
for T_q in np.linspace(Tmin, Tmax, 8):
for p_q in np.geomspace(pmin, pmax, 8):
try:
ref.update(PT_INPUTS, p_q, T_q)
ss.append(ref.smass())
except Exception:
pass
if len(ss) < 2:
s0 = ss[0] if ss else 0.0
return np.linspace(s0 - 1.0, s0 + 1.0, N_Y)
return np.linspace(min(ss), max(ss), N_Y)
def _dt_axes(ref, Tmin, Tmax, pmin, pmax, N_X):
"""Density axis for the DT panel. rho is the *input* for DmassT, so
we need a rho range, not a p range: sample the densities reached across
the (T, p) rectangle and span their min..max on a log axis. Sampling
the interior (not just corners) keeps fluids like CO2 — whose corners
fall below p_triple / the melting line — from collapsing it. The
spanned range also covers the two-phase dome, whose densities lie
between the vapor and liquid branches sampled here.
"""
rhos = []
for T_q in np.linspace(Tmin, Tmax, 8):
for p_q in np.geomspace(pmin, pmax, 8):
try:
ref.update(PT_INPUTS, p_q, T_q)
rhos.append(ref.rhomass())
except Exception:
pass
if len(rhos) < 2:
r0 = rhos[0] if rhos else 1.0
return np.geomspace(max(r0 * 0.5, 1e-6), r0 * 2.0, N_X)
return np.geomspace(max(min(rhos), 1e-6), max(rhos), N_X)
def compute_error_grid(fluid, input_pair, N_X=N_X, N_Y=N_Y):
"""Return (x_axis, y_axis, log10_err, Tc, pc, x_label, y_label, err_msg).
input_pair is 'PT', 'HP', 'DT', or 'PS'.
'PT' -> x = p (log, Pa), y = T (linear, K). Validates rho.
Mask a +/- BAND_REL band around the saturation line.
'HP' -> x = p (log, Pa), y = h (linear, J/kg). Validates rho.
No mask; the SVDSBTL surface is undefined inside the dome and
queries there are caught and set to NaN.
'PS' -> x = p (log, Pa), y = s (linear, J/kg/K). Validates rho.
No mask (same dome handling as HP — the two-phase wedge is a
true 2D region in (s, p) and surface queries there are NaN).
'DT' -> x = rho (log, kg/m^3), y = T (linear, K). Validates p.
rho is the input (exact), so the DT-indexed surface is checked
on p(rho, T). No mask: the two-phase dome is a valid DT region
(SVDSBTL routes it via the dome lever-rule, matching p_sat(T)).
SVDSBTL surface build failures are caught and reported via err_msg;
the returned grid is all-NaN in that case.
"""
ref = CP.AbstractState('HEOS', fluid)
Tc = ref.T_critical()
pc = ref.p_critical()
Tmin = max(ref.Tmin(), 0.6 * Tc)
Tmax = min(ref.Tmax(), 1.4 * Tc)
pmin = max(1e3, 0.01 * pc)
pmax = 3.0 * pc
psat = None
if input_pair == 'PT':
x_axis = np.geomspace(pmin, pmax, N_X)
y_axis = np.linspace(Tmin, Tmax, N_Y)
x_label = 'reduced pressure p / p_c'
y_label = 'reduced temperature T / T_c'
# Saturation line p_sat(T) for masking.
psat = np.full_like(y_axis, np.nan)
for i, T in enumerate(y_axis):
if T < Tc:
try:
ref.update(QT_INPUTS, 0.0, T)
psat[i] = ref.p()
except Exception:
pass
elif input_pair == 'HP':
x_axis = np.geomspace(pmin, pmax, N_X)
y_axis = _hp_axes(ref, Tmin, Tmax, pmin, pmax, N_Y)
x_label = 'reduced pressure p / p_c'
y_label = 'specific enthalpy h [kJ/kg]'
elif input_pair == 'DT':
x_axis = _dt_axes(ref, Tmin, Tmax, pmin, pmax, N_X)
y_axis = np.linspace(Tmin, Tmax, N_Y)
x_label = 'density rho [kg/m^3]'
y_label = 'reduced temperature T / T_c'
elif input_pair == 'PS':
x_axis = np.geomspace(pmin, pmax, N_X)
y_axis = _ps_axes(ref, Tmin, Tmax, pmin, pmax, N_Y)
x_label = 'reduced pressure p / p_c'
y_label = 'specific entropy s [J/(kg K)]'
else:
raise ValueError(f'unknown input_pair: {input_pair!r}')
err = np.full((N_Y, N_X), np.nan)
try:
svd = CP.AbstractState('SVDSBTL&HEOS', fluid)
except Exception as e:
return (x_axis, y_axis, err, Tc, pc, x_label, y_label,
f'SVDSBTL surface build failed: {e}')
for i, y in enumerate(y_axis):
for j, xv in enumerate(x_axis):
if psat is not None and np.isfinite(psat[i]) and abs(xv - psat[i]) / psat[i] < BAND_REL:
continue
try:
if input_pair == 'PT':
ref.update(PT_INPUTS, xv, y)
svd.update(PT_INPUTS, xv, y)
q_ref, q_svd = ref.rhomass(), svd.rhomass()
elif input_pair == 'HP':
ref.update(HmassP_INPUTS, y, xv)
svd.update(HmassP_INPUTS, y, xv)
q_ref, q_svd = ref.rhomass(), svd.rhomass()
elif input_pair == 'PS':
# PSmass takes (p, s) = (xv, y); validate density.
ref.update(PSmass_INPUTS, xv, y)
svd.update(PSmass_INPUTS, xv, y)
q_ref, q_svd = ref.rhomass(), svd.rhomass()
else: # DT — rho is the input; validate pressure
ref.update(DmassT_INPUTS, xv, y)
svd.update(DmassT_INPUTS, xv, y)
q_ref, q_svd = ref.p(), svd.p()
if q_ref > 0:
err[i, j] = abs(q_svd - q_ref) / q_ref
except Exception:
pass
# Floor zero / near-zero errors at 1e-16 so exact-match cells render at
# the colorbar floor (best-possible accuracy) instead of going to -inf
# and showing as NaN/white. Inside the two-phase dome SVDSBTL returns the
# same value as HEOS (shared saturation lookup), so err is ~0 there.
with np.errstate(divide='ignore', invalid='ignore'):
log_err = np.log10(np.maximum(err, 1e-16))
return x_axis, y_axis, log_err, Tc, pc, x_label, y_label, None
[3]:
def plot_from_grid(fluid, input_pair, grid):
x_axis, y_axis, log_err, Tc, pc, x_label, y_label, err_msg = grid
title = f'{fluid} [{input_pair}] \u2014 Tc={Tc:.2f} K, pc={pc/1e5:.3f} bar'
if err_msg is not None:
title += ' [SVDSBTL surface unavailable]'
if input_pair == 'PT':
# x = p/p_c (log), y = T/T_c (linear). z is stored as [y_index, x_index].
heat = go.Heatmap(
x=x_axis / pc, y=y_axis / Tc, z=log_err,
colorscale='Viridis', zmin=-12, zmax=-2,
colorbar=dict(title='log10 |\u0394\u03c1/\u03c1|'),
hovertemplate=('p/p_c=%{x:.3f}<br>'
'T/T_c=%{y:.3f}<br>'
'log10 err=%{z:.2f}<extra></extra>'),
)
xaxis = dict(title=x_label, type='log')
yaxis = dict(title=y_label)
elif input_pair == 'HP': # Mollier convention: x = h (linear), y = p (log).
heat = go.Heatmap(
x=y_axis / 1e3, y=x_axis / pc, z=log_err.T,
colorscale='Viridis', zmin=-12, zmax=-2,
colorbar=dict(title='log10 |\u0394\u03c1/\u03c1|'),
hovertemplate=('h=%{x:.1f} kJ/kg<br>'
'p/p_c=%{y:.3f}<br>'
'log10 err=%{z:.2f}<extra></extra>'),
)
xaxis = dict(title=y_label) # 'specific enthalpy h [kJ/kg]'
yaxis = dict(title='reduced pressure p / p_c', type='log')
elif input_pair == 'PS': # Mollier-like: x = s (linear), y = p (log).
heat = go.Heatmap(
x=y_axis, y=x_axis / pc, z=log_err.T,
colorscale='Viridis', zmin=-12, zmax=-2,
colorbar=dict(title='log10 |\u0394\u03c1/\u03c1|'),
hovertemplate=('s=%{x:.1f} J/(kg K)<br>'
'p/p_c=%{y:.3f}<br>'
'log10 err=%{z:.2f}<extra></extra>'),
)
xaxis = dict(title=y_label) # 'specific entropy s [J/(kg K)]'
yaxis = dict(title='reduced pressure p / p_c', type='log')
else: # DT — x = rho (log), y = T/T_c (linear). Validates pressure.
heat = go.Heatmap(
x=x_axis, y=y_axis / Tc, z=log_err,
colorscale='Viridis', zmin=-12, zmax=-2,
colorbar=dict(title='log10 |\u0394p/p|'),
hovertemplate=('rho=%{x:.4g} kg/m^3<br>'
'T/T_c=%{y:.3f}<br>'
'log10 err=%{z:.2f}<extra></extra>'),
)
xaxis = dict(title=x_label, type='log')
yaxis = dict(title=y_label)
fig = go.Figure(heat)
fig.update_layout(
title=title,
xaxis=xaxis,
yaxis=yaxis,
width=720, height=480,
margin=dict(l=70, r=20, t=50, b=60),
)
if err_msg is not None:
print(err_msg)
return fig
Environment preflight#
Before the parallel grid below, eagerly materialize all four input-pair surfaces for a single fluid via the SVDSBTL prebuild option. This fails loudly here if the CoolProp build / kernel environment can’t produce them (e.g. a stale wheel without PSmass support) — rather than letting every PS / DT panel downstream render as a silent blank. prebuild strips itself from the cache key, so these surfaces are shared with the plain SVDSBTL&HEOS constructions the panels use.
[4]:
# prebuild=true builds PT + HmassP + DmassT + PSmass for Water in a single
# construction; a build / env failure raises here and fails the docs build,
# which is the intended loud signal (vs. a quietly blank panel).
CP.AbstractState('SVDSBTL&HEOS', 'Water?{"prebuild": true}')
print('preflight OK: all SVDSBTL input-pair surfaces build for Water')
preflight OK: all SVDSBTL input-pair surfaces build for Water
Grid computation#
Each (fluid, input_pair) pair is an independent unit of work, so we compute all of them concurrently via joblib (process-based workers; n_jobs=min(len(FLUIDS)*len(INPUT_PAIRS), cpu_count)). Most of the wall-clock here is the one-time SVDSBTL surface build (a dense SVD at production resolution), not the per-point lookups; surfaces are cached and reused on subsequent builds.
[5]:
import os
from joblib import Parallel, delayed
def _compute(fluid, input_pair):
return (fluid, input_pair), compute_error_grid(fluid, input_pair)
tasks = [(f, ip) for f in FLUIDS for ip in INPUT_PAIRS]
n_jobs = min(len(tasks), os.cpu_count() or 1)
results = Parallel(n_jobs=n_jobs, backend='loky')(
delayed(_compute)(f, ip) for f, ip in tasks
)
GRIDS = {key: grid for key, grid in results}
print(f'computed {len(GRIDS)} panels (n_jobs={n_jobs}, cores={os.cpu_count()})')
computed 32 panels (n_jobs=4, cores=4)
Water#
PT#
[6]:
plot_from_grid('Water', 'PT', GRIDS[('Water', 'PT')])
HP#
[7]:
plot_from_grid('Water', 'HP', GRIDS[('Water', 'HP')])
DT#
[8]:
plot_from_grid('Water', 'DT', GRIDS[('Water', 'DT')])
PS#
[9]:
plot_from_grid('Water', 'PS', GRIDS[('Water', 'PS')])
Argon#
PT#
[10]:
plot_from_grid('Argon', 'PT', GRIDS[('Argon', 'PT')])
HP#
[11]:
plot_from_grid('Argon', 'HP', GRIDS[('Argon', 'HP')])
DT#
[12]:
plot_from_grid('Argon', 'DT', GRIDS[('Argon', 'DT')])
PS#
[13]:
plot_from_grid('Argon', 'PS', GRIDS[('Argon', 'PS')])
Hydrogen#
PT#
[14]:
plot_from_grid('Hydrogen', 'PT', GRIDS[('Hydrogen', 'PT')])
HP#
[15]:
plot_from_grid('Hydrogen', 'HP', GRIDS[('Hydrogen', 'HP')])
DT#
[16]:
plot_from_grid('Hydrogen', 'DT', GRIDS[('Hydrogen', 'DT')])
PS#
[17]:
plot_from_grid('Hydrogen', 'PS', GRIDS[('Hydrogen', 'PS')])
R1234yf#
PT#
[18]:
plot_from_grid('R1234yf', 'PT', GRIDS[('R1234yf', 'PT')])
HP#
[19]:
plot_from_grid('R1234yf', 'HP', GRIDS[('R1234yf', 'HP')])
DT#
[20]:
plot_from_grid('R1234yf', 'DT', GRIDS[('R1234yf', 'DT')])
PS#
[21]:
plot_from_grid('R1234yf', 'PS', GRIDS[('R1234yf', 'PS')])
R245fa#
PT#
[22]:
plot_from_grid('R245fa', 'PT', GRIDS[('R245fa', 'PT')])
HP#
[23]:
plot_from_grid('R245fa', 'HP', GRIDS[('R245fa', 'HP')])
DT#
[24]:
plot_from_grid('R245fa', 'DT', GRIDS[('R245fa', 'DT')])
PS#
[25]:
plot_from_grid('R245fa', 'PS', GRIDS[('R245fa', 'PS')])
D6#
PT#
[26]:
plot_from_grid('D6', 'PT', GRIDS[('D6', 'PT')])
HP#
[27]:
plot_from_grid('D6', 'HP', GRIDS[('D6', 'HP')])
DT#
[28]:
plot_from_grid('D6', 'DT', GRIDS[('D6', 'DT')])
PS#
[29]:
plot_from_grid('D6', 'PS', GRIDS[('D6', 'PS')])
CO2#
PT#
[30]:
plot_from_grid('CO2', 'PT', GRIDS[('CO2', 'PT')])
HP#
[31]:
plot_from_grid('CO2', 'HP', GRIDS[('CO2', 'HP')])
DT#
[32]:
plot_from_grid('CO2', 'DT', GRIDS[('CO2', 'DT')])
PS#
[33]:
plot_from_grid('CO2', 'PS', GRIDS[('CO2', 'PS')])
Helium#
PT#
[34]:
plot_from_grid('Helium', 'PT', GRIDS[('Helium', 'PT')])
HP#
[35]:
plot_from_grid('Helium', 'HP', GRIDS[('Helium', 'HP')])
DT#
[36]:
plot_from_grid('Helium', 'DT', GRIDS[('Helium', 'DT')])
PS#
[37]:
plot_from_grid('Helium', 'PS', GRIDS[('Helium', 'PS')])
Notes#
PT panels mask a 2-percent band around the saturation line, since (T, p) inside that band straddles the dome at ULP scale and SVDSBTL is single-phase only.
HP panels have no explicit mask: the two-phase dome is a true 2D region in (h, p), and SVDSBTL surface queries inside it throw — caught and reported as NaN. The white wedge in each HP heatmap is the dome.
PS panels mirror HP: the surface tabulates
(rho, T, h, u, w)over(p, s)withsas the input axis. No mask — the two-phase dome is a 2D wedge in (s, p), shown as the white region where surface queries are NaN.DT panels validate pressure (
rhois the input, hence exact):log10 |\u0394p/p|over(T, rho). No mask — the two-phase dome is a valid DT region (SVDSBTL routes it through the dome lever-rule, matching HEOSp_sat(T)), so it shows near-zero error rather than a NaN wedge.R1234yf,R245fa, andD6have narrow nativeTmin/Tmaxenvelopes that may clip the plot domain below the nominal0.6 Tclower bound.All panels are computed concurrently via
joblib(process workers;n_jobs=min(panels, cpu_count)). Most of the run time is the one-time SVDSBTL surface build, not the per-point lookups.This notebook is re-executed on every
make htmlbuild (Web/conf.pypre-executes all.ipynbviajupyter nbconvert --execute). Surfaces are cached at the directory printed in the first code cell (default~/.CoolProp/SVDTables-docs/, override viaCOOLPROP_ALTERNATIVE_SVDTABLES_DIRECTORY). First build of a fresh (fluid, input_pair) takes 20-80 s; subsequent builds reuse the serialized surface and run in ~1 s.Source generator:
Web/coolprop/_gen/gen_SVDSBTLValidation.py.