{
"cells": [
{
"cell_type": "markdown",
"id": "cell-001",
"metadata": {},
"source": [
"# SVDSBTL backend — validation against HEOS\n",
"\n",
"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.\n",
"\n",
"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.\n",
"\n",
"**Backends:** `HEOS` (reference) vs `SVDSBTL&HEOS` (rank-truncated SVD surrogate over HEOS). \n",
"**Inputs:** `PT_INPUTS`, `HmassP_INPUTS`, `PSmass_INPUTS` (validate `rhomass()`) and `DmassT_INPUTS` (validates `p()`). \n",
"**Fluids:** `Water`, `Argon`, `Hydrogen`, `R1234yf`, `R245fa`, `D6`, `CO2`, `Helium`.\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "cell-002",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-27T16:10:19.591202Z",
"iopub.status.busy": "2026-06-27T16:10:19.591068Z",
"iopub.status.idle": "2026-06-27T16:10:21.290255Z",
"shell.execute_reply": "2026-06-27T16:10:21.289904Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"SVDSBTL cache directory: /__w/CoolProp/CoolProp/.svdtables-cache\n"
]
}
],
"source": [
"import os\n",
"from pathlib import Path\n",
"\n",
"# Route SVDSBTL surface I/O to a docs-dedicated cache directory so the\n",
"# build does not collide with developer interactive work in\n",
"# ~/.CoolProp/SVDTables/. Setting this *before* importing CoolProp lets\n",
"# the C++ config-init read the env var on first access, and joblib's\n",
"# loky workers inherit the env so they hit the same cache.\n",
"_cache = os.environ.setdefault(\n",
" 'COOLPROP_ALTERNATIVE_SVDTABLES_DIRECTORY',\n",
" str(Path.home() / '.CoolProp' / 'SVDTables-docs'),\n",
")\n",
"Path(_cache).mkdir(parents=True, exist_ok=True)\n",
"\n",
"import numpy as np\n",
"import plotly.io as pio\n",
"import plotly.graph_objects as go\n",
"import CoolProp.CoolProp as CP\n",
"from CoolProp.CoolProp import PT_INPUTS, HmassP_INPUTS, QT_INPUTS, DmassT_INPUTS, PSmass_INPUTS\n",
"\n",
"# Also set via the explicit config setter in the parent process. NOTE:\n",
"# this only mutates the parent's Configuration singleton; joblib's loky\n",
"# workers spawn fresh interpreters with their own singletons and rely\n",
"# entirely on env-var inheritance (which is why os.environ.setdefault\n",
"# above MUST stay before the CoolProp import — do not remove it).\n",
"CP.set_config_string(CP.ALTERNATIVE_SVDTABLES_DIRECTORY, _cache)\n",
"\n",
"pio.renderers.default = 'notebook_connected'\n",
"print(f'SVDSBTL cache directory: {_cache}')\n",
"\n",
"FLUIDS = ['Water', 'Argon', 'Hydrogen', 'R1234yf', 'R245fa', 'D6', 'CO2', 'Helium']\n",
"INPUT_PAIRS = ['PT', 'HP', 'DT', 'PS']\n",
"N_X, N_Y = 300, 300 # grid resolution per axis (90k cells per panel)\n",
"BAND_REL = 0.02 # half-width band around saturation line to mask (PT only)\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "cell-003",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-27T16:10:21.292135Z",
"iopub.status.busy": "2026-06-27T16:10:21.291772Z",
"iopub.status.idle": "2026-06-27T16:10:21.300911Z",
"shell.execute_reply": "2026-06-27T16:10:21.300608Z"
}
},
"outputs": [],
"source": [
"def _hp_axes(ref, Tmin, Tmax, pmin, pmax, N_Y):\n",
" \"\"\"Estimate an enthalpy range covering the single-phase region for\n",
" the HP plot by sampling a coarse (T, p) grid across the rectangle.\n",
"\n",
" Sampling the interior — not just the four corners — matters for fluids\n",
" like CO2 whose high triple-point pressure and steep melting line put\n",
" most corners outside HEOS validity: 3 of CO2's 4 corners throw (p below\n",
" p_triple, or T below T_melt(p)). A corners-only range would then\n",
" collapse to a single enthalpy and render a degenerate sliver of a panel.\n",
" \"\"\"\n",
" hs = []\n",
" for T_q in np.linspace(Tmin, Tmax, 8):\n",
" for p_q in np.geomspace(pmin, pmax, 8):\n",
" try:\n",
" ref.update(PT_INPUTS, p_q, T_q)\n",
" hs.append(ref.hmass())\n",
" except Exception:\n",
" pass\n",
" if len(hs) < 2:\n",
" # Almost nothing valid in the rectangle even after sampling.\n",
" # Return a benign ~1 J/kg-wide axis so the panel still renders;\n",
" # the surface queries there will mostly be NaN.\n",
" h0 = hs[0] if hs else 0.0\n",
" return np.linspace(h0 - 1.0, h0 + 1.0, N_Y)\n",
" return np.linspace(min(hs), max(hs), N_Y)\n",
"\n",
"def _ps_axes(ref, Tmin, Tmax, pmin, pmax, N_Y):\n",
" \"\"\"Estimate a specific-entropy range covering the single-phase region\n",
" for the PS plot by sampling a coarse (T, p) grid across the rectangle.\n",
" Entropy twin of _hp_axes — entropy has no natural reduced form, so the\n",
" axis stays in absolute J/(kg.K). Interior sampling matters for the\n",
" same reason as _hp_axes (CO2's corners fall outside HEOS validity).\n",
" \"\"\"\n",
" ss = []\n",
" for T_q in np.linspace(Tmin, Tmax, 8):\n",
" for p_q in np.geomspace(pmin, pmax, 8):\n",
" try:\n",
" ref.update(PT_INPUTS, p_q, T_q)\n",
" ss.append(ref.smass())\n",
" except Exception:\n",
" pass\n",
" if len(ss) < 2:\n",
" s0 = ss[0] if ss else 0.0\n",
" return np.linspace(s0 - 1.0, s0 + 1.0, N_Y)\n",
" return np.linspace(min(ss), max(ss), N_Y)\n",
"\n",
"def _dt_axes(ref, Tmin, Tmax, pmin, pmax, N_X):\n",
" \"\"\"Density axis for the DT panel. rho is the *input* for DmassT, so\n",
" we need a rho range, not a p range: sample the densities reached across\n",
" the (T, p) rectangle and span their min..max on a log axis. Sampling\n",
" the interior (not just corners) keeps fluids like CO2 — whose corners\n",
" fall below p_triple / the melting line — from collapsing it. The\n",
" spanned range also covers the two-phase dome, whose densities lie\n",
" between the vapor and liquid branches sampled here.\n",
" \"\"\"\n",
" rhos = []\n",
" for T_q in np.linspace(Tmin, Tmax, 8):\n",
" for p_q in np.geomspace(pmin, pmax, 8):\n",
" try:\n",
" ref.update(PT_INPUTS, p_q, T_q)\n",
" rhos.append(ref.rhomass())\n",
" except Exception:\n",
" pass\n",
" if len(rhos) < 2:\n",
" r0 = rhos[0] if rhos else 1.0\n",
" return np.geomspace(max(r0 * 0.5, 1e-6), r0 * 2.0, N_X)\n",
" return np.geomspace(max(min(rhos), 1e-6), max(rhos), N_X)\n",
"\n",
"def compute_error_grid(fluid, input_pair, N_X=N_X, N_Y=N_Y):\n",
" \"\"\"Return (x_axis, y_axis, log10_err, Tc, pc, x_label, y_label, err_msg).\n",
"\n",
" input_pair is 'PT', 'HP', 'DT', or 'PS'.\n",
" 'PT' -> x = p (log, Pa), y = T (linear, K). Validates rho.\n",
" Mask a +/- BAND_REL band around the saturation line.\n",
" 'HP' -> x = p (log, Pa), y = h (linear, J/kg). Validates rho.\n",
" No mask; the SVDSBTL surface is undefined inside the dome and\n",
" queries there are caught and set to NaN.\n",
" 'PS' -> x = p (log, Pa), y = s (linear, J/kg/K). Validates rho.\n",
" No mask (same dome handling as HP — the two-phase wedge is a\n",
" true 2D region in (s, p) and surface queries there are NaN).\n",
" 'DT' -> x = rho (log, kg/m^3), y = T (linear, K). Validates p.\n",
" rho is the input (exact), so the DT-indexed surface is checked\n",
" on p(rho, T). No mask: the two-phase dome is a valid DT region\n",
" (SVDSBTL routes it via the dome lever-rule, matching p_sat(T)).\n",
"\n",
" SVDSBTL surface build failures are caught and reported via err_msg;\n",
" the returned grid is all-NaN in that case.\n",
" \"\"\"\n",
" ref = CP.AbstractState('HEOS', fluid)\n",
" Tc = ref.T_critical()\n",
" pc = ref.p_critical()\n",
" Tmin = max(ref.Tmin(), 0.6 * Tc)\n",
" Tmax = min(ref.Tmax(), 1.4 * Tc)\n",
" pmin = max(1e3, 0.01 * pc)\n",
" pmax = 3.0 * pc\n",
"\n",
" psat = None\n",
" if input_pair == 'PT':\n",
" x_axis = np.geomspace(pmin, pmax, N_X)\n",
" y_axis = np.linspace(Tmin, Tmax, N_Y)\n",
" x_label = 'reduced pressure p / p_c'\n",
" y_label = 'reduced temperature T / T_c'\n",
" # Saturation line p_sat(T) for masking.\n",
" psat = np.full_like(y_axis, np.nan)\n",
" for i, T in enumerate(y_axis):\n",
" if T < Tc:\n",
" try:\n",
" ref.update(QT_INPUTS, 0.0, T)\n",
" psat[i] = ref.p()\n",
" except Exception:\n",
" pass\n",
" elif input_pair == 'HP':\n",
" x_axis = np.geomspace(pmin, pmax, N_X)\n",
" y_axis = _hp_axes(ref, Tmin, Tmax, pmin, pmax, N_Y)\n",
" x_label = 'reduced pressure p / p_c'\n",
" y_label = 'specific enthalpy h [kJ/kg]'\n",
" elif input_pair == 'DT':\n",
" x_axis = _dt_axes(ref, Tmin, Tmax, pmin, pmax, N_X)\n",
" y_axis = np.linspace(Tmin, Tmax, N_Y)\n",
" x_label = 'density rho [kg/m^3]'\n",
" y_label = 'reduced temperature T / T_c'\n",
" elif input_pair == 'PS':\n",
" x_axis = np.geomspace(pmin, pmax, N_X)\n",
" y_axis = _ps_axes(ref, Tmin, Tmax, pmin, pmax, N_Y)\n",
" x_label = 'reduced pressure p / p_c'\n",
" y_label = 'specific entropy s [J/(kg K)]'\n",
" else:\n",
" raise ValueError(f'unknown input_pair: {input_pair!r}')\n",
"\n",
" err = np.full((N_Y, N_X), np.nan)\n",
"\n",
" try:\n",
" svd = CP.AbstractState('SVDSBTL&HEOS', fluid)\n",
" except Exception as e:\n",
" return (x_axis, y_axis, err, Tc, pc, x_label, y_label,\n",
" f'SVDSBTL surface build failed: {e}')\n",
"\n",
" for i, y in enumerate(y_axis):\n",
" for j, xv in enumerate(x_axis):\n",
" if psat is not None and np.isfinite(psat[i]) and abs(xv - psat[i]) / psat[i] < BAND_REL:\n",
" continue\n",
" try:\n",
" if input_pair == 'PT':\n",
" ref.update(PT_INPUTS, xv, y)\n",
" svd.update(PT_INPUTS, xv, y)\n",
" q_ref, q_svd = ref.rhomass(), svd.rhomass()\n",
" elif input_pair == 'HP':\n",
" ref.update(HmassP_INPUTS, y, xv)\n",
" svd.update(HmassP_INPUTS, y, xv)\n",
" q_ref, q_svd = ref.rhomass(), svd.rhomass()\n",
" elif input_pair == 'PS':\n",
" # PSmass takes (p, s) = (xv, y); validate density.\n",
" ref.update(PSmass_INPUTS, xv, y)\n",
" svd.update(PSmass_INPUTS, xv, y)\n",
" q_ref, q_svd = ref.rhomass(), svd.rhomass()\n",
" else: # DT — rho is the input; validate pressure\n",
" ref.update(DmassT_INPUTS, xv, y)\n",
" svd.update(DmassT_INPUTS, xv, y)\n",
" q_ref, q_svd = ref.p(), svd.p()\n",
" if q_ref > 0:\n",
" err[i, j] = abs(q_svd - q_ref) / q_ref\n",
" except Exception:\n",
" pass\n",
" # Floor zero / near-zero errors at 1e-16 so exact-match cells render at\n",
" # the colorbar floor (best-possible accuracy) instead of going to -inf\n",
" # and showing as NaN/white. Inside the two-phase dome SVDSBTL returns the\n",
" # same value as HEOS (shared saturation lookup), so err is ~0 there.\n",
" with np.errstate(divide='ignore', invalid='ignore'):\n",
" log_err = np.log10(np.maximum(err, 1e-16))\n",
" return x_axis, y_axis, log_err, Tc, pc, x_label, y_label, None\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "cell-004",
"metadata": {
"execution": {
"iopub.execute_input": "2026-06-27T16:10:21.302158Z",
"iopub.status.busy": "2026-06-27T16:10:21.302023Z",
"iopub.status.idle": "2026-06-27T16:10:21.306063Z",
"shell.execute_reply": "2026-06-27T16:10:21.305844Z"
}
},
"outputs": [],
"source": [
"def plot_from_grid(fluid, input_pair, grid):\n",
" x_axis, y_axis, log_err, Tc, pc, x_label, y_label, err_msg = grid\n",
" title = f'{fluid} [{input_pair}] \\u2014 Tc={Tc:.2f} K, pc={pc/1e5:.3f} bar'\n",
" if err_msg is not None:\n",
" title += ' [SVDSBTL surface unavailable]'\n",
" if input_pair == 'PT':\n",
" # x = p/p_c (log), y = T/T_c (linear). z is stored as [y_index, x_index].\n",
" heat = go.Heatmap(\n",
" x=x_axis / pc, y=y_axis / Tc, z=log_err,\n",
" colorscale='Viridis', zmin=-12, zmax=-2,\n",
" colorbar=dict(title='log10 |\\u0394\\u03c1/\\u03c1|'),\n",
" hovertemplate=('p/p_c=%{x:.3f}
'\n",
" 'T/T_c=%{y:.3f}
'\n",
" 'log10 err=%{z:.2f}
'\n",
" 'p/p_c=%{y:.3f}
'\n",
" 'log10 err=%{z:.2f}
'\n",
" 'p/p_c=%{y:.3f}
'\n",
" 'log10 err=%{z:.2f}
'\n",
" 'T/T_c=%{y:.3f}
'\n",
" 'log10 err=%{z:.2f}