完善仿真交互、结果展示与模型元数据

This commit is contained in:
ljz committed 2026-07-22 19:33:38 +08:00
1 parent f1256a121d
commit f7f1078911
26 files changed
+9042 -575

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
- `components/`: 元件级 Python 实现
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。
- `systems/`: 系统级装配与闭合
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
- `reporting/`: 结果导出与对比
+42 -6
View File
@@ -2,16 +2,53 @@ from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.ports import PortDefinition
from PythonModels.core.state import VolumeState
class Cylinder(DynamicComponent):
class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = "cylinder"
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.01,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
35e6,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
@@ -21,14 +58,13 @@ class Cylinder(DynamicComponent):
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
self.port_b = self.register_declared_port("port_b")
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
+240
View File
@@ -0,0 +1,240 @@
# 元件建模规范与示例
本文档是 `PythonModels/components` 下新增元件的最小开发规范。目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML 校验、求解器和前端分别维护同一份含义。
## 一、元件类必须声明的内容
每个元件类至少需要声明以下四个类属性:
```python
MODEL_TYPE = "example_component"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
```
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
元件构造函数还必须:
1. 调用 `super().__init__(name)`。
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
3. 使用 `register_declared_port()` 创建已声明端口。
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
## 二、输入参数与结果变量
输入参数和仿真结果必须分开声明:
- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。
- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。
- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。
- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。
参数定义示例:
```python
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
)
```
结果变量定义示例:
```python
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
)
```
## 三、命名和单位约定
- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。
- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。
- 无量纲参数的 `unit` 使用空字符串。
- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。
- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。
- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。
## 四、完整示例:单端口储气容腔
下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。
```python
from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortDefinition
from PythonModels.core.state import VolumeState
class ExampleVolume(ThermodynamicVolumeComponent):
MODEL_TYPE = "example_volume"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="p0",
label="初始压力",
quantity="pressure",
unit="Pa",
default=100000.0,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="T0",
label="初始温度",
quantity="temperature",
unit="K",
default=300.0,
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
medium: IdealGasMedium,
volume: float = 0.1,
p0: float = 100000.0,
T0: float = 300.0,
) -> None:
super().__init__(name)
self.set_parameter_values(
{"volume": volume, "p0": p0, "T0": T0}
)
self.medium = medium
self.V = volume
initial_mass = p0 * volume / (medium.R_gas * T0)
initial_energy = initial_mass * medium.specific_internal_energy(T0)
self.state = VolumeState(m=initial_mass, U=initial_energy)
self.port_a = self.register_declared_port("port_a")
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
properties = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
)
self.port_a.p = properties.p
self.port_a.h_outflow = properties.h
return properties
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.refresh_thermodynamic_ports()
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=self.port_a.m_flow,
connected_h=connected_h["port_a"],
internal_h=properties.h,
)
return [self.port_a.m_flow, self.port_a.m_flow * inlet_h]
def pressure_flow_equation_residuals(
self,
) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
).p
return (
EquationResidual(
id=f"{self.name}:port_a_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
role="effort",
value=self.port_a.p - pressure,
),
)
```
注册时只引用元件类已经声明的契约,不要再复制参数和端口定义:
```python
def _example_volume_factory(name, medium, values):
return ExampleVolume(
name=name,
medium=medium,
volume=values["volume"],
p0=values["p0"],
T0=values["T0"],
)
COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec(
model_type=ExampleVolume.MODEL_TYPE,
ports=ExampleVolume.PORTS,
parameters=ExampleVolume.PARAMETERS,
factory=_example_volume_factory,
)
```
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
```json
{
"key": "example_volume_1.port_a.m_flow",
"componentId": "example_volume_1",
"componentType": "example_volume",
"scope": "port",
"portName": "port_a",
"name": "m_flow",
"label": "质量流量",
"quantity": "mass_flow",
"unit": "kg/s",
"category": "flow",
"order": 20
}
```
## 五、新增元件检查清单
1. `MODEL_TYPE` 是否唯一,并与 XML、前端组件类型一致。
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
6. 是否只暴露有工程意义的结果,而非内部计算变量。
7. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。
+30 -8
View File
@@ -5,22 +5,44 @@ from math import sqrt
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.ports import PortState
from PythonModels.core.metadata import ParameterDefinition
from PythonModels.core.ports import PortDefinition
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None:
MODEL_TYPE = "orifice"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"K",
1e-5,
label="流量系数",
quantity="flow_coefficient",
unit="kg/(s*Pa^0.5)",
minimum=0.0,
),
ParameterDefinition(
"opening",
1.0,
label="开度",
minimum=0.0,
maximum=1.0,
),
)
RESULT_VARIABLES = ()
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
super().__init__(name=name)
self.set_parameter_values({"K": K, "opening": opening})
self.opening = opening
self.K = K
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@property
def K_eff(self) -> float:
+69 -9
View File
@@ -2,16 +2,71 @@ from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.ports import PortDefinition
from PythonModels.core.state import VolumeState
class Pipe(DynamicComponent):
class Pipe(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mypipe."""
MODEL_TYPE = "pipe"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
@@ -23,6 +78,15 @@ class Pipe(DynamicComponent):
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.L = L
self.D = D
@@ -32,12 +96,8 @@ class Pipe(DynamicComponent):
m0 = p0 * self.V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
self.port_b = self.register_port(
PortState.pneumatic("port_b", nominal_role="outlet")
)
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
+64 -5
View File
@@ -5,13 +5,65 @@ from math import pi
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import ParameterDefinition
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.ports import PortState
from PythonModels.core.ports import PortDefinition
class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = "pipe"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = ()
def __init__(
self,
name: str,
@@ -23,6 +75,15 @@ class ResistivePipe(AlgebraicComponent):
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.L = L
self.D = D
@@ -32,15 +93,13 @@ class ResistivePipe(AlgebraicComponent):
self.area = pi * D * D / 4.0
initial_h = medium.specific_enthalpy(T0)
self.port_a = PortState.pneumatic("port_a", nominal_role="inlet")
self.port_a = self.register_declared_port("port_a")
self.port_a.p = p0
self.port_a.h_outflow = initial_h
self.register_port(self.port_a)
self.port_b = PortState.pneumatic("port_b", nominal_role="outlet")
self.port_b = self.register_declared_port("port_b")
self.port_b.p = p0
self.port_b.h_outflow = initial_h
self.register_port(self.port_b)
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
average_pressure = max(0.5 * (p_a + p_b), 1.0)
+42 -6
View File
@@ -2,16 +2,53 @@ from __future__ import annotations
from collections.abc import Mapping
from PythonModels.core.base import DynamicComponent
from PythonModels.core.base import ThermodynamicVolumeComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.ports import PortDefinition
from PythonModels.core.state import VolumeState
class Tank(DynamicComponent):
class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank."""
MODEL_TYPE = "tank"
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.1,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
@@ -21,14 +58,13 @@ class Tank(DynamicComponent):
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_port(
PortState.pneumatic("port_a", nominal_role="inlet")
)
self.port_a = self.register_declared_port("port_a")
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
+14 -10
View File
@@ -4,23 +4,27 @@ from collections.abc import Mapping
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.ports import PortState
from PythonModels.core.ports import PortDefinition
class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee."""
MODEL_TYPE = "tee"
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.port_in = self.register_port(
PortState.pneumatic("port_in", nominal_role="bidirectional")
)
self.port_out1 = self.register_port(
PortState.pneumatic("port_out1", nominal_role="bidirectional")
)
self.port_out2 = self.register_port(
PortState.pneumatic("port_out2", nominal_role="bidirectional")
)
self.set_parameter_values({})
self.port_in = self.register_declared_port("port_in")
self.port_out1 = self.register_declared_port("port_out1")
self.port_out2 = self.register_declared_port("port_out2")
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
+158 -2
View File
@@ -2,17 +2,29 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import Any
from typing import Any, ClassVar
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
ResultVariableMetadata,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from PythonModels.core.ports import PortDefinition, PortState
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
def __init__(self, name: str) -> None:
self.name = name
self.model_type = self.__class__.__name__.lower()
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]:
@@ -35,12 +47,133 @@ class Component(ABC):
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
]
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Return algebraic residuals after the network assigns port states."""
@@ -97,5 +230,28 @@ class DynamicComponent(Component):
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."""
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Literal
ResultVariableScope = Literal["component", "port"]
@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,
),
)
+13
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from PythonModels.core.base import Component, DynamicComponent
from PythonModels.core.equations import EquationResidual
from PythonModels.core.metadata import ResultVariableMetadata
from PythonModels.core.ports import PortState
@@ -257,6 +258,13 @@ class SimulationNetwork:
if cursor != len(values):
raise ValueError("State vector length does not match dynamic components.")
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
return tuple(
variable
for component in self.components.values()
for variable in component.result_variable_metadata()
)
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
@@ -281,10 +289,15 @@ class SimulationNetwork:
{
"id": component.name,
"type": component.model_type,
"parameters": component.parameter_interface_dicts(),
"ports": [
definition.as_interface_dict()
for definition in component.port_definitions
],
"resultVariables": [
variable.as_dict()
for variable in component.result_variable_metadata()
],
}
for component in self.components.values()
],
+38 -4
View File
@@ -16,12 +16,22 @@ 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, str]:
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,
}
@@ -50,9 +60,33 @@ class PortDefinition:
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
variables=(
PortVariableDefinition("p", "effort", "equal"),
PortVariableDefinition("m_flow", "flow", "sumToZero"),
PortVariableDefinition("h_outflow", "stream", "streamMix"),
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,
),
),
)
+224 -19
View File
@@ -1,7 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from typing import Callable, Literal
CancellationCheck = Callable[[], bool]
AcceptedStepCallback = Callable[[float], None]
IntegrationStatus = Literal["completed", "cancelled", "failed"]
class _IntegrationCancelled(Exception):
pass
@dataclass(frozen=True)
@@ -20,17 +29,34 @@ class ODESolution:
y: list[list[float]]
success: bool
message: str
status: IntegrationStatus = "completed"
error: Exception | None = None
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
return [x + scale * y for x, y in zip(a, b)]
def _append_solution_sample(
times: list[float],
states: list[list[float]],
time: float,
state: list[float],
) -> None:
if times and time <= times[-1] + 1e-12:
return
times.append(float(time))
for index, value in enumerate(state):
states[index].append(float(value))
def _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
) -> ODESolution:
if t_eval is None:
point_count = max(
@@ -44,29 +70,189 @@ def _runge_kutta_4(
states = [[value] for value in state]
times = [float(t_eval[0])]
current_time = float(t_eval[0])
status: IntegrationStatus = "completed"
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
error: Exception | None = None
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
try:
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
if accepted_step_callback is not None:
accepted_step_callback(current_time)
times.append(float(target_time))
for index, value in enumerate(state):
states[index].append(value)
_append_solution_sample(times, states, target_time, state)
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
_append_solution_sample(times, states, current_time, state)
return ODESolution(
t=times,
y=states,
success=True,
message="Integrated with built-in RK4 fallback because SciPy is unavailable.",
success=status == "completed",
message=message,
status=status,
error=error,
)
def _integrate_scipy_stepwise(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck,
accepted_step_callback: AcceptedStepCallback | None,
) -> ODESolution:
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
solver_types = {
"BDF": BDF,
"DOP853": DOP853,
"LSODA": LSODA,
"RK23": RK23,
"RK45": RK45,
"Radau": Radau,
}
solver_type = solver_types.get(config.method)
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
last_accepted_time = float(config.t_start)
last_accepted_state = [float(value) for value in initial_state]
sample_times = list(t_eval or [])
sample_index = 0
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= config.t_start + 1e-12
):
sample_index += 1
def cancellable_rhs(time, state):
if cancel_check():
raise _IntegrationCancelled
return rhs(float(time), [float(value) for value in state])
if cancel_check():
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
)
except _IntegrationCancelled:
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
except Exception as exc:
return ODESolution(
t=times,
y=states,
success=False,
message=str(exc),
status="failed",
error=exc,
)
status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval."
error: Exception | None = None
while solver.status == "running":
if cancel_check():
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
try:
step_message = solver.step()
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
if solver.status == "failed":
status = "failed"
message = str(step_message or "Integration step failed.")
break
last_accepted_time = float(solver.t)
last_accepted_state = [float(value) for value in solver.y]
if sample_times:
dense_output = solver.dense_output()
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time + 1e-12
):
sample_time = float(sample_times[sample_index])
sample_state = [float(value) for value in dense_output(sample_time)]
_append_solution_sample(times, states, sample_time, sample_state)
sample_index += 1
else:
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
if accepted_step_callback is not None:
accepted_step_callback(last_accepted_time)
if status != "completed":
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
@@ -75,6 +261,8 @@ def integrate_ode(
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None = None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
):
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
@@ -89,7 +277,24 @@ def integrate_ode(
try:
from scipy.integrate import solve_ivp
except ImportError:
return _runge_kutta_4(rhs, initial_state, config, t_eval)
return _runge_kutta_4(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
if cancel_check is not None:
return _integrate_scipy_stepwise(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
return solve_ivp(
fun=rhs,
+28 -64
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from math import isfinite
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
@@ -10,29 +9,12 @@ from PythonModels.components.resistive_pipe import ResistivePipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.base import Component
from PythonModels.core.metadata import ParameterDefinition
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.ports import PortDefinition
@dataclass(frozen=True)
class ParameterSpec:
name: str
default: float
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
ParameterSpec = ParameterDefinition
ComponentFactory = Callable[
@@ -45,11 +27,11 @@ ComponentFactory = Callable[
class ComponentModelSpec:
model_type: str
ports: tuple[PortDefinition, ...]
parameters: tuple[ParameterSpec, ...]
parameters: tuple[ParameterDefinition, ...]
factory: ComponentFactory
@property
def parameter_by_name(self) -> dict[str, ParameterSpec]:
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
return {parameter.name: parameter for parameter in self.parameters}
def create(
@@ -77,6 +59,15 @@ class ComponentModelSpec:
)
component = self.factory(name, medium, resolved)
component.model_type = self.model_type
if component.port_definitions != self.ports:
raise ValueError(
f"Component implementation {self.model_type} does not match "
"its declared ports."
)
if component.parameter_values != resolved:
raise ValueError(
f"Component implementation {self.model_type} did not preserve its parameters."
)
return component
@@ -142,60 +133,33 @@ def _tee_factory(
COMPONENT_MODEL_REGISTRY: dict[str, ComponentModelSpec] = {
"cylinder": ComponentModelSpec(
model_type="cylinder",
ports=(PortDefinition.pneumatic("port_b", nominal_role="outlet"),),
parameters=(
ParameterSpec("volume", 0.01, minimum=0.0, minimum_exclusive=True),
ParameterSpec("p0", 35e6, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
model_type=Cylinder.MODEL_TYPE,
ports=Cylinder.PORTS,
parameters=Cylinder.PARAMETERS,
factory=_cylinder_factory,
),
"tank": ComponentModelSpec(
model_type="tank",
ports=(PortDefinition.pneumatic("port_a", nominal_role="inlet"),),
parameters=(
ParameterSpec("volume", 0.1, minimum=0.0, minimum_exclusive=True),
ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
model_type=Tank.MODEL_TYPE,
ports=Tank.PORTS,
parameters=Tank.PARAMETERS,
factory=_tank_factory,
),
"pipe": ComponentModelSpec(
model_type="pipe",
ports=(
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
),
parameters=(
ParameterSpec("length", 5.0, minimum=0.0, minimum_exclusive=True),
ParameterSpec("diameter", 0.02, minimum=0.0, minimum_exclusive=True),
ParameterSpec("lambda_darcy", 0.02, minimum=0.0),
ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True),
ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True),
),
model_type=ResistivePipe.MODEL_TYPE,
ports=ResistivePipe.PORTS,
parameters=ResistivePipe.PARAMETERS,
factory=_pipe_factory,
),
"orifice": ComponentModelSpec(
model_type="orifice",
ports=(
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
),
parameters=(
ParameterSpec("K", 1e-5, minimum=0.0),
ParameterSpec("opening", 1.0, minimum=0.0, maximum=1.0),
),
model_type=Orifice.MODEL_TYPE,
ports=Orifice.PORTS,
parameters=Orifice.PARAMETERS,
factory=_orifice_factory,
),
"tee": ComponentModelSpec(
model_type="tee",
ports=(
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
),
parameters=(),
model_type=Tee.MODEL_TYPE,
ports=Tee.PORTS,
parameters=Tee.PARAMETERS,
factory=_tee_factory,
),
}
+133 -28
View File
@@ -1,15 +1,23 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from math import floor, isfinite
from typing import Literal
from PythonModels.core.algebraic import PressureFlowSolver
from PythonModels.core.base import DynamicComponent
from PythonModels.core.metadata import ResultVariableMetadata
from PythonModels.core.network import Endpoint, SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.core.solver import ODESolution, SolveIVPConfig, integrate_ode
from PythonModels.core.stream import StreamResolver
SimulationProgressCallback = Callable[[float, str], None]
SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@dataclass(frozen=True)
class SimulationPreparationIssue:
code: str
@@ -28,7 +36,11 @@ class SimulationPreparationError(ValueError):
@dataclass(frozen=True)
class GenericSimulationResult:
success: bool
status: SimulationRunStatus
message: str
simulated_until: float
requested_stop_time: float
variables: tuple[ResultVariableMetadata, ...]
series: dict[str, list[float]]
final: dict[str, float]
diagnostics: dict[str, object]
@@ -36,7 +48,12 @@ class GenericSimulationResult:
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"status": self.status,
"partial": self.status != "completed",
"message": self.message,
"simulatedUntil": self.simulated_until,
"requestedStopTime": self.requested_stop_time,
"variables": [variable.as_dict() for variable in self.variables],
"series": self.series,
"final": self.final,
"diagnostics": self.diagnostics,
@@ -276,51 +293,121 @@ class GenericFluidSystem:
return derivatives
def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.dynamic_components:
properties = component.refresh_thermodynamic_ports()
state = component.get_state_vector()
if len(state) >= 2:
series.setdefault(f"{component.name}.m", []).append(float(state[0]))
series.setdefault(f"{component.name}.U", []).append(float(state[1]))
for name in ("p", "T", "rho", "u", "h"):
if hasattr(properties, name):
series.setdefault(f"{component.name}.{name}", []).append(
float(getattr(properties, name))
)
for component in self.network.components.values():
for port_name, port in component.ports.items():
prefix = f"{component.name}.{port_name}"
series.setdefault(f"{prefix}.p", []).append(float(port.p))
series.setdefault(f"{prefix}.m_flow", []).append(float(port.m_flow))
series.setdefault(f"{prefix}.h_outflow", []).append(
float(port.h_outflow)
)
for relative_key, value in component.result_values().items():
series.setdefault(
f"{component.name}.{relative_key}", []
).append(value)
def simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
) -> GenericSimulationResult:
last_reported_progress = -1.0
last_reported_phase = ""
def report_progress(
progress: float,
phase: str,
*,
force: bool = False,
) -> None:
nonlocal last_reported_phase, last_reported_progress
if progress_callback is None:
return
bounded_progress = min(1.0, max(0.0, progress))
if (
force
or phase != last_reported_phase
or bounded_progress - last_reported_progress >= 0.0025
):
last_reported_phase = phase
last_reported_progress = max(
last_reported_progress,
bounded_progress,
)
progress_callback(last_reported_progress, phase)
report_progress(0.0, "initializing", force=True)
t_eval = simulation_sample_times(config, sample_step)
initial_state = self.consistent_initial_state_vector()
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start
def report_solver_time(time: float) -> None:
nonlocal furthest_solver_time
furthest_solver_time = max(furthest_solver_time, float(time))
time_fraction = (
(furthest_solver_time - config.t_start) / duration
if duration > 0.0
else 1.0
)
report_progress(time_fraction, "integrating")
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
if cancel_check is None:
report_solver_time(time)
return self.rhs(time, state_vector)
solution = integrate_ode(
rhs=self.rhs,
rhs=monitored_rhs,
initial_state=initial_state,
config=config,
t_eval=t_eval,
cancel_check=cancel_check,
accepted_step_callback=(
report_solver_time if cancel_check is not None else None
),
)
if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status
integration_error = solution.error
else:
run_status = "completed" if bool(solution.success) else "failed"
integration_error = None
result_message = str(solution.message)
postprocess_progress = (
1.0
if run_status == "completed"
else max(0.0, last_reported_progress)
)
report_progress(postprocess_progress, "postprocessing", force=True)
times = [float(value) for value in solution.t]
series: dict[str, list[float]] = {"time": times}
series: dict[str, list[float]] = {"time": []}
postprocessing_error: Exception | None = None
for time_index in range(len(times)):
if (
run_status == "completed"
and cancel_check is not None
and cancel_check()
):
run_status = "cancelled"
result_message = "Simulation was stopped while preparing partial results."
break
state = [
float(solution.y[state_index][time_index])
for state_index in range(len(solution.y))
]
self.apply_state_vector(state)
self._close_current_state()
self._append_current_state(series)
try:
self.apply_state_vector(state)
self._close_current_state()
self._append_current_state(series)
series["time"].append(times[time_index])
except Exception as exc:
run_status = "failed"
result_message = str(exc)
postprocessing_error = exc
break
if len(series["time"]) < 2:
if postprocessing_error is not None:
raise postprocessing_error
if integration_error is not None:
raise integration_error
final = {
key: values[-1]
@@ -347,11 +434,29 @@ class GenericFluidSystem:
),
},
"stateCount": len(initial_state),
"sampleCount": len(times),
"sampleCount": len(series["time"]),
}
variables = tuple(
variable
for variable in self.network.result_variable_metadata()
if variable.key in series
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
force=True,
)
return GenericSimulationResult(
success=bool(solution.success),
message=str(solution.message),
success=run_status == "completed" and bool(solution.success),
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
),
requested_stop_time=float(config.t_stop),
variables=variables,
series=series,
final=final,
diagnostics=diagnostics,
+1
View File
@@ -11,6 +11,7 @@ ReactFlow 系统建模与 PythonModels 仿真应用。
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 PythonModels 组件网络。
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
+485 -2
View File
@@ -1,13 +1,24 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
import io
import json
from math import isfinite
from pathlib import Path
import queue
import re
import threading
from time import monotonic
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import quote
from uuid import uuid4
from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.system_xml import (
@@ -27,6 +38,47 @@ PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
SYSTEM_XML_SCHEMA_VERSION = "2"
SYSTEM_XML_UNIT_SYSTEM = "SI"
SimulationProgressEmitter = Callable[
[int, str, str, float | None, float | None],
None,
]
SIMULATION_PHASE_MESSAGES = {
"initializing": "正在初始化状态与方程",
"integrating": "正在进行时间积分与压力流量求解",
"postprocessing": "正在整理采样结果",
"cancelled": "正在整理已终止仿真的部分结果",
"failed": "正在整理异常终止前的部分结果",
"complete": "正在汇总仿真结果",
}
SIMULATION_STREAM_HEARTBEAT_SECONDS = 5.0
SIMULATION_TASK_RETENTION_SECONDS = 600.0
SimulationCancelReason = Literal["user", "stalled"]
SimulationTaskStatus = Literal[
"queued",
"running",
"cancelling",
"completed",
"stopped",
"stalled",
"failed",
]
@dataclass
class SimulationTaskRecord:
simulation_id: str
cancel_event: threading.Event
status: SimulationTaskStatus = "queued"
cancel_reason: SimulationCancelReason | None = None
result: dict[str, object] | None = None
error: dict[str, object] | None = None
updated_at: float = 0.0
SIMULATION_TASKS: dict[str, SimulationTaskRecord] = {}
SIMULATION_TASKS_LOCK = threading.Lock()
class ReactFlowPosition(BaseModel):
x: float = 0.0
@@ -88,6 +140,30 @@ class ReactFlowProjectPayload(BaseModel):
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
class SimulationResultVariablePayload(BaseModel):
key: str
componentId: str
componentType: str
scope: Literal["component", "port"]
portName: str | None = None
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
class SimulationResultCsvPayload(BaseModel):
projectName: str = "simulation"
variables: list[SimulationResultVariablePayload] = Field(default_factory=list)
series: dict[str, list[float]] = Field(default_factory=dict)
class SimulationCancellationPayload(BaseModel):
reason: SimulationCancelReason = "user"
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump(mode="json")
@@ -164,6 +240,92 @@ def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
return Response(content=xml, media_type="application/xml")
@app.post("/api/simulation-results/csv")
def export_simulation_results_csv(
payload: SimulationResultCsvPayload,
) -> Response:
csv_text = build_simulation_results_csv(payload)
filename = simulation_results_csv_filename(payload.projectName)
ascii_filename = re.sub(r"[^A-Za-z0-9._-]+", "_", filename).strip("._")
if not ascii_filename:
ascii_filename = "simulation-results.csv"
disposition = (
f'attachment; filename="{ascii_filename}"; '
f"filename*=UTF-8''{quote(filename)}"
)
return Response(
content="\ufeff" + csv_text,
media_type="text/csv",
headers={"Content-Disposition": disposition},
)
def build_simulation_results_csv(payload: SimulationResultCsvPayload) -> str:
times = payload.series.get("time")
if not times:
raise HTTPException(
status_code=422,
detail="Simulation results must contain a non-empty time series.",
)
variable_keys = [variable.key for variable in payload.variables]
if not variable_keys:
raise HTTPException(
status_code=422,
detail="Simulation results do not contain exportable variables.",
)
if len(variable_keys) != len(set(variable_keys)):
raise HTTPException(
status_code=422,
detail="Simulation result metadata contains duplicate variable keys.",
)
expected_keys = {"time", *variable_keys}
missing_keys = sorted(expected_keys - set(payload.series))
unknown_keys = sorted(set(payload.series) - expected_keys)
if missing_keys or unknown_keys:
details = []
if missing_keys:
details.append("missing " + ", ".join(missing_keys))
if unknown_keys:
details.append("unmapped " + ", ".join(unknown_keys))
raise HTTPException(
status_code=422,
detail="Simulation result columns do not match metadata: "
+ "; ".join(details)
+ ".",
)
row_count = len(times)
for key in ("time", *variable_keys):
values = payload.series[key]
if len(values) != row_count:
raise HTTPException(
status_code=422,
detail=f"Simulation result column '{key}' has an inconsistent length.",
)
if not all(isfinite(value) for value in values):
raise HTTPException(
status_code=422,
detail=f"Simulation result column '{key}' contains non-finite values.",
)
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\r\n")
writer.writerow(["time", *variable_keys])
for index in range(row_count):
writer.writerow(
[payload.series["time"][index]]
+ [payload.series[key][index] for key in variable_keys]
)
return output.getvalue()
def simulation_results_csv_filename(project_name: str) -> str:
stem = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", project_name).strip(" .")
return f"{stem[:80] or 'simulation'}-results.csv"
@app.get("/api/reactflow/projects")
def list_reactflow_projects() -> dict[str, object]:
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
@@ -258,8 +420,158 @@ async def compile_system_xml_model(request: Request) -> dict[str, object]:
}
TERMINAL_SIMULATION_TASK_STATUSES = {"completed", "stopped", "stalled", "failed"}
def _register_simulation_task(simulation_id: str) -> SimulationTaskRecord:
normalized_id = simulation_id.strip()
if (
not normalized_id
or len(normalized_id) > 128
or re.fullmatch(r"[A-Za-z0-9._-]+", normalized_id) is None
):
raise HTTPException(status_code=422, detail="Invalid simulation task id.")
now = monotonic()
with SIMULATION_TASKS_LOCK:
expired_ids = [
task_id
for task_id, task in SIMULATION_TASKS.items()
if task.status in TERMINAL_SIMULATION_TASK_STATUSES
and now - task.updated_at > SIMULATION_TASK_RETENTION_SECONDS
]
for task_id in expired_ids:
del SIMULATION_TASKS[task_id]
if normalized_id in SIMULATION_TASKS:
raise HTTPException(
status_code=409,
detail="A simulation task with this id already exists.",
)
task = SimulationTaskRecord(
simulation_id=normalized_id,
cancel_event=threading.Event(),
updated_at=now,
)
SIMULATION_TASKS[normalized_id] = task
return task
def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]:
with SIMULATION_TASKS_LOCK:
return {
"simulationId": task.simulation_id,
"status": task.status,
"cancelReason": task.cancel_reason,
"result": task.result,
"error": task.error,
}
def _request_simulation_task_cancel(
task: SimulationTaskRecord,
reason: SimulationCancelReason,
) -> bool:
with SIMULATION_TASKS_LOCK:
if task.status in TERMINAL_SIMULATION_TASK_STATUSES:
return False
if task.cancel_reason is None:
task.cancel_reason = reason
task.status = "cancelling"
task.updated_at = monotonic()
task.cancel_event.set()
return True
def _mark_simulation_task_running(task: SimulationTaskRecord) -> None:
with SIMULATION_TASKS_LOCK:
if task.status == "queued":
task.status = "running"
task.updated_at = monotonic()
def _mark_simulation_task_result(
task: SimulationTaskRecord,
result: dict[str, object],
) -> dict[str, object]:
with SIMULATION_TASKS_LOCK:
result_status = result.get("status")
if result_status == "cancelled":
public_status: SimulationTaskStatus = (
"stalled" if task.cancel_reason == "stalled" else "stopped"
)
elif result_status == "completed" and bool(result.get("success")):
public_status = "completed"
else:
public_status = "failed"
result["status"] = public_status
result["partial"] = public_status != "completed"
task.status = public_status
task.result = result
task.updated_at = monotonic()
return result
def _mark_simulation_task_error(
task: SimulationTaskRecord,
error: dict[str, object],
) -> None:
with SIMULATION_TASKS_LOCK:
task.status = "failed"
task.error = error
task.updated_at = monotonic()
@app.post("/api/system-xml/simulate")
async def simulate_system_xml(request: Request) -> dict[str, object]:
return run_system_xml_simulation(await request.body())
@app.post("/api/system-xml/simulate-stream")
async def simulate_system_xml_stream(request: Request) -> StreamingResponse:
simulation_id = request.headers.get("x-simulation-id") or uuid4().hex
task = _register_simulation_task(simulation_id)
return StreamingResponse(
simulation_event_stream(await request.body(), task=task),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Simulation-Id": task.simulation_id,
},
)
@app.post("/api/system-xml/simulations/{simulation_id}/cancel")
def cancel_system_xml_simulation(
simulation_id: str,
payload: SimulationCancellationPayload,
) -> dict[str, object]:
with SIMULATION_TASKS_LOCK:
task = SIMULATION_TASKS.get(simulation_id)
if task is None:
raise HTTPException(status_code=404, detail="Simulation task was not found.")
accepted = _request_simulation_task_cancel(task, payload.reason)
return {
"simulationId": simulation_id,
"accepted": accepted,
"status": task.status,
}
@app.get("/api/system-xml/simulations/{simulation_id}")
def get_system_xml_simulation(simulation_id: str) -> dict[str, object]:
with SIMULATION_TASKS_LOCK:
task = SIMULATION_TASKS.get(simulation_id)
if task is None:
raise HTTPException(status_code=404, detail="Simulation task was not found.")
return _simulation_task_snapshot(task)
def run_system_xml_simulation(
xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
) -> dict[str, object]:
from PythonModels.core.algebraic import AlgebraicSolveError
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.core.stream import StreamSolveError
@@ -268,9 +580,42 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
SimulationPreparationError,
)
report = validate_system_xml_document(await request.body())
def emit(
progress: int,
phase: str,
message: str,
simulated_time: float | None = None,
total_time: float | None = None,
) -> None:
if progress_callback is not None:
progress_callback(
progress,
phase,
message,
simulated_time,
total_time,
)
emit(0, "validation", "正在校验 System XML")
report = validate_system_xml_document(xml_bytes)
document = _validated_xml_document_or_422(report)
emit(0, "compilation", "正在编译组件与连接关系")
project, network = _compile_xml_document_or_422(document)
emit(0, "initialization", "模型编译完成,正在准备求解器")
def report_system_progress(progress: float, phase: str) -> None:
bounded_progress = min(1.0, max(0.0, progress))
simulated_time = project.simulation.t_start + bounded_progress * (
project.simulation.t_stop - project.simulation.t_start
)
emit(
round(100 * bounded_progress),
phase,
SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"),
simulated_time,
project.simulation.t_stop,
)
try:
system = GenericFluidSystem(network)
result = system.simulate(
@@ -281,6 +626,8 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
max_step=project.simulation.max_step,
),
sample_step=project.simulation.step,
progress_callback=report_system_progress,
cancel_check=cancel_check,
)
except SimulationPreparationError as exc:
raise HTTPException(
@@ -353,6 +700,142 @@ async def simulate_system_xml(request: Request) -> dict[str, object]:
}
def simulation_event_stream(
xml_bytes: bytes,
*,
task: SimulationTaskRecord | None = None,
) -> Iterator[str]:
events: queue.Queue[dict[str, object] | object] = queue.Queue()
finished = object()
latest_progress = 0
latest_phase = "queued"
latest_message = "正在等待仿真任务启动"
latest_simulated_time: float | None = None
latest_total_time: float | None = None
def emit_progress(
progress: int,
phase: str,
message: str,
simulated_time: float | None = None,
total_time: float | None = None,
) -> None:
nonlocal latest_message, latest_phase, latest_progress
nonlocal latest_simulated_time, latest_total_time
latest_progress = max(latest_progress, min(100, max(0, progress)))
latest_phase = phase
latest_message = message
if simulated_time is not None and isfinite(simulated_time):
latest_simulated_time = simulated_time
if total_time is not None and isfinite(total_time):
latest_total_time = total_time
event: dict[str, object] = {
"event": "progress",
"progress": latest_progress,
"phase": phase,
"message": message,
}
if latest_simulated_time is not None:
event["simulatedTime"] = latest_simulated_time
if latest_total_time is not None:
event["totalTime"] = latest_total_time
events.put(event)
def worker() -> None:
if task is not None:
_mark_simulation_task_running(task)
try:
result = run_system_xml_simulation(
xml_bytes,
emit_progress,
task.cancel_event.is_set if task is not None else None,
)
if task is not None:
result = _mark_simulation_task_result(task, result)
result_status = str(result.get("status", "completed"))
result_messages = {
"completed": "仿真完成",
"stopped": "仿真已由用户终止,已保留部分结果",
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
"failed": "仿真异常终止,已保留可用的部分结果",
}
events.put(
{
"event": "result",
"progress": 100 if result_status == "completed" else latest_progress,
"phase": result_status,
"message": result_messages.get(result_status, "仿真任务结束"),
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
)
except HTTPException as exc:
detail = exc.detail
message = (
str(detail.get("message", "仿真失败"))
if isinstance(detail, dict)
else str(detail)
)
error_event = {
"event": "error",
"progress": latest_progress,
"phase": "failed",
"status": exc.status_code,
"message": message,
"detail": detail,
}
if task is not None:
_mark_simulation_task_error(task, error_event)
events.put(error_event)
except Exception as exc: # pragma: no cover - last-resort stream guard
error_event = {
"event": "error",
"progress": latest_progress,
"phase": "failed",
"status": 500,
"message": "仿真服务发生未预期错误。",
"detail": str(exc),
}
if task is not None:
_mark_simulation_task_error(task, error_event)
events.put(error_event)
finally:
events.put(finished)
threading.Thread(
target=worker,
name="system-simulation",
daemon=True,
).start()
try:
while True:
try:
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
except queue.Empty:
yield json.dumps(
{
"event": "progress",
"progress": latest_progress,
"phase": latest_phase,
"message": latest_message,
"heartbeat": True,
"simulatedTime": latest_simulated_time,
"totalTime": latest_total_time,
},
ensure_ascii=False,
separators=(",", ":"),
) + "\n"
continue
if event is finished:
break
yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
finally:
if task is not None:
_request_simulation_task_cancel(task, "stalled")
def _validated_xml_document_or_422(
report: SystemXmlValidationReport,
) -> SystemXmlDocument:
+50
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
@@ -854,12 +855,30 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1015,6 +1034,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1436,6 +1468,15 @@
"node": ">=0.10.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1505,6 +1546,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
+1932 -104
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -3,12 +3,14 @@ export type WorkspaceView = "modeling" | "results";
type WorkspaceViewTabsProps = {
activeView: WorkspaceView;
hasResults: boolean;
hasUnreadResults?: boolean;
onViewChange: (view: WorkspaceView) => void;
};
export function WorkspaceViewTabs({
activeView,
hasResults,
hasUnreadResults = false,
onViewChange,
}: WorkspaceViewTabsProps) {
return (
@@ -23,14 +25,18 @@ export function WorkspaceViewTabs({
建模
</button>
<button
aria-label={hasUnreadResults ? "结果,有新的仿真结果" : "结果"}
aria-selected={activeView === "results"}
className={activeView === "results" ? "active" : ""}
onClick={() => onViewChange("results")}
role="tab"
title={hasUnreadResults ? "有新的仿真结果" : undefined}
type="button"
>
结果
{hasResults ? <span aria-label="已有仿真结果" className="result-available-dot" /> : null}
{hasResults && hasUnreadResults ? (
<span aria-hidden="true" className="result-available-dot" />
) : null}
</button>
</div>
);
+1161 -48
View File
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import unittest
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.pipe import Pipe
from PythonModels.components.resistive_pipe import ResistivePipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.ports import PortVariableDefinition
from PythonModels.registry import COMPONENT_MODEL_REGISTRY
class ComponentMetadataTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
def component_instances(self):
return (
Cylinder("cylinder_1", self.medium),
Tank("tank_1", self.medium),
Pipe("pipe_dynamic_1", self.medium),
ResistivePipe("pipe_1", self.medium),
Orifice("orifice_1"),
Tee("tee_1"),
)
def test_every_component_declares_a_complete_model_contract(self) -> None:
for component in self.component_instances():
with self.subTest(component=component.name):
for attribute in (
"MODEL_TYPE",
"PORTS",
"PARAMETERS",
"RESULT_VARIABLES",
):
self.assertIn(attribute, type(component).__dict__)
self.assertTrue(component.MODEL_TYPE)
self.assertEqual(component.model_type, component.MODEL_TYPE)
self.assertEqual(component.port_definitions, component.PORTS)
self.assertEqual(
set(component.parameter_values),
{definition.name for definition in component.PARAMETERS},
)
self.assertTrue(
all(definition.label for definition in component.PARAMETERS)
)
self.assertTrue(
all(definition.quantity for definition in component.PARAMETERS)
)
def test_registry_reuses_component_parameter_and_port_declarations(self) -> None:
registered_classes = {
"cylinder": Cylinder,
"tank": Tank,
"pipe": ResistivePipe,
"orifice": Orifice,
"tee": Tee,
}
for model_type, component_class in registered_classes.items():
with self.subTest(model_type=model_type):
spec = COMPONENT_MODEL_REGISTRY[model_type]
self.assertEqual(spec.model_type, component_class.MODEL_TYPE)
self.assertEqual(spec.ports, component_class.PORTS)
self.assertEqual(spec.parameters, component_class.PARAMETERS)
component = spec.create(f"{model_type}_1", self.medium, {})
self.assertEqual(
component.parameter_values,
{
definition.name: definition.default
for definition in component_class.PARAMETERS
},
)
def test_result_metadata_exactly_describes_each_exposed_value(self) -> None:
for component in self.component_instances():
with self.subTest(component=component.name):
values = component.result_values()
metadata = component.result_variable_metadata()
keys = [variable.key for variable in metadata]
self.assertEqual(len(keys), len(set(keys)))
self.assertEqual(
set(values),
{
variable.key.removeprefix(f"{component.name}.")
for variable in metadata
},
)
self.assertTrue(all(variable.label for variable in metadata))
self.assertTrue(all(variable.quantity for variable in metadata))
def test_storage_and_port_variables_include_display_metadata(self) -> None:
cylinder = Cylinder("cylinder_1", self.medium)
metadata = {
variable.key: variable
for variable in cylinder.result_variable_metadata()
}
pressure = metadata["cylinder_1.p"]
self.assertEqual(pressure.scope, "component")
self.assertEqual(pressure.label, "压力")
self.assertEqual(pressure.quantity, "pressure")
self.assertEqual(pressure.unit, "Pa")
mass_flow = metadata["cylinder_1.port_b.m_flow"]
self.assertEqual(mass_flow.scope, "port")
self.assertEqual(mass_flow.port_name, "port_b")
self.assertEqual(mass_flow.label, "质量流量")
self.assertEqual(mass_flow.quantity, "mass_flow")
self.assertEqual(mass_flow.unit, "kg/s")
def test_port_display_metadata_is_not_part_of_the_physical_contract(self) -> None:
first = PortVariableDefinition(
"p",
"effort",
"equal",
label="压力",
quantity="pressure",
unit="Pa",
)
second = PortVariableDefinition(
"p",
"effort",
"equal",
label="Pressure",
quantity="absolute_pressure",
unit="kPa",
)
self.assertEqual(first, second)
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -1,15 +1,26 @@
from __future__ import annotations
import asyncio
import json
import threading
import time
import unittest
from unittest.mock import patch
from uuid import uuid4
from fastapi import HTTPException, Request
from app.main import (
ReactFlowEdgePayload,
ReactFlowProjectPayload,
SimulationCancellationPayload,
_mark_simulation_task_result,
_register_simulation_task,
build_reactflow_system_xml,
cancel_system_xml_simulation,
compile_reactflow_network,
get_system_xml_simulation,
simulation_event_stream,
simulate_system_xml,
)
from PythonModels.components.resistive_pipe import ResistivePipe
@@ -242,6 +253,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
network = compile_reactflow_network(chain_project())
progress: list[tuple[float, str]] = []
result = GenericFluidSystem(network).simulate(
SolveIVPConfig(
t_start=0.0,
@@ -250,9 +262,20 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
max_step=0.001,
),
sample_step=0.005,
progress_callback=lambda value, phase: progress.append((value, phase)),
)
self.assertTrue(result.success)
self.assertEqual(progress[0], (0.0, "initializing"))
self.assertEqual(progress[-1], (1.0, "complete"))
self.assertTrue(
all(
current[0] <= following[0]
for current, following in zip(progress, progress[1:])
)
)
self.assertIn("integrating", {phase for _, phase in progress})
self.assertIn("postprocessing", {phase for _, phase in progress})
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
total_mass = [
@@ -276,6 +299,58 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
1e-7,
)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
cancel_event = threading.Event()
def request_cancel_after_progress(progress: float, phase: str) -> None:
if phase == "integrating" and progress >= 0.2:
cancel_event.set()
result = GenericFluidSystem(
compile_reactflow_network(chain_project())
).simulate(
SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001),
sample_step=0.005,
progress_callback=request_cancel_after_progress,
cancel_check=cancel_event.is_set,
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertGreaterEqual(result.diagnostics["sampleCount"], 2)
self.assertGreater(result.simulated_until, 0.0)
self.assertLess(result.simulated_until, 0.05)
self.assertEqual(
len(result.series["time"]),
len(result.series["cylinder_1.p"]),
)
def test_task_registry_distinguishes_user_stop_and_stalled_stop(self) -> None:
for reason, expected_status in (("user", "stopped"), ("stalled", "stalled")):
simulation_id = f"test-{uuid4().hex}"
task = _register_simulation_task(simulation_id)
response = cancel_system_xml_simulation(
simulation_id,
SimulationCancellationPayload(reason=reason),
)
self.assertTrue(response["accepted"])
result = _mark_simulation_task_result(
task,
{
"success": False,
"status": "cancelled",
"partial": True,
"series": {"time": [0.0, 0.1]},
},
)
snapshot = get_system_xml_simulation(simulation_id)
self.assertEqual(result["status"], expected_status)
self.assertEqual(snapshot["status"], expected_status)
self.assertEqual(snapshot["cancelReason"], reason)
self.assertEqual(snapshot["result"]["series"]["time"], [0.0, 0.1])
def test_physical_edge_order_does_not_change_simulation(self) -> None:
forward = GenericFluidSystem(
compile_reactflow_network(chain_project())
@@ -344,6 +419,129 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(response["model"]["pressureFlowSystem"]["unknownCount"], 12)
self.assertEqual(response["diagnostics"]["stateCount"], 4)
self.assertGreater(response["final"]["tank_1.p"], 100000.0)
variables = {variable["key"]: variable for variable in response["variables"]}
self.assertEqual(set(variables), set(response["series"]) - {"time"})
self.assertEqual(variables["cylinder_1.p"]["componentId"], "cylinder_1")
self.assertEqual(variables["cylinder_1.p"]["label"], "压力")
self.assertEqual(variables["cylinder_1.p"]["unit"], "Pa")
self.assertEqual(variables["pipe_1.port_a.m_flow"]["scope"], "port")
self.assertEqual(
variables["pipe_1.port_a.m_flow"]["portName"],
"port_a",
)
def test_streaming_endpoint_events_have_monotonic_progress_and_result(self) -> None:
project = chain_project()
xml = build_reactflow_system_xml(project)
events = [json.loads(line) for line in simulation_event_stream(xml)]
progress_events = [
event for event in events if event["event"] == "progress"
]
progress = [event["progress"] for event in progress_events]
self.assertGreater(len(progress), 3)
self.assertTrue(
all(current <= following for current, following in zip(progress, progress[1:]))
)
preparation_events = [
event
for event in progress_events
if event["phase"] in {"validation", "compilation", "initialization"}
]
self.assertTrue(preparation_events)
self.assertTrue(all(event["progress"] == 0 for event in preparation_events))
timed_events = [
event
for event in progress_events
if event.get("simulatedTime") is not None
]
self.assertGreater(len(timed_events), 1)
for event in timed_events:
self.assertEqual(event["totalTime"], project.simulation.t_stop)
expected_progress = round(
100
* (event["simulatedTime"] - project.simulation.t_start)
/ (project.simulation.t_stop - project.simulation.t_start)
)
self.assertEqual(event["progress"], expected_progress)
self.assertEqual(events[-1]["event"], "result")
self.assertEqual(events[-1]["progress"], 100)
self.assertTrue(events[-1]["result"]["success"])
def test_streaming_task_stop_returns_partial_result(self) -> None:
project = branched_project()
project.simulation.t_stop = 2.0
project.simulation.step = 0.02
project.simulation.max_step = 0.01
xml = build_reactflow_system_xml(project)
simulation_id = f"test-{uuid4().hex}"
task = _register_simulation_task(simulation_id)
events: list[dict[str, object]] = []
stop_requested = False
for line in simulation_event_stream(xml, task=task):
event = json.loads(line)
events.append(event)
if (
not stop_requested
and event["event"] == "progress"
and event["phase"] == "integrating"
and event["progress"] >= 20
):
response = cancel_system_xml_simulation(
simulation_id,
SimulationCancellationPayload(reason="user"),
)
self.assertTrue(response["accepted"])
stop_requested = True
result_event = next(event for event in events if event["event"] == "result")
result = result_event["result"]
snapshot = get_system_xml_simulation(simulation_id)
self.assertTrue(stop_requested)
self.assertEqual(result["status"], "stopped")
self.assertTrue(result["partial"])
self.assertGreaterEqual(result["diagnostics"]["sampleCount"], 2)
self.assertGreater(result["simulatedUntil"], 0.0)
self.assertLess(result["simulatedUntil"], 2.0)
self.assertEqual(snapshot["status"], "stopped")
self.assertEqual(snapshot["result"]["status"], "stopped")
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
def delayed_simulation(_xml_bytes, progress_callback, _cancel_check=None):
progress_callback(49, "integrating", "正在进行时间积分与压力流量求解")
time.sleep(0.03)
return {"success": True}
with (
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
patch(
"app.main.run_system_xml_simulation",
side_effect=delayed_simulation,
),
):
events = [
json.loads(line)
for line in simulation_event_stream(b"<System />")
]
heartbeats = [event for event in events if event.get("heartbeat") is True]
self.assertGreaterEqual(len(heartbeats), 1)
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
self.assertTrue(all(event["phase"] == "integrating" for event in heartbeats))
self.assertEqual(events[-1]["event"], "result")
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
events = [
json.loads(line)
for line in simulation_event_stream(b"<System schemaVersion='2'>")
]
self.assertEqual(events[-1]["event"], "error")
self.assertEqual(events[-1]["status"], 422)
self.assertIn("issues", events[-1]["detail"])
def test_simulation_endpoint_returns_422_for_ideal_storage_coupling(self) -> None:
project = chain_project()
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import csv
import io
import unittest
from fastapi import HTTPException
from app.main import (
SimulationResultCsvPayload,
SimulationResultVariablePayload,
export_simulation_results_csv,
)
def result_variable(
key: str,
component_id: str,
name: str,
label: str,
unit: str,
) -> SimulationResultVariablePayload:
return SimulationResultVariablePayload(
key=key,
componentId=component_id,
componentType="tank",
scope="component",
name=name,
label=label,
quantity="pressure",
unit=unit,
)
class ResultCsvExportTests(unittest.TestCase):
def valid_payload(self) -> SimulationResultCsvPayload:
return SimulationResultCsvPayload(
projectName="储气系统",
variables=[
result_variable(
"cylinder_1.p",
"cylinder_1",
"p",
"压力",
"Pa",
),
result_variable("tank_1.p", "tank_1", "p", "压力", "Pa"),
],
series={
"time": [0.0, 0.1],
"cylinder_1.p": [35000000.0, 34900000.0],
"tank_1.p": [100000.0, 101000.0],
},
)
def test_csv_export_preserves_result_keys_and_rows(self) -> None:
response = export_simulation_results_csv(self.valid_payload())
text = response.body.decode("utf-8-sig")
rows = list(csv.reader(io.StringIO(text)))
self.assertEqual(
rows[0],
["time", "cylinder_1.p", "tank_1.p"],
)
self.assertEqual(rows[1], ["0.0", "35000000.0", "100000.0"])
self.assertEqual(rows[2], ["0.1", "34900000.0", "101000.0"])
self.assertIn(
"filename*=UTF-8''",
response.headers["content-disposition"],
)
def test_csv_export_rejects_inconsistent_column_lengths(self) -> None:
payload = self.valid_payload()
payload.series["tank_1.p"] = [100000.0]
with self.assertRaises(HTTPException) as caught:
export_simulation_results_csv(payload)
self.assertEqual(caught.exception.status_code, 422)
self.assertIn("inconsistent length", str(caught.exception.detail))
def test_csv_export_requires_metadata_for_every_result_column(self) -> None:
payload = self.valid_payload()
payload.series["orphan.value"] = [1.0, 2.0]
with self.assertRaises(HTTPException) as caught:
export_simulation_results_csv(payload)
self.assertEqual(caught.exception.status_code, 422)
self.assertIn("unmapped orphan.value", str(caught.exception.detail))
if __name__ == "__main__":
unittest.main()