feat: integrate AMESim media models and editor UI

This commit is contained in:
ljz committed 2026-07-31 23:36:58 +08:00
1 parent 3e4f466114
commit e7177ab03e
181 files changed
+7042 -25270

No files matched your search

+2 -2
View File
@@ -15,7 +15,7 @@ from app.simulation.core.metadata import (
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium
class Component(ABC):
@@ -185,7 +185,7 @@ class Component(ABC):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
+2
View File
@@ -5,6 +5,7 @@ from typing import Literal
PortDisplaySide = Literal["left", "right"]
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
@dataclass(frozen=True)
@@ -42,6 +43,7 @@ class ComponentDisplaySpec:
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
role: ComponentCatalogRole | None = None
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
+79
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
@@ -12,6 +13,53 @@ class ThermodynamicProperties:
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 dynamic_viscosity(self, T: float) -> float: ...
def specific_internal_energy(self, T: float) -> float: ...
def specific_enthalpy(self, T: float) -> float: ...
def temperature_from_internal_energy(self, u: float) -> float: ...
def temperature_from_enthalpy(self, 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.
@@ -27,6 +75,9 @@ class IdealGasMedium:
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:
@@ -45,6 +96,18 @@ class IdealGasMedium:
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
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 (
@@ -77,6 +140,22 @@ class IdealGasMedium:
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_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.")
+34
View File
@@ -6,6 +6,10 @@ from typing import Literal
ResultVariableScope = Literal["component", "port"]
ParameterEditor = Literal[
"amesimGasReference",
"amesimGasPropertyModel",
]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
@@ -34,6 +38,20 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
}
@dataclass(frozen=True)
class ParameterOption:
"""One numeric choice exposed by a catalog-backed parameter editor."""
value: float
label: str
def as_interface_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
@@ -46,6 +64,9 @@ class ParameterDefinition:
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
editor: ParameterEditor | None = None
options: tuple[ParameterOption, ...] = ()
description: str = ""
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
@@ -57,6 +78,11 @@ class ParameterDefinition:
return f"must be at least {self.minimum:g}"
if self.maximum is not None and value > self.maximum:
return f"must be at most {self.maximum:g}"
if self.options and value not in {
float(option.value) for option in self.options
}:
available = ", ".join(f"{option.value:g}" for option in self.options)
return f"must be one of {available}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
@@ -72,6 +98,14 @@ class ParameterDefinition:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if self.editor is not None:
payload["editor"] = self.editor
if self.options:
payload["options"] = [
option.as_interface_dict() for option in self.options
]
if self.description:
payload["description"] = self.description
if value is not None:
payload["value"] = value
return payload