Replace Python numerical kernels with native C execution

This commit is contained in:
ljz committed 2026-09-10 01:12:18 +08:00
1 parent 48da6be21c
commit 3b38f73fe0
227 files changed
+16801 -75499

No files matched your search

+28 -267
View File
@@ -1,40 +1,17 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from abc import ABC
from collections.abc import Mapping
from typing import TYPE_CHECKING, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
ResultVariableMetadata,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.equations import EquationDefinition
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, ResultVariableMetadata, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import GasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
# ``True`` means that pressure/flow residuals read values written by
# ``update_stream_outflows`` or ``update_flow_temperature_references``.
# ``False`` is an explicit promise that those residuals are independent of
# stream propagation. ``None`` keeps custom components conservative: when
# they override either stream hook, the closure planner retains the legacy
# full-network thermofluid fixed point.
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
# Exact residual suffixes whose declared variables are summed, in order,
# to form a ``sumToZero`` flow equation. The causal solver deliberately
# reads this capability from the concrete class ``__dict__``: subclasses
# must repeat the promise after changing any equation semantics.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
frozenset[str]
] = frozenset()
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
@@ -52,80 +29,53 @@ class Component(ABC):
@property
def port_definitions(self) -> tuple[PortDefinition, ...]:
return tuple(
port.definition
for port in self._ports.values()
if port.definition is not None
)
return tuple((port.definition for port in self._ports.values() if port.definition is not None))
@classmethod
def active_port_definitions_for_parameters(
cls,
parameters: Mapping[str, float],
) -> tuple[PortDefinition, ...]:
def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]:
"""Declared ports enabled by one normalized parameter set."""
return cls.PORTS
@property
def active_port_definitions(self) -> tuple[PortDefinition, ...]:
"""Instance ports that participate in execution and result reporting."""
return self.port_definitions
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple(
definition.name
for definition in self.active_port_definitions
if definition.kind == "physical"
)
return tuple((definition.name for definition in self.active_port_definitions if definition.kind == 'physical'))
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
raise ValueError(f"Component {self.name} cannot register an undefined port.")
raise ValueError(f'Component {self.name} cannot register an undefined port.')
if definition.name in self._ports:
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
raise ValueError(f'Duplicate port {self.name}.{definition.name}.')
self._ports[definition.name] = port
return port
def register_declared_port(self, name: str) -> PortState:
try:
definition = next(item for item in self.PORTS if item.name == name)
definition = next((item for item in self.PORTS if item.name == name))
except StopIteration as exc:
raise ValueError(
f"Component model {self.model_type} does not declare port {name}."
) from exc
raise ValueError(f'Component model {self.model_type} does not declare port {name}.') from exc
return self.register_port(PortState(definition=definition))
def set_parameter_values(self, values: Mapping[str, float]) -> None:
definitions = {definition.name: definition for definition in self.PARAMETERS}
unknown = sorted(set(values) - set(definitions))
if unknown:
raise ValueError(
f"Component {self.name} contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
raise ValueError(f'Component {self.name} contains unsupported parameters: ' + ', '.join(unknown) + '.')
missing = sorted(set(definitions) - set(values))
if missing:
raise ValueError(
f"Component {self.name} is missing parameters: "
+ ", ".join(missing)
+ "."
)
raise ValueError(f'Component {self.name} is missing parameters: ' + ', '.join(missing) + '.')
resolved: dict[str, float] = {}
for name, definition in definitions.items():
value = float(values[name])
message = definition.validation_message(value)
if message is not None:
raise ValueError(
f"Parameter '{name}' on component '{self.name}' {message}."
)
raise ValueError(f"Parameter '{name}' on component '{self.name}' {message}.")
resolved[name] = value
self._parameter_values = resolved
@@ -137,229 +87,40 @@ class Component(ABC):
try:
return self._ports[name]
except KeyError as exc:
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
def component_result_values(self) -> Mapping[str, float]:
return {}
def result_values(self) -> dict[str, float]:
component_values = dict(self.component_result_values())
declared = {definition.name: definition for definition in self.RESULT_VARIABLES}
unknown = sorted(set(component_values) - set(declared))
if unknown:
raise ValueError(
f"Component {self.name} returned undeclared result variables: "
+ ", ".join(unknown)
+ "."
)
values: dict[str, float] = {}
for name, definition in declared.items():
if not definition.visible:
continue
if name not in component_values:
raise ValueError(
f"Component {self.name} did not provide declared result variable {name}."
)
values[name] = float(component_values[name])
for port_definition in self.active_port_definitions:
port = self.get_port(port_definition.name)
for variable in port_definition.variables:
if not variable.result_visible:
continue
values[f"{port_definition.name}.{variable.name}"] = float(
getattr(port, variable.name)
)
return values
raise ValueError(f'Component {self.name} has no port named {name}.') from exc
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
metadata = [
ResultVariableMetadata(
key=f"{self.name}.{definition.name}",
component_id=self.name,
component_type=self.model_type,
scope="component",
name=definition.name,
label=definition.label,
quantity=definition.quantity,
unit=definition.unit,
category=definition.category,
order=definition.order,
)
for definition in self.RESULT_VARIABLES
if definition.visible
]
metadata = [ResultVariableMetadata(key=f'{self.name}.{definition.name}', component_id=self.name, component_type=self.model_type, scope='component', name=definition.name, label=definition.label, quantity=definition.quantity, unit=definition.unit, category=definition.category, order=definition.order) for definition in self.RESULT_VARIABLES if definition.visible]
for port_definition in self.active_port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
metadata.append(
ResultVariableMetadata(
key=f"{self.name}.{port_definition.name}.{variable.name}",
component_id=self.name,
component_type=self.model_type,
scope="port",
port_name=port_definition.name,
name=variable.name,
label=variable.label or variable.name,
quantity=variable.quantity or variable.name,
unit=variable.unit,
category=variable.role,
order=variable.order,
)
)
metadata.append(ResultVariableMetadata(key=f'{self.name}.{port_definition.name}.{variable.name}', component_id=self.name, component_type=self.model_type, scope='port', port_name=port_definition.name, name=variable.name, label=variable.label or variable.name, quantity=variable.quantity or variable.name, unit=variable.unit, category=variable.role, order=variable.order))
return tuple(metadata)
def parameter_interface_dicts(self) -> list[dict[str, object]]:
return [
definition.as_interface_dict(
value=self._parameter_values.get(definition.name)
)
for definition in self.PARAMETERS
]
return [definition.as_interface_dict(value=self._parameter_values.get(definition.name)) for definition in self.PARAMETERS]
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> Component:
def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> Component:
"""Create a catalog model from normalized SI parameters."""
raise NotImplementedError(f'Component model {cls.__name__} must implement create().')
EQUATIONS = ()
raise NotImplementedError(
f"Component model {cls.__name__} must implement create()."
)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Return algebraic residuals after the network assigns port states."""
return ()
def pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Return live residual values in the declared equation order.
Components with frequently evaluated equations can override this
method to avoid rebuilding immutable equation metadata during closure.
The default keeps third-party components compatible with the public
residual API.
"""
return tuple(
float(equation.value)
for equation in self.pressure_flow_equation_residuals()
)
def pressure_flow_equation_value_readers(
self,
) -> Mapping[str, Callable[[], float]]:
"""Return explicitly separable scalar residual readers.
The solver consumes this optional capability only when the concrete
component class declares the method itself. Subclasses therefore
cannot accidentally inherit an equation-purity promise.
"""
return {}
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
return None
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
"""Update enthalpy references used only by pressure-flow laws.
Most components use the normal stream enthalpy for both energy
transport and upstream-property evaluation. AMESim node submodels can
expose a distinct temperature reference, so the default is a no-op.
"""
return None
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
"""Return directed ``volume``/``volume_flow`` values by pneumatic port.
Most pneumatic components contribute no external chamber volume. Moving
boundaries such as PNRP17 override this hook; the network resolver then
propagates the pair to the component connected at the same physical port.
"""
return {}
def equation_definitions(self):
def bind(value):
if isinstance(value, str):
return value.replace('__MODEL__', self.name)
return tuple((bind(v) for v in value))
return tuple((EquationDefinition(id=bind(e['id']), owner=e['owner'], owner_id=self.name, relation=e['relation'], variables=bind(e['variables']), role=e['role']) for e in self.EQUATIONS))
class DynamicComponent(Component):
state_size = 2
@staticmethod
def actual_stream_enthalpy(
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
return connected_h if port_m_flow > 0.0 else internal_h
def connection_inlet_enthalpy(
self,
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Resolve the enthalpy convected into this control volume through one port."""
return self.actual_stream_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
@abstractmethod
def get_state_vector(self) -> list[float]:
raise NotImplementedError
@abstractmethod
def set_state_vector(self, values: list[float]) -> None:
raise NotImplementedError
def refresh_thermodynamic_ports(self) -> Any:
raise NotImplementedError
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
raise NotImplementedError
class ThermodynamicVolumeComponent(DynamicComponent):
"""Two-state gas volume exposing the shared thermodynamic result contract."""
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def component_result_values(self) -> Mapping[str, float]:
state = self.get_state_vector()
if len(state) < 2:
raise ValueError(
f"Thermodynamic component {self.name} must expose mass and energy states."
)
properties = self.refresh_thermodynamic_ports()
return {
"m": float(state[0]),
"U": float(state[1]),
"p": float(properties.p),
"T": float(properties.T),
"rho": float(properties.rho),
"u": float(properties.u),
"h": float(properties.h),
}
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
+3 -4
View File
@@ -11,15 +11,14 @@ EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True, slots=True)
class EquationResidual:
"""One executable scalar equation in the pressure-flow subsystem."""
class EquationDefinition:
"""One declarative equation in the compiled model interface."""
id: str
owner: EquationOwner
owner_id: str
relation: EquationRelation
variables: tuple[str, ...]
value: float
role: VariableRole | None = None
def as_definition_dict(self) -> dict[str, object]:
@@ -33,4 +32,4 @@ class EquationResidual:
}
def as_interface_dict(self) -> dict[str, object]:
return {**self.as_definition_dict(), "residual": self.value}
return self.as_definition_dict()
+4 -366
View File
@@ -1,378 +1,16 @@
"""Compile-time gas property constants. No Python property evaluator."""
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Protocol, Sequence
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
@dataclass(frozen=True)
class ThermodynamicPropertyTangents:
"""Directional derivatives of a recovered thermodynamic state."""
p: tuple[float, ...]
T: tuple[float, ...]
rho: tuple[float, ...]
u: tuple[float, ...]
h: tuple[float, ...]
@property
def width(self) -> int:
return len(self.p)
@classmethod
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
values = (0.0,) * width
return cls(p=values, T=values, rho=values, u=values, h=values)
@dataclass(frozen=True)
class ThermodynamicPropertiesLinearization:
"""Primal properties and a validity-checked directional linearization."""
properties: ThermodynamicProperties
tangents: ThermodynamicPropertyTangents
valid: bool = True
reason: str | None = None
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 diagnostic_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: ...
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization: ...
@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"
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_ref: float = 1.82e-05
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)
)
def diagnostic_dynamic_viscosity(self, T: float) -> float:
"""Return the viscosity convention used by derived diagnostics.
Most media use the same transport property for dynamics and reported
diagnostics. Reference-library media may override this without
changing a calibrated constitutive flow relation.
"""
return self.dynamic_viscosity(T)
@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)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Linearize properties_from_mU for several seed directions."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="properties_primal_mismatch",
)
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_finite_tangent_input",
)
cv = self.cv_at_temperature(props.T)
cp = self.cp_at_temperature(props.T)
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_positive_heat_capacity",
)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = internal_energy_tangent / cv
pressure_tangent = self.R_gas * (
props.T * density_tangent + props.rho * temperature_tangent
)
enthalpy_tangent = cp * temperature_tangent
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
valid = all(isfinite(value) for value in tangent_values)
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
valid=valid,
reason=None if valid else "non_finite_property_tangent",
)
GasMedium = IdealGasMedium
-424
View File
@@ -1,424 +0,0 @@
from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt
from app.simulation.performance import profile_property
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the
# commonly printed Peng-Robinson constants 0.45724 and 0.07780.
PENG_ROBINSON_A_COEFFICIENT = 0.457235583
PENG_ROBINSON_B_COEFFICIENT = 0.07779607
@dataclass(frozen=True)
class PengRobinsonFluid:
"""Pure-fluid Peng-Robinson equation-of-state helper.
The class covers the equation-of-state layer plus the enthalpy departure
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
"""
name: str
molar_mass: float
critical_temperature: float
critical_pressure: float
acentric_factor: float
@property
def specific_gas_constant(self) -> float:
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
@property
def a_parameter(self) -> float:
return (
PENG_ROBINSON_A_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
* self.critical_temperature
/ self.critical_pressure
)
@property
def b_parameter(self) -> float:
return (
PENG_ROBINSON_B_COEFFICIENT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
/ self.critical_pressure
)
@property
def kappa(self) -> float:
omega = self.acentric_factor
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
def alpha(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
def alpha_temperature_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return -(
alpha_base
* self.kappa
/ (self.critical_temperature * sqrt_reduced_temperature)
)
def alpha_temperature_second_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return (
self.kappa
/ (2.0 * self.critical_temperature * self.critical_temperature)
* (
self.kappa / reduced_temperature
+ alpha_base / (reduced_temperature * sqrt_reduced_temperature)
)
)
def attractive_parameter(self, temperature: float) -> float:
return self.a_parameter * self.alpha(temperature)
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
return self.a_parameter * self.alpha_temperature_derivative(temperature)
def attractive_parameter_temperature_second_derivative(
self,
temperature: float,
) -> float:
return self.a_parameter * self.alpha_temperature_second_derivative(temperature)
@profile_property(
"pressure_from_molar_volume",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature)
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive
@profile_property(
"pressure_from_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0:
raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
@profile_property(
"pressure_temperature_derivative_at_density",
layer="kernel",
minimum_mode="audit",
)
def pressure_temperature_derivative_at_density(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
return (
UNIVERSAL_GAS_CONSTANT / (molar_volume - b)
- self.attractive_parameter_temperature_derivative(temperature) / denominator
)
@profile_property(
"pressure_density_derivative_at_temperature",
layer="kernel",
minimum_mode="audit",
)
def pressure_density_derivative_at_temperature(
self,
temperature: float,
density: float,
) -> float:
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
if molar_volume <= self.b_parameter:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
b = self.b_parameter
denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b)
pressure_molar_volume_derivative = (
-UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) ** 2
+ self.attractive_parameter(temperature)
* 2.0
* (molar_volume + b)
/ denominator**2
)
molar_volume_density_derivative = -self.molar_mass / (density * density)
return pressure_molar_volume_derivative * molar_volume_density_derivative
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
self._validate_pressure_temperature(pressure, temperature)
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B
@profile_property(
"compressibility_roots",
layer="kernel",
minimum_mode="audit",
)
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature)
coefficients = (
-(1.0 - B),
A - 3.0 * B * B - 2.0 * B,
-(A * B - B * B - B * B * B),
)
roots = _real_cubic_roots(*coefficients)
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
if not physical_roots:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots
@profile_property(
"compressibility_factor",
layer="kernel",
minimum_mode="audit",
)
def compressibility_factor(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
roots = self.compressibility_roots(pressure, temperature)
if phase == "vapor":
return roots[-1]
if phase == "liquid":
return roots[0]
if phase == "stable-single-root":
return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}")
@profile_property(
"molar_volume",
layer="kernel",
minimum_mode="audit",
)
def molar_volume(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
@profile_property("density", layer="kernel", minimum_mode="audit")
def density(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
@profile_property(
"residual_specific_enthalpy",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_enthalpy(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
self._validate_pressure_temperature(pressure, temperature)
z = self.compressibility_factor(pressure, temperature, phase=phase)
_, B = self.reduced_parameters(pressure, temperature)
b = self.b_parameter
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
z + (1.0 - sqrt(2.0)) * B
)
residual_molar_enthalpy = (
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
+ (
temperature * d_attractive_d_temperature
- attractive
)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_enthalpy / self.molar_mass
@profile_property(
"residual_specific_internal_energy_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_specific_internal_energy_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return Peng-Robinson internal-energy departure, J/kg."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_internal_energy = (
temperature * d_attractive_d_temperature - attractive
) * log(log_argument) / (2.0 * sqrt(2.0) * b)
return residual_molar_internal_energy / self.molar_mass
@profile_property(
"residual_isochoric_heat_capacity_at_density",
layer="kernel",
minimum_mode="audit",
)
def residual_isochoric_heat_capacity_at_density(
self,
temperature: float,
density: float,
) -> float:
"""Return the constant-volume heat-capacity departure, J/kg/K."""
self._validate_temperature(temperature)
if density <= 0.0:
raise ValueError("Density must be positive.")
molar_volume = self.molar_mass / density
b = self.b_parameter
if molar_volume <= b:
raise RecoverableTrialStateError(
"Molar volume must be larger than Peng-Robinson b parameter."
)
log_argument = (
molar_volume + (1.0 + sqrt(2.0)) * b
) / (
molar_volume + (1.0 - sqrt(2.0)) * b
)
residual_molar_cv = (
temperature
* self.attractive_parameter_temperature_second_derivative(temperature)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_cv / self.molar_mass
@staticmethod
def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0:
raise RecoverableTrialStateError("Temperature must be positive.")
@classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0:
raise RecoverableTrialStateError("Pressure must be positive.")
cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid(
name="helium",
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
# Simcenter Amesim 2404 helium_eos.data.
acentric_factor=-0.382,
)
NITROGEN_PR = PengRobinsonFluid(
name="nitrogen",
molar_mass=0.0280134,
critical_temperature=126.192,
critical_pressure=3.3958e6,
acentric_factor=0.0372,
)
AIR_PR = PengRobinsonFluid(
name="air",
molar_mass=0.02896513,
critical_temperature=132.5306,
critical_pressure=3.786e6,
acentric_factor=0.0335,
)
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
depressed_p = b - a * a / 3.0
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
offset = -a / 3.0
tolerance = 1e-14
if discriminant > tolerance:
sqrt_discriminant = sqrt(discriminant)
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
return (u + v + offset,)
if abs(discriminant) <= tolerance:
u = _real_cube_root(-depressed_q / 2.0)
return tuple(sorted({2.0 * u + offset, -u + offset}))
if depressed_p >= 0.0:
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
radius = 2.0 * sqrt(-depressed_p / 3.0)
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
argument = max(-1.0, min(1.0, argument))
theta = acos(argument) / 3.0
roots = [
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
for index in range(3)
]
return tuple(sorted(roots))
def _real_cube_root(value: float) -> float:
if value == 0.0:
return 0.0
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
-21
View File
@@ -1,21 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class VolumeState:
"""Primary dynamic state for rigid adiabatic control volumes."""
m: float
U: float
def as_vector(self) -> list[float]:
return [self.m, self.U]
@classmethod
def from_vector(cls, values: list[float]) -> "VolumeState":
if len(values) != 2:
raise ValueError("VolumeState requires exactly two values: [m, U].")
return cls(m=values[0], U=values[1])