完善仿真交互、结果展示与模型元数据
This commit is contained in:
1 parent
f1256a121d
commit
f7f1078911
26 files changed
+8964
-497
No files matched your search
@@ -12,6 +12,7 @@
|
||||
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
|
||||
- `components/`: 元件级 Python 实现
|
||||
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
|
||||
新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。
|
||||
- `systems/`: 系统级装配与闭合
|
||||
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
|
||||
- `reporting/`: 结果导出与对比
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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()
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+212
-7
@@ -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,9 +70,15 @@ 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
|
||||
|
||||
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))
|
||||
@@ -57,16 +89,170 @@ def _runge_kutta_4(
|
||||
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
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
+130
-25
@@ -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))
|
||||
]
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Generated
+50
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1915
-87
File diff suppressed because it is too large.
Load diff
+3679
-213
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user