227 lines
7.2 KiB
Python
227 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Protocol
|
|
|
|
from app.simulation.core.errors import RecoverableTrialStateError
|
|
from app.simulation.performance import profile_property
|
|
|
|
|
|
@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
|
|
|
|
@profile_property("density")
|
|
def density(self, p: float, T: float) -> float:
|
|
return p / (self.R_gas * T)
|
|
|
|
@profile_property("isentropic_density_pressure_factor")
|
|
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
|
|
|
|
@profile_property("dynamic_viscosity")
|
|
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)
|
|
)
|
|
|
|
@profile_property("specific_internal_energy")
|
|
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
|
|
)
|
|
|
|
@profile_property("specific_internal_energy_at_pressure")
|
|
def specific_internal_energy_at_pressure(self, p: float, T: float) -> float:
|
|
del p
|
|
return self.specific_internal_energy(T)
|
|
|
|
@profile_property("specific_enthalpy")
|
|
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
|
|
)
|
|
|
|
@profile_property("specific_enthalpy_at_pressure")
|
|
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
|
|
|
|
@profile_property("temperature_from_pressure_enthalpy")
|
|
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
|
|
|
|
@profile_property("properties_from_mU")
|
|
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)
|