规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。 完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
1 parent
f7f1078911
commit
bbc88a6bbb
71 files changed
+4574
-806
No files matched your search
@@ -0,0 +1,2 @@
|
||||
"""Core abstractions for the Python system model."""
|
||||
|
||||
@@ -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."""
|
||||
@@ -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),
|
||||
)
|
||||
],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user