Source code for simulator.thermo.thermo_state

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, Mapping
import math

import numpy as np

from .species import SPECIES_DB, Species, R_UNIVERSAL


[docs] @dataclass class ThermoState: """Thermodynamic state for a gas mixture. The dataclass stores temperature, pressure, species mass fractions, density, specific heats, mixture gas constant, enthalpy, and internal energy for a gas mixture. Use :meth:`from_T_p_Y` when the mixture is described by species names in the internal species database. Use :meth:`from_cantera` when the state is supplied by a Cantera ``Solution`` object. The field annotations below are intentionally left as the source of truth for autodoc. Avoiding a separate ``Attributes`` section prevents duplicate object-description warnings for dataclass fields in Sphinx. """ T: float p: float mass_fractions: Dict[str, float] rho: float cp: float cv: float gamma: float R_mix: float h: float u: float # ------------------------------------------------------------------ # Path 1: our internal NASA/species model # ------------------------------------------------------------------
[docs] @classmethod def from_T_p_Y(cls, T: float, p: float, Y: Mapping[str, float]) -> "ThermoState": """Build a mixture state from temperature, pressure, and mass fractions. Species keys must exist in :data:`SPECIES_DB`. Unknown species are ignored, and the remaining positive fractions are renormalized before mixture properties are computed. """ # Filter to known species and renormalise Y_eff: Dict[str, float] = {} for name, y in Y.items(): if name in SPECIES_DB and y > 0.0: Y_eff[name] = float(y) total = sum(Y_eff.values()) if total <= 0.0: raise ValueError("No positive mass fractions supplied for known species.") for k in list(Y_eff.keys()): Y_eff[k] /= total # Mixture molar mass and cp, cv, h, u inv_M = 0.0 cp_mix = 0.0 cv_mix = 0.0 h_mix = 0.0 u_mix = 0.0 for name, yk in Y_eff.items(): sp: Species = SPECIES_DB[name] inv_M += yk / sp.molar_mass cp_k = sp.cp_mass(T) cv_k = cp_k - R_UNIVERSAL / sp.molar_mass h_k = sp.h_mass(T) u_k = h_k - R_UNIVERSAL * T / sp.molar_mass cp_mix += yk * cp_k cv_mix += yk * cv_k h_mix += yk * h_k u_mix += yk * u_k M_mix = 1.0 / inv_M R_mix = R_UNIVERSAL / M_mix rho = p / (R_mix * T) gamma = cp_mix / cv_mix if cv_mix > 0.0 else math.inf return cls( T=float(T), p=float(p), mass_fractions=dict(Y_eff), rho=float(rho), cp=float(cp_mix), cv=float(cv_mix), gamma=float(gamma), R_mix=float(R_mix), h=float(h_mix), u=float(u_mix), )
# ------------------------------------------------------------------ # Path 2: from a Cantera Solution object # ------------------------------------------------------------------
[docs] @classmethod def from_cantera(cls, gas) -> "ThermoState": """Build a mixture state from a Cantera ``Solution`` object. Cantera provides the thermodynamic properties directly. The mixture gas constant is computed from the ideal-gas identity ``R_mix = cp_mass - cv_mass`` so the method does not depend on version-specific Cantera attributes. """ T = float(gas.T) p = float(gas.P) rho = float(gas.density) cp = float(gas.cp_mass) # [J/kg-K] cv = float(gas.cv_mass) # [J/kg-K] gamma = cp / cv if cv > 0.0 else 1.4 R_mix = cp - cv # ideal-gas identity h = float(gas.enthalpy_mass) # [J/kg] u = float(gas.int_energy_mass) # [J/kg] Y = np.asarray(gas.Y, dtype=float) mass_fractions: Dict[str, float] = {} for name, yk in zip(gas.species_names, Y): if abs(yk) > 1e-12: mass_fractions[name] = float(yk) return cls( T=T, p=p, mass_fractions=mass_fractions, rho=rho, cp=cp, cv=cv, gamma=gamma, R_mix=R_mix, h=h, u=u, )
# ------------------------------------------------------------------ # Convenience updaters # ------------------------------------------------------------------
[docs] def copy_with_T_p(self, T: float | None = None, p: float | None = None) -> "ThermoState": """Return a new state with updated temperature and/or pressure. The new state is recomputed through :meth:`from_T_p_Y` using the existing mass fractions. If the original state came from Cantera and contains species that are not present in :data:`SPECIES_DB`, those species are ignored during recomputation. """ T_new = self.T if T is None else float(T) p_new = self.p if p is None else float(p) return ThermoState.from_T_p_Y(T_new, p_new, self.mass_fractions)
[docs] def copy_with(self, **changes: Any) -> "ThermoState": """Return a shallow copy with arbitrary field overrides. This helper does not recompute thermodynamic properties. It simply replaces selected dataclass fields, which is useful for diagnostics or small reporting tweaks. Example:: state2 = state.copy_with(T=2100.0, p=4e6) """ data = { "T": self.T, "p": self.p, "mass_fractions": dict(self.mass_fractions), "rho": self.rho, "cp": self.cp, "cv": self.cv, "gamma": self.gamma, "R_mix": self.R_mix, "h": self.h, "u": self.u, } data.update(changes) return ThermoState(**data)