from __future__ import annotations
"""
thermo_props.state
State builder for thermodynamic properties (CoolProp-backed).
Goals:
- Predictable state construction from *two* independent thermodynamic_properties.
- Accept EES-ish aliases (h, s, rho, v, x) and unit-suffixed keys (T_C, P_bar, h_kJkg, ...).
- Return a rich state object with a consistent property dictionary in SI units.
Notes:
- This module builds *single thermodynamic states*. The "solve sets of equations"
experience lives in `equations/*`.
"""
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
import math
from utils import with_error_context
from .coolprop_backend import (
CoolPropCallError,
CoolPropNotInstalled,
phase_si,
props_multi,
props_si,
)
# ------------------------------ canonical outputs ------------------------------
# CoolProp-native keys for computed thermodynamic_properties (SI), plus a few convenient derived ones.
DEFAULT_OUTPUTS: tuple[str, ...] = (
"T", # K
"P", # Pa
"Q", # quality (0..1), may be NaN in single phase
"Hmass", # J/kg
"Smass", # J/kg-K
"Umass", # J/kg
"Dmass", # kg/m^3
"Cpmass", # J/kg-K
"Cvmass", # J/kg-K
"A", # m/s speed of sound
"V", # Pa*s viscosity (CoolProp key)
"L", # W/m-K thermal conductivity (CoolProp key)
"Prandtl", # -
)
# Allowed CoolProp input pairs (after normalization)
_ALLOWED_IN: set[str] = {"T", "P", "Q", "Hmass", "Smass", "Umass", "Dmass"}
# Keys to ignore when picking the "two independent thermodynamic_properties" out of a mapping
_IGNORE_KEYS: set[str] = {
"fluid",
"name",
"label",
"notes",
"comment",
"id",
"backend",
"outputs",
"include_phase",
"meta",
"units",
"unit",
"basis",
"options",
# defensive (in case someone passes a whole thermo_props payload)
"state",
"states",
"given",
"ask",
"mode",
}
# ------------------------------ input aliases ------------------------------
# EES-ish aliases for *inputs*.
_CANONICAL_IN_ALIASES: Mapping[str, str] = {
"t": "T",
"p": "P",
"q": "Q",
"x": "Q",
"h": "Hmass",
"s": "Smass",
"u": "Umass",
"rho": "Dmass",
"d": "Dmass",
"dmass": "Dmass",
# specific volume: EES uses "v" for m^3/kg
"v": "v_m3kg",
}
# ------------------------------ unit-suffixed inputs ------------------------------
# v_si = value * factor + offset (except specific volume which is inverted)
_UNIT_KEYMAP: Mapping[str, tuple[str, float, float]] = {
# Temperature
"T": ("T", 1.0, 0.0),
"T_K": ("T", 1.0, 0.0),
"T_C": ("T", 1.0, 273.15),
# Pressure
"P": ("P", 1.0, 0.0),
"P_Pa": ("P", 1.0, 0.0),
"P_kPa": ("P", 1.0e3, 0.0),
"P_MPa": ("P", 1.0e6, 0.0),
"P_bar": ("P", 1.0e5, 0.0),
"P_atm": ("P", 101325.0, 0.0),
# Enthalpy
"h": ("Hmass", 1.0, 0.0),
"Hmass": ("Hmass", 1.0, 0.0),
"h_Jkg": ("Hmass", 1.0, 0.0),
"h_kJkg": ("Hmass", 1.0e3, 0.0),
# Entropy
"s": ("Smass", 1.0, 0.0),
"Smass": ("Smass", 1.0, 0.0),
"s_JkgK": ("Smass", 1.0, 0.0),
"s_kJkgK": ("Smass", 1.0e3, 0.0),
# Internal energy
"u": ("Umass", 1.0, 0.0),
"Umass": ("Umass", 1.0, 0.0),
"u_Jkg": ("Umass", 1.0, 0.0),
"u_kJkg": ("Umass", 1.0e3, 0.0),
# Density
"rho": ("Dmass", 1.0, 0.0),
"rho_kgm3": ("Dmass", 1.0, 0.0),
"Dmass": ("Dmass", 1.0, 0.0),
# Specific volume (m^3/kg) -> Dmass (inversion; handled specially)
"v_m3kg": ("Dmass", -1.0, 0.0),
# Quality
"x": ("Q", 1.0, 0.0),
"q": ("Q", 1.0, 0.0),
"Q": ("Q", 1.0, 0.0),
}
# ------------------------------ output aliases (user-friendly) ------------------------------
_DERIVED_OUTPUTS: set[str] = {"v", "gamma", "R_eff"}
_OUTPUT_ALIASES: Mapping[str, str] = {
"t": "T",
"p": "P",
"q": "Q",
"x": "Q",
"h": "Hmass",
"s": "Smass",
"u": "Umass",
"rho": "Dmass",
"d": "Dmass",
"cp": "Cpmass",
"cv": "Cvmass",
"a": "A",
"mu": "V",
"visc": "V",
"k": "L",
"lambda": "L",
"cond": "L",
"pr": "Prandtl",
# derived / postprocessed
"v": "v",
"gamma": "gamma",
"r_eff": "R_eff",
"reff": "R_eff",
}
# ------------------------------ small helpers ------------------------------
def _is_finite(x: float) -> bool:
return isinstance(x, (int, float)) and math.isfinite(float(x))
def _as_float(v: Any, key: str) -> float:
try:
x = float(v)
except Exception as e:
raise TypeError(f"{key} must be a number; got {type(v).__name__}") from e
if not _is_finite(x):
raise ValueError(f"{key} must be finite; got {x!r}")
return x
def _normalize_key(key: str) -> str:
"""
Normalize input key:
- exact match in _UNIT_KEYMAP
- then try a couple case-normalizations for unit-suffixed keys
- then apply EES-ish aliasing for bare keys (t,p,h,s,u,rho,v,x)
"""
k = str(key).strip()
if not k:
raise ValueError("Empty key in state specification.")
if k in _UNIT_KEYMAP:
return k
# common user inputs: p_bar, t_c, T_c, etc.
k2 = (k[0].upper() + k[1:]) if k else k
if k2 in _UNIT_KEYMAP:
return k2
k3 = k.upper()
if k3 in _UNIT_KEYMAP:
return k3
kl = k.lower()
if kl in _CANONICAL_IN_ALIASES:
return _CANONICAL_IN_ALIASES[kl]
return k
def _decode_input_item(key: str, value: Any) -> tuple[str, float]:
"""
Convert a user-facing (key,value) into a CoolProp input key and SI value.
Supports:
- Unit-suffixed inputs: T_C, P_bar, h_kJkg, s_kJkgK, ...
- EES-ish aliases: T,P,h,s,u,rho,v,x
- Specific volume keys: v_m3kg or v (interpreted as m^3/kg) -> density inversion
"""
k0 = _normalize_key(key)
v0 = _as_float(value, key)
# Specific volume -> density inversion
if k0 in ("v_m3kg",):
if v0 <= 0:
raise ValueError(f"{key} must be > 0 (specific volume). Got {v0}")
return "Dmass", 1.0 / v0
# If alias normalized to v_m3kg already, handle it too
if k0 == "v_m3kg":
if v0 <= 0:
raise ValueError(f"{key} must be > 0 (specific volume). Got {v0}")
return "Dmass", 1.0 / v0
# Linear transform keys
if k0 in _UNIT_KEYMAP:
canon, factor, offset = _UNIT_KEYMAP[k0]
# v_m3kg handled above; here it's linear
return canon, v0 * factor + offset
# Canonical allowed keys
if k0 in _ALLOWED_IN:
return k0, v0
raise ValueError(
f"Unsupported state input key {key!r}. "
f"Allowed: {sorted(_ALLOWED_IN)} plus unit keys like T_C, P_bar, h_kJkg, s_kJkgK, rho_kgm3, v_m3kg, x."
)
def _pick_two_inputs(mapping: Mapping[str, Any]) -> tuple[str, float, str, float]:
"""
Select exactly two independent thermodynamic_properties from a mapping.
Rules:
- Ignore metadata keys (_IGNORE_KEYS).
- Ignore keys whose value is None.
- If more than two thermo keys are present, raise.
"""
items: list[tuple[str, Any]] = []
for k, v in mapping.items():
if k in _IGNORE_KEYS:
continue
if v is None:
continue
items.append((k, v))
if len(items) != 2:
keys = [k for k, _ in items]
raise ValueError(
"State requires exactly 2 independent thermodynamic_properties; "
f"got {len(items)} keys: {keys}"
)
(k1, v1), (k2, v2) = items
in1, x1 = _decode_input_item(k1, v1)
in2, x2 = _decode_input_item(k2, v2)
if in1 == in2:
raise ValueError(f"State inputs must be distinct; got {in1!r} twice.")
return in1, x1, in2, x2
def _normalize_output_key(k: Any) -> str:
s = str(k).strip()
if not s:
return s
if s in _DERIVED_OUTPUTS:
return s
sl = s.lower()
if sl in _OUTPUT_ALIASES:
return _OUTPUT_ALIASES[sl]
return s
def _normalize_outputs(outputs: Sequence[str]) -> tuple[list[str], set[str]]:
"""
Normalize user-facing output keys to CoolProp keys, tracking derived outputs.
- If user requests 'v' -> ensure 'Dmass' is computed (postprocess creates v).
- If user requests 'gamma' or 'R_eff' -> ensure Cp/Cv are computed.
"""
want_cp: list[str] = []
want_derived: set[str] = set()
for o in outputs:
ok = _normalize_output_key(o)
if not ok:
continue
if ok in _DERIVED_OUTPUTS:
want_derived.add(ok)
continue
want_cp.append(ok)
# dependencies for derived outputs
if "v" in want_derived and "Dmass" not in want_cp:
want_cp.append("Dmass")
if ("gamma" in want_derived or "R_eff" in want_derived) and (
"Cpmass" not in want_cp or "Cvmass" not in want_cp
):
if "Cpmass" not in want_cp:
want_cp.append("Cpmass")
if "Cvmass" not in want_cp:
want_cp.append("Cvmass")
# de-dupe while preserving order
seen: set[str] = set()
out: list[str] = []
for k in want_cp:
if k not in seen:
seen.add(k)
out.append(k)
return out, want_derived
# ------------------------------ state object ------------------------------
[docs]
@dataclass(frozen=True)
class ThermoState:
"""
A thermodynamic state evaluated by CoolProp.
`props` contains CoolProp-native keys in SI units, plus derived:
- "v" : specific volume (m^3/kg)
- "gamma" : Cp/Cv (if available)
- "R_eff" : Cp - Cv (diagnostic; not constant for real fluids)
"""
fluid: str
in1: str
v1: float
in2: str
v2: float
props: dict[str, float]
phase: str | None = None
[docs]
def get(self, key: str, default: float | None = None) -> float | None:
return self.props.get(key, default)
@property
def T(self) -> float:
return float(self.props["T"])
@property
def P(self) -> float:
return float(self.props["P"])
@property
def h(self) -> float:
return float(self.props["Hmass"])
@property
def s(self) -> float:
return float(self.props["Smass"])
@property
def rho(self) -> float:
return float(self.props["Dmass"])
@property
def v(self) -> float:
return float(self.props["v"])
# ------------------------------ builders ------------------------------
def _postprocess(props: dict[str, float]) -> dict[str, float]:
out = dict(props)
# specific volume
D = out.get("Dmass", float("nan"))
if _is_finite(D) and D > 0:
out["v"] = 1.0 / D
else:
out["v"] = float("nan")
# gamma and R_eff (diagnostics)
cp = out.get("Cpmass", float("nan"))
cv = out.get("Cvmass", float("nan"))
if _is_finite(cp) and _is_finite(cv) and cv != 0.0:
out["gamma"] = cp / cv
out["R_eff"] = cp - cv
else:
out["gamma"] = float("nan")
out["R_eff"] = float("nan")
return out
[docs]
@with_error_context("thermo_props.state_from_pair")
def state_from_pair(
fluid: str,
in1: str,
v1: float,
in2: str,
v2: float,
outputs: Sequence[str] = DEFAULT_OUTPUTS,
include_phase: bool = True,
) -> ThermoState:
"""
Build a state from two independent thermodynamic_properties (already in SI units / CoolProp keys).
Example:
state_from_pair("R134a", "T", 273.15, "Q", 1.0)
state_from_pair("HEOS::Air", "T", 300.0, "P", 101325.0)
"""
if not isinstance(fluid, str) or not fluid.strip():
raise ValueError("fluid must be a non-empty string.")
if in1 not in _ALLOWED_IN or in2 not in _ALLOWED_IN:
raise ValueError(f"Inputs must be in {sorted(_ALLOWED_IN)}; got {in1!r}, {in2!r}")
if not _is_finite(v1) or not _is_finite(v2):
raise ValueError("Input values must be finite.")
if in1 == in2:
raise ValueError("in1 and in2 must be distinct.")
outs_cp, _derived = _normalize_outputs(outputs)
# Compute thermodynamic_properties
props = props_multi(list(outs_cp), in1, float(v1), in2, float(v2), fluid)
props2 = _postprocess(props)
ph: str | None = None
if include_phase:
try:
ph = phase_si(in1, float(v1), in2, float(v2), fluid)
except Exception:
ph = None
return ThermoState(
fluid=fluid,
in1=in1,
v1=float(v1),
in2=in2,
v2=float(v2),
props=props2,
phase=ph,
)
[docs]
@with_error_context("thermo_props.state_from_mapping")
def state_from_mapping(
mapping: Mapping[str, Any],
*,
fluid: str | None = None,
outputs: Sequence[str] = DEFAULT_OUTPUTS,
include_phase: bool = True,
) -> ThermoState:
"""
Build a state from a mapping that contains exactly two independent thermodynamic_properties.
Accepts keys like:
- {"T_C": -10, "x": 1.0}
- {"P_bar": 10, "h_kJkg": 240}
- {"T": 300, "P": 101325}
- {"rho_kgm3": 12.3, "T_K": 280}
- {"v_m3kg": 0.0012, "P_bar": 10} # specific volume
You may supply `fluid=` or include "fluid" in the mapping.
"""
f = (fluid or str(mapping.get("fluid", "")).strip()).strip()
if not f:
raise ValueError("Fluid must be provided (argument `fluid=` or mapping['fluid']).")
in1, v1, in2, v2 = _pick_two_inputs(mapping)
return state_from_pair(
f,
in1,
v1,
in2,
v2,
outputs=outputs,
include_phase=include_phase,
)
# ------------------------------ convenience constructors ------------------------------
def state_TP(fluid: str, T_K: float, P_Pa: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "T", float(T_K), "P", float(P_Pa), outputs=outputs)
def state_Tx(fluid: str, T_K: float, x: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "T", float(T_K), "Q", float(x), outputs=outputs)
def state_Px(fluid: str, P_Pa: float, x: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "P", float(P_Pa), "Q", float(x), outputs=outputs)
def state_Ph(fluid: str, P_Pa: float, h_Jkg: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "P", float(P_Pa), "Hmass", float(h_Jkg), outputs=outputs)
def state_Ps(fluid: str, P_Pa: float, s_JkgK: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "P", float(P_Pa), "Smass", float(s_JkgK), outputs=outputs)
def state_Th(fluid: str, T_K: float, h_Jkg: float, *, outputs: Sequence[str] = DEFAULT_OUTPUTS) -> ThermoState:
return state_from_pair(fluid, "T", float(T_K), "Hmass", float(h_Jkg), outputs=outputs)
# ------------------------------ single-property helper ------------------------------
[docs]
@with_error_context("thermo_props.prop_from_state")
def prop_from_state(
fluid: str,
out: str,
in1: str,
v1: float,
in2: str,
v2: float,
) -> float:
"""
Tiny helper for solvers that want *one* property without building a full ThermoState.
Note:
- `out` may be a CoolProp key (e.g., "Hmass") or a common alias ("h","s","rho",...).
- Derived outputs ("v","gamma","R_eff") are not supported here; use state_from_pair().
"""
out_key = _normalize_output_key(out)
if out_key in _DERIVED_OUTPUTS:
raise ValueError(f"Derived output {out!r} is not supported in prop_from_state(); use state_from_pair().")
return props_si(out_key, in1, float(v1), in2, float(v2), fluid)
__all__ = [
"DEFAULT_OUTPUTS",
"ThermoState",
"state_from_mapping",
"state_from_pair",
"prop_from_state",
]