规范仿真模型库并完善前端交互

归档仿真模型并补充组件目录、建模规范与校验。

完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
ljz committed 2026-07-29 15:44:16 +08:00
1 parent f7f1078911
commit bbc88a6bbb
71 files changed
+4574 -806

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
"""Core abstractions for the Python system model."""
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, 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.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
def __init__(self, name: str) -> None:
self.name = name
self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower()
self._ports: dict[str, PortState] = {}
self._parameter_values: dict[str, float] = {}
@property
def ports(self) -> dict[str, PortState]:
return dict(self._ports)
@property
def port_definitions(self) -> tuple[PortDefinition, ...]:
return tuple(
port.definition
for port in self._ports.values()
if port.definition is not None
)
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.")
if definition.name in self._ports:
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)
except StopIteration as 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)
+ "."
)
missing = sorted(set(definitions) - set(values))
if 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}."
)
resolved[name] = value
self._parameter_values = resolved
@property
def parameter_values(self) -> dict[str, float]:
return dict(self._parameter_values)
def get_port(self, name: str) -> PortState:
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.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
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
]
for port_definition in self.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,
)
)
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
]
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
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 update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
return None
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."""
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
PortDisplaySide = Literal["left", "right"]
@dataclass(frozen=True)
class ComponentCategorySpec:
"""A presentation-only category declared by one component library."""
id: str
label: str
order: int = 0
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"order": self.order,
}
@dataclass(frozen=True)
class PortDisplaySpec:
"""Canvas placement for one port without changing its physical contract."""
name: str
side: PortDisplaySide
order: int = 0
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
label: str
library_id: str
category_id: str
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
return {port.name: port for port in self.ports}
@dataclass(frozen=True)
class ComponentLibrarySpec:
"""Manifest for one explicitly enabled component library."""
id: str
label: str
version: str
source_package: str
categories: tuple[ComponentCategorySpec, ...]
models: tuple[str, ...]
temporary: bool = False
order: int = 0
@property
def category_by_id(self) -> dict[str, ComponentCategorySpec]:
return {category.id: category for category in self.categories}
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"version": self.version,
"sourcePackage": self.source_package,
"temporary": self.temporary,
"order": self.order,
"categories": [
category.as_catalog_dict()
for category in sorted(
self.categories,
key=lambda item: (item.order, item.id),
)
],
}
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.simulation.core.ports import VariableRole
EquationOwner = Literal["connection", "component"]
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
@dataclass(frozen=True)
class EquationResidual:
"""One executable scalar equation in the pressure-flow subsystem."""
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]:
return {
"id": self.id,
"owner": self.owner,
"ownerId": self.owner_id,
"relation": self.relation,
"variables": list(self.variables),
"role": self.role,
}
def as_interface_dict(self) -> dict[str, object]:
return {**self.as_definition_dict(), "residual": self.value}
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ThermodynamicProperties:
p: float
T: float
rho: float
u: float
h: float
@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
@property
def cv(self) -> float:
return self.cv_at_temperature(self.T_ref)
@property
def gamma(self) -> float:
return self.cp_at_temperature(self.T_ref) / self.cv
def cp_at_temperature(self, T: float) -> float:
return self.cp_ref + self.cp_slope * (T - self.T_ref)
def cv_at_temperature(self, T: float) -> float:
return self.cp_at_temperature(T) - self.R_gas
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
def specific_internal_energy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
self.cv * self.T_ref
+ self.cv * delta_T
+ 0.5 * self.cp_slope * delta_T * delta_T
)
def specific_enthalpy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
self.cp_ref * self.T_ref
+ self.cp_ref * delta_T
+ 0.5 * self.cp_slope * delta_T * delta_T
)
def 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_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.")
return self.temperature_from_internal_energy(U / m)
def pressure(self, m: float, T: float, V: float) -> float:
if V <= 0.0:
raise ValueError("Volume must stay positive.")
return m * self.R_gas * T / V
def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties:
T = self.temperature_from_mass_internal_energy(m, U)
p = self.pressure(m, T, V)
rho = m / V
u = U / m
h = self.specific_enthalpy(T)
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Literal
ResultVariableScope = Literal["component", "port"]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
"dimensionless": "",
"density": "kg/m³",
"flow_coefficient": "kg/(s*Pa^0.5)",
"internal_energy": "J",
"length": "m",
"mass": "kg",
"mass_flow": "kg/s",
"pressure": "Pa",
"specific_enthalpy": "J/kg",
"specific_internal_energy": "J/kg",
"temperature": "K",
"volume": "m3",
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
name: str
default: float
label: str = ""
quantity: str = "dimensionless"
unit: str = ""
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
return "must be finite"
if self.minimum is not None:
if self.minimum_exclusive and value <= self.minimum:
return f"must be greater than {self.minimum:g}"
if not self.minimum_exclusive and value < self.minimum:
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}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
payload: dict[str, object] = {
"name": self.name,
"label": self.label or self.name,
"quantity": self.quantity,
"unit": self.unit,
"default": self.default,
"minimumExclusive": self.minimum_exclusive,
}
if self.minimum is not None:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if value is not None:
payload["value"] = value
return payload
@dataclass(frozen=True)
class ResultVariableDefinition:
"""Component-relative declaration of a user-visible simulation result."""
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
visible: bool = True
@dataclass(frozen=True)
class ResultVariableMetadata:
"""A result declaration bound to one concrete component instance."""
key: str
component_id: str
component_type: str
scope: ResultVariableScope
name: str
label: str
quantity: str
unit: str
category: str
order: int
port_name: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"key": self.key,
"componentId": self.component_id,
"componentType": self.component_type,
"scope": self.scope,
"portName": self.port_name,
"name": self.name,
"label": self.label,
"quantity": self.quantity,
"unit": self.unit,
"category": self.category,
"order": self.order,
}
THERMODYNAMIC_VOLUME_RESULT_VARIABLES = (
ResultVariableDefinition(
name="m",
label="质量",
quantity="mass",
unit="kg",
category="state",
order=10,
),
ResultVariableDefinition(
name="U",
label="内能",
quantity="internal_energy",
unit="J",
category="state",
order=20,
),
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
),
ResultVariableDefinition(
name="T",
label="温度",
quantity="temperature",
unit="K",
category="thermodynamic",
order=40,
),
ResultVariableDefinition(
name="rho",
label="密度",
quantity="density",
unit="kg/m³",
category="thermodynamic",
order=50,
),
ResultVariableDefinition(
name="u",
label="比内能",
quantity="specific_internal_energy",
unit="J/kg",
category="thermodynamic",
order=60,
),
ResultVariableDefinition(
name="h",
label="比焓",
quantity="specific_enthalpy",
unit="J/kg",
category="thermodynamic",
order=70,
),
)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
PortKind = Literal["physical", "signal"]
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
ActualFlowDirection = Literal["in", "out", "stagnant"]
VariableRole = Literal["effort", "flow", "stream", "signal"]
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
@dataclass(frozen=True)
class PortVariableDefinition:
name: str
role: VariableRole
connection_rule: ConnectionRule
label: str = field(default="", compare=False)
quantity: str = field(default="", compare=False)
unit: str = field(default="", compare=False)
result_visible: bool = field(default=True, compare=False)
order: int = field(default=0, compare=False)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"role": self.role,
"connectionRule": self.connection_rule,
"label": self.label or self.name,
"quantity": self.quantity or self.name,
"unit": self.unit,
"resultVisible": self.result_visible,
"order": self.order,
}
@dataclass(frozen=True)
class PortDefinition:
"""Stable connector contract shared by components, XML, and the compiler."""
name: str
kind: PortKind
domain: str
nominal_role: PortNominalRole
positive_flow_direction: Literal["intoComponent"] | None = None
variables: tuple[PortVariableDefinition, ...] = ()
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortDefinition:
return cls(
name=name,
kind="physical",
domain="pneumatic",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
variables=(
PortVariableDefinition(
"p",
"effort",
"equal",
label="压力",
quantity="pressure",
unit="Pa",
order=10,
),
PortVariableDefinition(
"m_flow",
"flow",
"sumToZero",
label="质量流量",
quantity="mass_flow",
unit="kg/s",
order=20,
),
PortVariableDefinition(
"h_outflow",
"stream",
"streamMix",
label="流出比焓",
quantity="specific_enthalpy",
unit="J/kg",
order=30,
),
),
)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"kind": self.kind,
"domain": self.domain,
"nominalRole": self.nominal_role,
"positiveFlowDirection": self.positive_flow_direction,
"variables": [variable.as_interface_dict() for variable in self.variables],
}
@dataclass
class PortState:
"""Python-side analogue of a Modelica fluid port."""
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
@classmethod
def pneumatic(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortState:
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
@property
def inflow_rate(self) -> float:
return max(self.m_flow, 0.0)
@property
def outflow_rate(self) -> float:
return max(-self.m_flow, 0.0)
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
if self.m_flow > tolerance:
return "in"
if self.m_flow < -tolerance:
return "out"
return "stagnant"
+21
View File
@@ -0,0 +1,21 @@
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])