from __future__ import annotations from dataclasses import dataclass from typing import Protocol from app.simulation.core.errors import RecoverableTrialStateError @dataclass(frozen=True) class ThermodynamicProperties: p: float T: float rho: float u: float h: float class GasMedium(Protocol): """Thermodynamic contract required by pneumatic components. ``IdealGasMedium`` is the default implementation. Keeping the component boundary structural allows a later helium/Peng-Robinson implementation to be registered without changing every AMESim component constructor. """ name: str R_gas: float cp_ref: float T_ref: float @property def cv(self) -> float: ... @property def gamma(self) -> float: ... def cp_at_temperature(self, T: float) -> float: ... def cv_at_temperature(self, T: float) -> float: ... def density(self, p: float, T: float) -> float: ... def isentropic_density_pressure_factor( self, p: float, T: float, downstream_pressure: float | None = None, ) -> float: ... def dynamic_viscosity(self, T: float) -> float: ... def specific_internal_energy(self, T: float) -> float: ... def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ... def specific_enthalpy(self, T: float) -> float: ... def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: ... def temperature_from_internal_energy(self, u: float) -> float: ... def temperature_from_enthalpy(self, h: float) -> float: ... def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: ... def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: ... def pressure(self, m: float, T: float, V: float) -> float: ... def properties_from_mU( self, m: float, U: float, V: float, ) -> ThermodynamicProperties: ... @dataclass(frozen=True) class IdealGasMedium: """Temperature-dependent ideal-gas air approximation. This is still not a strict clone of `Modelica.Media.Air.SimpleAir`. The small linear `cp(T)` term is kept configurable for calibration, but the current default is calibrated against the committed Testmodel baseline and therefore falls back to the constant-heat-capacity limit. """ name: str = "SimpleAirApprox" R_gas: float = 287.0 cp_ref: float = 1005.0 T_ref: float = 300.0 cp_slope: float = 0.0 viscosity_ref: float = 1.82e-5 viscosity_T_ref: float = 293.15 sutherland_constant: float = 110.4 @property def cv(self) -> float: return self.cv_at_temperature(self.T_ref) @property def gamma(self) -> float: return self.cp_at_temperature(self.T_ref) / self.cv def cp_at_temperature(self, T: float) -> float: return self.cp_ref + self.cp_slope * (T - self.T_ref) def cv_at_temperature(self, T: float) -> float: return self.cp_at_temperature(T) - self.R_gas def density(self, p: float, T: float) -> float: return p / (self.R_gas * T) def isentropic_density_pressure_factor( self, p: float, T: float, downstream_pressure: float | None = None, ) -> float: del p del downstream_pressure cp = self.cp_at_temperature(T) cv = self.cv_at_temperature(T) return cv / cp def dynamic_viscosity(self, T: float) -> float: """Return dynamic viscosity using the default air Sutherland law.""" if T <= 0.0: raise ValueError("Temperature must be positive.") return ( self.viscosity_ref * (T / self.viscosity_T_ref) ** 1.5 * (self.viscosity_T_ref + self.sutherland_constant) / (T + self.sutherland_constant) ) def specific_internal_energy(self, T: float) -> float: delta_T = T - self.T_ref return ( self.cv * self.T_ref + self.cv * delta_T + 0.5 * self.cp_slope * delta_T * delta_T ) def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: del p return self.specific_internal_energy(T) def specific_enthalpy(self, T: float) -> float: delta_T = T - self.T_ref return ( self.cp_ref * self.T_ref + self.cp_ref * delta_T + 0.5 * self.cp_slope * delta_T * delta_T ) def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: del p return self.specific_enthalpy(T) def temperature_from_internal_energy(self, u: float) -> float: reference_internal_energy = self.cv * self.T_ref delta_u = u - reference_internal_energy if abs(self.cp_slope) <= 1e-15: return self.T_ref + delta_u / self.cv a = 0.5 * self.cp_slope b = self.cv c = -delta_u discriminant = max(b * b - 4.0 * a * c, 0.0) positive_root = (-b + discriminant**0.5) / (2.0 * a) negative_root = (-b - discriminant**0.5) / (2.0 * a) delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root return self.T_ref + delta_T def temperature_from_enthalpy(self, h: float) -> float: reference_enthalpy = self.cp_ref * self.T_ref delta_h = h - reference_enthalpy if abs(self.cp_slope) <= 1e-15: return self.T_ref + delta_h / self.cp_ref a = 0.5 * self.cp_slope b = self.cp_ref c = -delta_h discriminant = max(b * b - 4.0 * a * c, 0.0) positive_root = (-b + discriminant**0.5) / (2.0 * a) negative_root = (-b - discriminant**0.5) / (2.0 * a) delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root return self.T_ref + delta_T def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: del p return self.temperature_from_enthalpy(h) def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: if m <= 0.0: raise RecoverableTrialStateError( "Mass must stay positive when recovering temperature." ) return self.temperature_from_internal_energy(U / m) def pressure(self, m: float, T: float, V: float) -> float: if V <= 0.0: raise ValueError("Volume must stay positive.") return m * self.R_gas * T / V def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties: T = self.temperature_from_mass_internal_energy(m, U) p = self.pressure(m, T, V) rho = m / V u = U / m h = self.specific_enthalpy(T) return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)