from __future__ import annotations
"""High-level thermodynamic-property API for TDPy.
This module provides the app-facing and user-facing property evaluation surface
for the ``thermo_props`` package.
Public entry points
-------------------
``state``
Build one ``ThermoState`` from exactly two independent state properties.
``props``
Return a property dictionary in SI units for one state.
``prop``
Return one scalar property in SI units for one state.
``run``
JSON-driven facade used by the TDPy application layer for single-state and
batch thermodynamic-property evaluations.
``saturation_curve_Ts`` and ``isobars_Ts``
Plot-overlay helpers for T-s diagrams.
Implementation notes
--------------------
The module remains thin by design. Robust state construction lives in
``thermo_props.state`` and low-level property calls remain isolated in
``thermo_props.coolprop_backend``.
"""
from dataclasses import asdict
from typing import Any, Mapping, Sequence
from utils import with_error_context
from .coolprop_backend import CoolPropCallError, props_si
from .state import DEFAULT_OUTPUTS, ThermoState, state_from_mapping
# ------------------------------ primary API ------------------------------
[docs]
@with_error_context("thermo_props.api.state")
def state(
*,
fluid: str | None = None,
outputs: Sequence[str] = DEFAULT_OUTPUTS,
include_phase: bool = True,
**spec: Any,
) -> ThermoState:
"""Build a thermodynamic state from two independent properties.
Parameters
----------
fluid:
Fluid identifier. This may also be supplied in ``spec``.
outputs:
Requested output property keys.
include_phase:
Whether to request a phase string when supported by the backend.
**spec:
State-definition keys. The mapping must contain exactly two
independent thermodynamic inputs after metadata keys are ignored.
Examples
--------
``state(fluid="R134a", T_C=-10, x=1.0)``
``state(fluid="HEOS::Air", T=300.0, P_bar=1.01325)``
Notes
-----
Accepted input keys include EES-style names such as ``T``, ``P``, ``h``,
``s``, ``u``, ``rho``, ``v``, ``x``, and ``Q``, plus unit-suffixed keys
such as ``T_C``, ``P_bar``, ``h_kJkg``, ``s_kJkgK``, ``rho_kgm3``, and
``v_m3kg``.
"""
mapping: dict[str, Any] = dict(spec)
if fluid is not None:
mapping["fluid"] = fluid
return state_from_mapping(mapping, outputs=outputs, include_phase=include_phase)
[docs]
@with_error_context("thermo_props.api.props")
def props(
*,
fluid: str | None = None,
outputs: Sequence[str] = DEFAULT_OUTPUTS,
include_phase: bool = True,
**spec: Any,
) -> dict[str, float]:
"""Return a property dictionary in SI units for a thermodynamic state."""
st = state(fluid=fluid, outputs=outputs, include_phase=include_phase, **spec)
return dict(st.props)
[docs]
@with_error_context("thermo_props.api.prop")
def prop(
out: str,
*,
fluid: str | None = None,
include_phase: bool = False,
**spec: Any,
) -> float:
"""Return one property in SI units for a thermodynamic state.
Parameters
----------
out:
Requested output property key.
fluid:
Fluid identifier. This may also be supplied in ``spec``.
include_phase:
Whether to include phase evaluation while constructing the state.
**spec:
State-definition keys.
Examples
--------
``prop("P", fluid="R134a", T_C=35, x=0.0)``
``prop("Hmass", fluid="HEOS::Air", T=300, P=101325)``
"""
st = state(fluid=fluid, outputs=(out,), include_phase=include_phase, **spec)
if out not in st.props:
raise ValueError(f"Property {out!r} was not computed. Available: {sorted(st.props.keys())}")
return float(st.props[out])
# ------------------------------ app-facing facade ------------------------------
def _as_mapping(x: Any, what: str) -> Mapping[str, Any]:
if isinstance(x, Mapping):
return x
# allow dataclass-ish objects with __dict__
d = getattr(x, "__dict__", None)
if isinstance(d, dict):
return d
raise TypeError(f"{what} must be a mapping-like object; got {type(x).__name__}.")
def _coerce_outputs(v: Any) -> Sequence[str]:
if v is None:
return DEFAULT_OUTPUTS
if isinstance(v, (list, tuple)):
out = tuple(str(x) for x in v if str(x).strip())
return out if out else DEFAULT_OUTPUTS
if isinstance(v, str):
parts = [p.strip() for p in v.split(",") if p.strip()]
return tuple(parts) if parts else DEFAULT_OUTPUTS
return DEFAULT_OUTPUTS
def _is_given_style_state(m: Mapping[str, Any]) -> bool:
# design.py emits {"given": {...}, "ask": [...]}
g = m.get("given", None)
return isinstance(g, Mapping)
def _eval_one_state(
state_item: Mapping[str, Any],
*,
i: int,
fluid_default: str | None,
outputs_default: Sequence[str] | None,
include_phase: bool,
) -> dict[str, Any]:
# allow per-state fluid override, else inherit root fluid
fluid = state_item.get("fluid", None)
fluid = str(fluid) if fluid is not None else (str(fluid_default) if fluid_default is not None else None)
if fluid is None:
raise ValueError("thermo_props: missing 'fluid' (provide at root or per-state).")
if _is_given_style_state(state_item):
given_map = _as_mapping(state_item.get("given"), f"states[{i}].given")
ask = state_item.get("ask", None)
outputs = _coerce_outputs(ask) if ask is not None else (outputs_default or DEFAULT_OUTPUTS)
st = state_from_mapping(
dict(given_map),
fluid=fluid,
outputs=outputs,
include_phase=include_phase,
)
return {
"index": i,
"id": state_item.get("id", None),
"name": state_item.get("name", None),
"label": state_item.get("label", None),
"fluid": st.fluid,
"given": dict(given_map),
"outputs": list(outputs),
"in1": st.in1,
"v1": st.v1,
"in2": st.in2,
"v2": st.v2,
"phase": st.phase,
"props": dict(st.props),
}
# Back-compat: allow state defined directly by two keys at the item level.
outputs = outputs_default or DEFAULT_OUTPUTS
st = state_from_mapping(
dict(state_item),
fluid=fluid,
outputs=outputs,
include_phase=include_phase,
)
return {
"index": i,
"id": state_item.get("id", None),
"name": state_item.get("name", None),
"label": state_item.get("label", None),
"fluid": st.fluid,
"given": {
k: v
for k, v in dict(state_item).items()
if k not in ("id", "name", "label", "fluid", "ask", "outputs", "backend", "meta")
},
"outputs": list(outputs),
"in1": st.in1,
"v1": st.v1,
"in2": st.in2,
"v2": st.v2,
"phase": st.phase,
"props": dict(st.props),
}
[docs]
@with_error_context("thermo_props.api.run")
def run(spec: Any) -> dict[str, Any]:
"""Evaluate one or more thermodynamic-property states.
This is the app-oriented entry point used by ``problem_type =
"thermo_props"`` inputs.
Preferred input shape
---------------------
The current design layer emits a mapping with these keys:
* ``backend``: backend label, typically ``"coolprop"``.
* ``fluid``: root-level fluid identifier.
* ``states``: list of state mappings.
* ``meta``: optional metadata mapping.
Each state may use ``given`` for state inputs and ``ask`` for requested
output keys.
Backward-compatible shapes
--------------------------
The function also accepts older shapes:
* ``{"fluid": "...", "state": {...}, "outputs": [...]}``
* ``{"fluid": "...", "states": [...], "outputs": [...]}``
* ``{"fluid": "...", "given": {...}, "ask": [...]}``
* ``{"fluid": "...", "T": ..., "P": ..., "outputs": [...]}``
Returns
-------
dict[str, Any]
JSON-serializable result payload.
"""
m = _as_mapping(spec, "thermo_props spec")
backend = str(m.get("backend", "coolprop"))
fluid = m.get("fluid", None)
fluid = str(fluid) if fluid is not None else None
include_phase = bool(m.get("include_phase", True))
# If provided, these apply as defaults (per-state 'ask' can override).
outputs_default: Sequence[str] | None = None
if "outputs" in m and m.get("outputs") is not None:
outputs_default = _coerce_outputs(m.get("outputs"))
# ---- Single-state: explicit "state" mapping (older)
if "state" in m and m.get("state") is not None:
st_map = _as_mapping(m["state"], "spec['state']")
st = state_from_mapping(
dict(st_map),
fluid=fluid,
outputs=outputs_default or DEFAULT_OUTPUTS,
include_phase=include_phase,
)
return {
"mode": "single",
"backend": backend,
"fluid": st.fluid,
"in1": st.in1,
"v1": st.v1,
"in2": st.in2,
"v2": st.v2,
"phase": st.phase,
"props": dict(st.props),
}
# ---- Single-state: design-style shorthand at root {"given": {...}, "ask": [...]}
if "given" in m and isinstance(m.get("given"), Mapping):
given_map = _as_mapping(m["given"], "spec['given']")
ask = m.get("ask", None)
outputs = _coerce_outputs(ask) if ask is not None else (outputs_default or DEFAULT_OUTPUTS)
if fluid is None:
raise ValueError("thermo_props: missing 'fluid' (provide at root).")
st = state_from_mapping(
dict(given_map),
fluid=fluid,
outputs=outputs,
include_phase=include_phase,
)
return {
"mode": "single",
"backend": backend,
"fluid": st.fluid,
"in1": st.in1,
"v1": st.v1,
"in2": st.in2,
"v2": st.v2,
"phase": st.phase,
"props": dict(st.props),
}
# ---- Batch: "states" list (preferred; design.py emits this)
if "states" in m and m.get("states") is not None:
items = m["states"]
if not isinstance(items, (list, tuple)):
raise TypeError("spec['states'] must be a list of state mappings.")
out_states: list[dict[str, Any]] = []
for i, item in enumerate(items):
st_map = _as_mapping(item, f"spec['states'][{i}]")
out_states.append(
_eval_one_state(
st_map,
i=i,
fluid_default=fluid,
outputs_default=outputs_default,
include_phase=include_phase,
)
)
return {
"mode": "batch",
"backend": backend,
"fluid": fluid,
"states": out_states,
"meta": dict(m.get("meta", {}) or {}) if isinstance(m.get("meta", {}) or {}, Mapping) else {},
}
# ---- Compatibility: attempt to interpret the top-level mapping directly as a two-key state.
try:
st = state_from_mapping(
dict(m),
fluid=fluid,
outputs=outputs_default or DEFAULT_OUTPUTS,
include_phase=include_phase,
)
return {
"mode": "single",
"backend": backend,
"fluid": st.fluid,
"in1": st.in1,
"v1": st.v1,
"in2": st.in2,
"v2": st.v2,
"phase": st.phase,
"props": dict(st.props),
}
except Exception as e:
raise ValueError(
"thermo_props.api.run(spec) could not interpret the input. "
"Preferred: {'fluid':..., 'states':[{'given':{...}, 'ask':[...]} ...]} "
"or single-state {'fluid':..., 'given':{...}, 'ask':[...]}."
) from e
# Back-compat alias (EespyApp will fall back to this name if run() isn't found).
eval_states = run
# ------------------------------ serialization helpers ------------------------------
[docs]
def state_to_dict(st: ThermoState) -> dict[str, Any]:
"""Return a JSON-friendly representation of a ``ThermoState``.
Values are in SI units using CoolProp-native keys, except derived outputs
such as ``v`` in cubic meters per kilogram.
"""
return asdict(st)
# ------------------------------ overlay helpers (Plotly-friendly) ------------------------------
[docs]
@with_error_context("thermo_props.api.saturation_curve_Ts")
def saturation_curve_Ts(
fluid: str,
*,
n: int = 200,
T_min_K: float | None = None,
T_max_K: float | None = None,
) -> dict[str, list[float]]:
"""Return saturation-dome curves for a pure fluid in T-s space.
Output keys are ``T_K``, ``sL_JkgK`` for saturated liquid entropy, and
``sV_JkgK`` for saturated vapor entropy.
The helper uses CoolProp saturation evaluation through ``(T, Q)`` calls.
Fluids without a saturation curve, or mixtures that do not support this
evaluation path, may raise ``CoolPropCallError``.
"""
if n < 10:
raise ValueError("n must be >= 10")
if T_min_K is None or T_max_K is None:
T_min_K2, T_max_K2 = _find_sat_T_bounds(fluid)
T_min_K = T_min_K if T_min_K is not None else T_min_K2
T_max_K = T_max_K if T_max_K is not None else T_max_K2
if not (T_min_K and T_max_K and T_max_K > T_min_K):
raise CoolPropCallError("Invalid saturation temperature bounds.")
Ts = [T_min_K + (T_max_K - T_min_K) * i / (n - 1) for i in range(n)]
T_out: list[float] = []
sL: list[float] = []
sV: list[float] = []
for T in Ts:
try:
s_liq = props_si("Smass", "T", float(T), "Q", 0.0, fluid)
s_vap = props_si("Smass", "T", float(T), "Q", 1.0, fluid)
except Exception:
continue
T_out.append(float(T))
sL.append(float(s_liq))
sV.append(float(s_vap))
if len(T_out) < 5:
raise CoolPropCallError(
"Failed to build saturation curve (insufficient points). "
"This fluid may not support saturation via CoolProp."
)
return {"T_K": T_out, "sL_JkgK": sL, "sV_JkgK": sV}
[docs]
@with_error_context("thermo_props.api.isobars_Ts")
def isobars_Ts(
fluid: str,
pressures_Pa: Sequence[float],
*,
nT: int = 80,
T_min_K: float | None = None,
T_max_K: float | None = None,
) -> list[dict[str, Any]]:
"""Generate T-s isobars for Plotly-style overlays.
Parameters
----------
fluid:
Fluid identifier.
pressures_Pa:
Iterable of pressure values in pascals.
nT:
Number of temperature samples per isobar.
T_min_K:
Optional lower temperature bound in kelvin.
T_max_K:
Optional upper temperature bound in kelvin.
Returns
-------
list[dict[str, Any]]
Trace-like dictionaries with keys ``P_Pa``, ``T_K``, and ``s_JkgK``.
"""
if nT < 10:
raise ValueError("nT must be >= 10")
if not pressures_Pa:
return []
if T_min_K is None or T_max_K is None:
try:
Tmin, Tmax = _find_sat_T_bounds(fluid)
T_min_K = T_min_K if T_min_K is not None else Tmin
T_max_K = T_max_K if T_max_K is not None else Tmax
except Exception:
T_min_K = T_min_K if T_min_K is not None else 200.0
T_max_K = T_max_K if T_max_K is not None else 600.0
if not (T_min_K and T_max_K and T_max_K > T_min_K):
raise CoolPropCallError("Invalid isobar temperature bounds.")
traces: list[dict[str, Any]] = []
Ts = [T_min_K + (T_max_K - T_min_K) * i / (nT - 1) for i in range(nT)]
for P in pressures_Pa:
P = float(P)
T_out: list[float] = []
s_out: list[float] = []
for T in Ts:
try:
s = props_si("Smass", "T", float(T), "P", P, fluid)
except Exception:
continue
T_out.append(float(T))
s_out.append(float(s))
if len(T_out) >= 5:
traces.append({"P_Pa": P, "T_K": T_out, "s_JkgK": s_out})
return traces
# ------------------------------ internal helpers ------------------------------
def _safe_sat_probe(fluid: str) -> bool:
try:
_ = props_si("P", "T", 300.0, "Q", 0.0, fluid)
_ = props_si("P", "T", 300.0, "Q", 1.0, fluid)
return True
except Exception:
return False
def _find_sat_T_bounds(fluid: str) -> tuple[float, float]:
"""Find approximate saturation-temperature bounds for ``(T, Q)`` calls.
The search is intentionally best-effort. It probes downward and upward from
300 K and returns a usable interval for overlay generation.
"""
if not _safe_sat_probe(fluid):
raise CoolPropCallError("Fluid does not appear to support saturation via (T,Q) calls.")
Tmin = 300.0
for _ in range(80):
try:
_ = props_si("P", "T", Tmin, "Q", 0.0, fluid)
_ = props_si("P", "T", Tmin, "Q", 1.0, fluid)
Tmin *= 0.98
except Exception:
Tmin = Tmin / 0.98
break
Tmax = 300.0
last_ok = Tmax
for _ in range(160):
try:
_ = props_si("P", "T", Tmax, "Q", 0.0, fluid)
_ = props_si("P", "T", Tmax, "Q", 1.0, fluid)
last_ok = Tmax
Tmax *= 1.02
except Exception:
break
Tmax = max(last_ok * 0.999, Tmin + 1.0)
return float(Tmin), float(Tmax)