Replace Python numerical kernels with native C execution
This commit is contained in:
1 parent
48da6be21c
commit
3b38f73fe0
227 files changed
+16801
-75499
No files matched your search
+28
-267
@@ -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."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user