完善 XML 通用仿真与结果查看
This commit is contained in:
1 parent
104d41d294
commit
f1256a121d
36 files changed
+6666
-196
No files matched your search
@@ -165,12 +165,13 @@
|
|||||||
`testmodel_tank_temperature.svg`
|
`testmodel_tank_temperature.svg`
|
||||||
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
||||||
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
||||||
|
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
||||||
|
|
||||||
当前没有实现:
|
当前没有实现:
|
||||||
|
|
||||||
- 通用 DAE 初始化器
|
- 通用 DAE 初始化器
|
||||||
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
||||||
- 面向任意拓扑的通用 connector/stream 求解器
|
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
|
||||||
|
|
||||||
## 当前怎么运行
|
## 当前怎么运行
|
||||||
|
|
||||||
@@ -288,7 +289,7 @@ print(result.used_modelica_reference)
|
|||||||
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
||||||
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
||||||
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
||||||
- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。
|
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
|
||||||
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
||||||
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
||||||
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
from PythonModels.core.base import DynamicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||||
from PythonModels.core.ports import PortState
|
from PythonModels.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from PythonModels.core.state import VolumeState
|
||||||
@@ -23,7 +26,9 @@ class Cylinder(DynamicComponent):
|
|||||||
m0 = p0 * V / (medium.R_gas * T0)
|
m0 = p0 * V / (medium.R_gas * T0)
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
U0 = m0 * medium.specific_internal_energy(T0)
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
self.state = VolumeState(m=m0, U=U0)
|
||||||
self.port_b = PortState()
|
self.port_b = self.register_port(
|
||||||
|
PortState.pneumatic("port_b", nominal_role="outlet")
|
||||||
|
)
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
def get_state_vector(self) -> list[float]:
|
||||||
return self.state.as_vector()
|
return self.state.as_vector()
|
||||||
@@ -37,6 +42,39 @@ class Cylinder(DynamicComponent):
|
|||||||
self.port_b.h_outflow = props.h
|
self.port_b.h_outflow = props.h
|
||||||
return props
|
return props
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||||
|
return self.properties()
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
properties = self.properties()
|
||||||
|
derivative = self.derivatives_from_connection(
|
||||||
|
connected_h=connected_h["port_b"],
|
||||||
|
port_m_flow=self.port_b.m_flow,
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return derivative.as_vector()
|
||||||
|
|
||||||
|
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_b_pressure_state",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="state",
|
||||||
|
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_b.p - pressure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def derivatives_from_connection(
|
def derivatives_from_connection(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from math import sqrt
|
from math import sqrt
|
||||||
|
|
||||||
from PythonModels.core.base import AlgebraicComponent
|
from PythonModels.core.base import AlgebraicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
from PythonModels.core.ports import PortState
|
from PythonModels.core.ports import PortState
|
||||||
|
|
||||||
|
|
||||||
@@ -13,8 +15,12 @@ class Orifice(AlgebraicComponent):
|
|||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.opening = opening
|
self.opening = opening
|
||||||
self.K = K
|
self.K = K
|
||||||
self.port_a = PortState()
|
self.port_a = self.register_port(
|
||||||
self.port_b = PortState()
|
PortState.pneumatic("port_a", nominal_role="inlet")
|
||||||
|
)
|
||||||
|
self.port_b = self.register_port(
|
||||||
|
PortState.pneumatic("port_b", nominal_role="outlet")
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def K_eff(self) -> float:
|
def K_eff(self) -> float:
|
||||||
@@ -26,3 +32,37 @@ class Orifice(AlgebraicComponent):
|
|||||||
return 0.0
|
return 0.0
|
||||||
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
|
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.port_b.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:pressure_flow_relation",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.p",
|
||||||
|
f"{self.name}.port_b.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow
|
||||||
|
- self.mass_flow(self.port_a.p, self.port_b.p),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
from PythonModels.core.base import DynamicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||||
from PythonModels.core.ports import PortState
|
from PythonModels.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from PythonModels.core.state import VolumeState
|
||||||
@@ -29,8 +32,12 @@ class Pipe(DynamicComponent):
|
|||||||
m0 = p0 * self.V / (medium.R_gas * T0)
|
m0 = p0 * self.V / (medium.R_gas * T0)
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
U0 = m0 * medium.specific_internal_energy(T0)
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
self.state = VolumeState(m=m0, U=U0)
|
||||||
self.port_a = PortState()
|
self.port_a = self.register_port(
|
||||||
self.port_b = PortState()
|
PortState.pneumatic("port_a", nominal_role="inlet")
|
||||||
|
)
|
||||||
|
self.port_b = self.register_port(
|
||||||
|
PortState.pneumatic("port_b", nominal_role="outlet")
|
||||||
|
)
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
def get_state_vector(self) -> list[float]:
|
||||||
return self.state.as_vector()
|
return self.state.as_vector()
|
||||||
@@ -45,11 +52,64 @@ class Pipe(DynamicComponent):
|
|||||||
self.port_b.h_outflow = props.h
|
self.port_b.h_outflow = props.h
|
||||||
return props
|
return props
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||||
|
return self.properties()
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
properties = self.properties()
|
||||||
|
derivative = self.derivatives_from_connections(
|
||||||
|
port_a_m_flow=self.port_a.m_flow,
|
||||||
|
connected_h_a=connected_h["port_a"],
|
||||||
|
port_b_m_flow=self.port_b.m_flow,
|
||||||
|
connected_h_b=connected_h["port_b"],
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return derivative.as_vector()
|
||||||
|
|
||||||
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
|
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
|
||||||
resistance = self.lambda_darcy * (self.L / self.D)
|
resistance = self.lambda_darcy * (self.L / self.D)
|
||||||
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
|
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
|
||||||
return core_pressure + resistance * dynamic_term
|
return core_pressure + resistance * dynamic_term
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
properties = self.medium.properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.V,
|
||||||
|
)
|
||||||
|
expected_inlet_pressure = self.inlet_pressure(
|
||||||
|
self.port_a.m_flow,
|
||||||
|
max(properties.rho, 1e-12),
|
||||||
|
properties.p,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:darcy_pressure_loss",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.state",
|
||||||
|
),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_a.p - expected_inlet_pressure,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:port_b_pressure_state",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="state",
|
||||||
|
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_b.p - properties.p,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def port_a_inlet_enthalpy(
|
def port_a_inlet_enthalpy(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from math import pi
|
||||||
|
|
||||||
|
from PythonModels.core.base import AlgebraicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
|
from PythonModels.core.medium import IdealGasMedium
|
||||||
|
from PythonModels.core.ports import PortState
|
||||||
|
|
||||||
|
|
||||||
|
class ResistivePipe(AlgebraicComponent):
|
||||||
|
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
L: float = 5.0,
|
||||||
|
D: float = 0.02,
|
||||||
|
lambda_darcy: float = 0.02,
|
||||||
|
p0: float = 1e5,
|
||||||
|
T0: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.medium = medium
|
||||||
|
self.L = L
|
||||||
|
self.D = D
|
||||||
|
self.lambda_darcy = lambda_darcy
|
||||||
|
self.p0 = p0
|
||||||
|
self.T0 = T0
|
||||||
|
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.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.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)
|
||||||
|
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
|
||||||
|
resistance = self.lambda_darcy * (self.L / self.D)
|
||||||
|
return (
|
||||||
|
resistance
|
||||||
|
* m_flow_a
|
||||||
|
* abs(m_flow_a)
|
||||||
|
/ (2.0 * density * self.area * self.area)
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.port_b.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:darcy_pressure_loss",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.p",
|
||||||
|
f"{self.name}.port_b.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
),
|
||||||
|
role="effort",
|
||||||
|
value=(
|
||||||
|
self.port_a.p
|
||||||
|
- self.port_b.p
|
||||||
|
- self.pressure_drop(
|
||||||
|
self.port_a.m_flow,
|
||||||
|
self.port_a.p,
|
||||||
|
self.port_b.p,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
from PythonModels.core.base import DynamicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||||
from PythonModels.core.ports import PortState
|
from PythonModels.core.ports import PortState
|
||||||
from PythonModels.core.state import VolumeState
|
from PythonModels.core.state import VolumeState
|
||||||
@@ -23,7 +26,9 @@ class Tank(DynamicComponent):
|
|||||||
m0 = p0 * V / (medium.R_gas * T0)
|
m0 = p0 * V / (medium.R_gas * T0)
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
U0 = m0 * medium.specific_internal_energy(T0)
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
self.state = VolumeState(m=m0, U=U0)
|
||||||
self.port_a = PortState()
|
self.port_a = self.register_port(
|
||||||
|
PortState.pneumatic("port_a", nominal_role="inlet")
|
||||||
|
)
|
||||||
|
|
||||||
def get_state_vector(self) -> list[float]:
|
def get_state_vector(self) -> list[float]:
|
||||||
return self.state.as_vector()
|
return self.state.as_vector()
|
||||||
@@ -37,6 +42,39 @@ class Tank(DynamicComponent):
|
|||||||
self.port_a.h_outflow = props.h
|
self.port_a.h_outflow = props.h
|
||||||
return props
|
return props
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||||
|
return self.properties()
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
properties = self.properties()
|
||||||
|
derivative = self.derivatives_from_connection(
|
||||||
|
connected_h=connected_h["port_a"],
|
||||||
|
port_m_flow=self.port_a.m_flow,
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return derivative.as_vector()
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def derivatives_from_connection(
|
def derivatives_from_connection(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from PythonModels.core.base import AlgebraicComponent
|
from PythonModels.core.base import AlgebraicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
from PythonModels.core.ports import PortState
|
from PythonModels.core.ports import PortState
|
||||||
|
|
||||||
|
|
||||||
@@ -9,9 +12,71 @@ class Tee(AlgebraicComponent):
|
|||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.port_in = PortState()
|
self.port_in = self.register_port(
|
||||||
self.port_out1 = PortState()
|
PortState.pneumatic("port_in", nominal_role="bidirectional")
|
||||||
self.port_out2 = PortState()
|
)
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:common_pressure_out1",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="equal",
|
||||||
|
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_in.p - self.port_out1.p,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:common_pressure_out2",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="equal",
|
||||||
|
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_in.p - self.port_out2.p,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_in.m_flow",
|
||||||
|
f"{self.name}.port_out1.m_flow",
|
||||||
|
f"{self.name}.port_out2.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=(
|
||||||
|
self.port_in.m_flow
|
||||||
|
+ self.port_out1.m_flow
|
||||||
|
+ self.port_out2.m_flow
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
incoming = [
|
||||||
|
(port.m_flow, connected_h[name])
|
||||||
|
for name, port in self.ports.items()
|
||||||
|
if port.m_flow > 1e-12
|
||||||
|
]
|
||||||
|
total_flow = sum(m_flow for m_flow, _ in incoming)
|
||||||
|
if total_flow > 1e-12:
|
||||||
|
mixed_h = sum(
|
||||||
|
m_flow * enthalpy for m_flow, enthalpy in incoming
|
||||||
|
) / total_flow
|
||||||
|
else:
|
||||||
|
values = list(connected_h.values())
|
||||||
|
mixed_h = sum(values) / len(values) if values else 0.0
|
||||||
|
for port in self.ports.values():
|
||||||
|
port.h_outflow = mixed_h
|
||||||
|
|
||||||
def mixed_inlet_enthalpy(
|
def mixed_inlet_enthalpy(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import sqrt
|
||||||
|
|
||||||
|
from PythonModels.core.network import SimulationNetwork
|
||||||
|
from PythonModels.core.ports import PortState, VariableRole
|
||||||
|
|
||||||
|
|
||||||
|
class AlgebraicSolveError(RuntimeError):
|
||||||
|
def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlgebraicUnknown:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
variable: str
|
||||||
|
role: VariableRole
|
||||||
|
state: PortState
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return f"{self.component}.{self.port}.{self.variable}"
|
||||||
|
|
||||||
|
def read(self) -> float:
|
||||||
|
return float(getattr(self.state, self.variable))
|
||||||
|
|
||||||
|
def write(self, value: float) -> None:
|
||||||
|
setattr(self.state, self.variable, float(value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlgebraicSolveDiagnostics:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
evaluations: int
|
||||||
|
pressure_scale: float
|
||||||
|
flow_scale: float
|
||||||
|
max_scaled_residual: float
|
||||||
|
max_raw_residual: float
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"success": self.success,
|
||||||
|
"message": self.message,
|
||||||
|
"evaluations": self.evaluations,
|
||||||
|
"pressureScale": self.pressure_scale,
|
||||||
|
"flowScale": self.flow_scale,
|
||||||
|
"maxScaledResidual": self.max_scaled_residual,
|
||||||
|
"maxRawResidual": self.max_raw_residual,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PressureFlowSolver:
|
||||||
|
"""Solve the acausal pressure-flow subsystem for a compiled network."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: SimulationNetwork,
|
||||||
|
*,
|
||||||
|
residual_tolerance: float = 1e-7,
|
||||||
|
max_evaluations: int = 500,
|
||||||
|
) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.residual_tolerance = residual_tolerance
|
||||||
|
self.max_evaluations = max_evaluations
|
||||||
|
self.unknowns = self._build_unknowns()
|
||||||
|
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
|
||||||
|
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||||
|
unknowns: list[AlgebraicUnknown] = []
|
||||||
|
for component in self.network.components.values():
|
||||||
|
for definition in component.port_definitions:
|
||||||
|
if definition.kind != "physical":
|
||||||
|
continue
|
||||||
|
state = component.get_port(definition.name)
|
||||||
|
for variable in definition.variables:
|
||||||
|
if variable.role not in {"effort", "flow"}:
|
||||||
|
continue
|
||||||
|
unknowns.append(
|
||||||
|
AlgebraicUnknown(
|
||||||
|
component=component.name,
|
||||||
|
port=definition.name,
|
||||||
|
variable=variable.name,
|
||||||
|
role=variable.role,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(unknowns)
|
||||||
|
|
||||||
|
def _seed_equal_pressures(self) -> None:
|
||||||
|
for _ in range(max(2, len(self.network.connections))):
|
||||||
|
changed = False
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
first = self.network.components[
|
||||||
|
connection.endpoint_a.component
|
||||||
|
].get_port(connection.endpoint_a.port)
|
||||||
|
second = self.network.components[
|
||||||
|
connection.endpoint_b.component
|
||||||
|
].get_port(connection.endpoint_b.port)
|
||||||
|
if first.p > 0.0 and second.p <= 0.0:
|
||||||
|
second.p = first.p
|
||||||
|
changed = True
|
||||||
|
elif second.p > 0.0 and first.p <= 0.0:
|
||||||
|
first.p = second.p
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for component in self.network.components.values():
|
||||||
|
equal_pressure_equations = [
|
||||||
|
equation
|
||||||
|
for equation in component.pressure_flow_equation_residuals()
|
||||||
|
if equation.relation == "equal" and equation.role == "effort"
|
||||||
|
]
|
||||||
|
for equation in equal_pressure_equations:
|
||||||
|
states = []
|
||||||
|
for variable in equation.variables:
|
||||||
|
_, port_name, variable_name = variable.rsplit(".", 2)
|
||||||
|
if variable_name == "p":
|
||||||
|
states.append(component.get_port(port_name))
|
||||||
|
if len(states) != 2:
|
||||||
|
continue
|
||||||
|
first, second = states
|
||||||
|
if first.p > 0.0 and second.p <= 0.0:
|
||||||
|
second.p = first.p
|
||||||
|
changed = True
|
||||||
|
elif second.p > 0.0 and first.p <= 0.0:
|
||||||
|
first.p = second.p
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _scales(self) -> tuple[float, float]:
|
||||||
|
pressure_scale = max(
|
||||||
|
[
|
||||||
|
abs(unknown.read())
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "effort" and unknown.read() > 0.0
|
||||||
|
]
|
||||||
|
+ [1e5]
|
||||||
|
)
|
||||||
|
estimated_flows = [
|
||||||
|
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
||||||
|
for component in self.network.components.values()
|
||||||
|
if hasattr(component, "K_eff")
|
||||||
|
]
|
||||||
|
flow_scale = max(
|
||||||
|
estimated_flows
|
||||||
|
+ [
|
||||||
|
abs(unknown.read())
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "flow"
|
||||||
|
]
|
||||||
|
+ [1e-3]
|
||||||
|
)
|
||||||
|
return pressure_scale, flow_scale
|
||||||
|
|
||||||
|
def solve(self) -> AlgebraicSolveDiagnostics:
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from scipy.optimize import least_squares
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._seed_equal_pressures()
|
||||||
|
pressure_scale, flow_scale = self._scales()
|
||||||
|
positive_pressures = [
|
||||||
|
unknown.read()
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "effort" and unknown.read() > 0.0
|
||||||
|
]
|
||||||
|
fallback_pressure = (
|
||||||
|
sum(positive_pressures) / len(positive_pressures)
|
||||||
|
if positive_pressures
|
||||||
|
else pressure_scale
|
||||||
|
)
|
||||||
|
|
||||||
|
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
||||||
|
return pressure_scale if unknown.role == "effort" else flow_scale
|
||||||
|
|
||||||
|
x0 = np.asarray(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
unknown.read()
|
||||||
|
if unknown.role != "effort" or unknown.read() > 0.0
|
||||||
|
else fallback_pressure
|
||||||
|
)
|
||||||
|
/ variable_scale(unknown)
|
||||||
|
for unknown in self.unknowns
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
lower = np.asarray(
|
||||||
|
[
|
||||||
|
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
|
||||||
|
for unknown in self.unknowns
|
||||||
|
]
|
||||||
|
)
|
||||||
|
upper = np.full(len(self.unknowns), np.inf)
|
||||||
|
|
||||||
|
def assign(values) -> None:
|
||||||
|
for unknown, value in zip(self.unknowns, values):
|
||||||
|
unknown.write(float(value) * variable_scale(unknown))
|
||||||
|
|
||||||
|
def scaled_residuals(values):
|
||||||
|
assign(values)
|
||||||
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
|
return np.asarray(
|
||||||
|
[
|
||||||
|
equation.value
|
||||||
|
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||||
|
for equation in equations
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = least_squares(
|
||||||
|
scaled_residuals,
|
||||||
|
x0,
|
||||||
|
bounds=(lower, upper),
|
||||||
|
x_scale="jac",
|
||||||
|
ftol=1e-10,
|
||||||
|
xtol=1e-10,
|
||||||
|
gtol=1e-10,
|
||||||
|
max_nfev=self.max_evaluations,
|
||||||
|
)
|
||||||
|
assign(result.x)
|
||||||
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
|
scaled = [
|
||||||
|
abs(
|
||||||
|
equation.value
|
||||||
|
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||||
|
)
|
||||||
|
for equation in equations
|
||||||
|
]
|
||||||
|
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
|
||||||
|
diagnostics = AlgebraicSolveDiagnostics(
|
||||||
|
success=success,
|
||||||
|
message=str(result.message),
|
||||||
|
evaluations=int(result.nfev),
|
||||||
|
pressure_scale=pressure_scale,
|
||||||
|
flow_scale=flow_scale,
|
||||||
|
max_scaled_residual=max(scaled, default=0.0),
|
||||||
|
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
if not success:
|
||||||
|
raise AlgebraicSolveError(
|
||||||
|
"Pressure-flow equations did not converge to the requested tolerance.",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
@@ -1,11 +1,55 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
|
from PythonModels.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
|
|
||||||
class Component(ABC):
|
class Component(ABC):
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.model_type = self.__class__.__name__.lower()
|
||||||
|
self._ports: dict[str, PortState] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self) -> dict[str, PortState]:
|
||||||
|
return dict(self._ports)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||||
|
return tuple(
|
||||||
|
port.definition
|
||||||
|
for port in self._ports.values()
|
||||||
|
if port.definition is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_port(self, port: PortState) -> PortState:
|
||||||
|
definition = port.definition
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(f"Component {self.name} cannot register an undefined port.")
|
||||||
|
if definition.name in self._ports:
|
||||||
|
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
|
||||||
|
self._ports[definition.name] = port
|
||||||
|
return port
|
||||||
|
|
||||||
|
def 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 pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Return algebraic residuals after the network assigns port states."""
|
||||||
|
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
"""Update connector outflow properties from current flow directions."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DynamicComponent(Component):
|
class DynamicComponent(Component):
|
||||||
@@ -43,6 +87,15 @@ class DynamicComponent(Component):
|
|||||||
def set_state_vector(self, values: list[float]) -> None:
|
def set_state_vector(self, values: list[float]) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class AlgebraicComponent(Component):
|
class AlgebraicComponent(Component):
|
||||||
"""Stateless element described by algebraic constraints only."""
|
"""Stateless element described by algebraic constraints only."""
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from PythonModels.core.ports import VariableRole
|
||||||
|
|
||||||
|
|
||||||
|
EquationOwner = Literal["connection", "component"]
|
||||||
|
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EquationResidual:
|
||||||
|
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
owner: EquationOwner
|
||||||
|
owner_id: str
|
||||||
|
relation: EquationRelation
|
||||||
|
variables: tuple[str, ...]
|
||||||
|
value: float
|
||||||
|
role: VariableRole | None = None
|
||||||
|
|
||||||
|
def as_definition_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"owner": self.owner,
|
||||||
|
"ownerId": self.owner_id,
|
||||||
|
"relation": self.relation,
|
||||||
|
"variables": list(self.variables),
|
||||||
|
"role": self.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {**self.as_definition_dict(), "residual": self.value}
|
||||||
+241
-17
@@ -3,14 +3,67 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PythonModels.core.base import Component, DynamicComponent
|
from PythonModels.core.base import Component, DynamicComponent
|
||||||
|
from PythonModels.core.equations import EquationResidual
|
||||||
|
from PythonModels.core.ports import PortState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Endpoint:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> tuple[str, str]:
|
||||||
|
return self.component, self.port
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.component}.{self.port}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Connection:
|
class Connection:
|
||||||
source_component: str
|
id: str
|
||||||
source_port: str
|
kind: str
|
||||||
target_component: str
|
domain: str
|
||||||
target_port: str
|
endpoint_a: Endpoint
|
||||||
|
endpoint_b: Endpoint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoints(self) -> tuple[Endpoint, Endpoint]:
|
||||||
|
return self.endpoint_a, self.endpoint_b
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||||
|
first, second = sorted((self.endpoint_a.key, self.endpoint_b.key))
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
# Compatibility accessors for existing reports. They do not imply physical flow.
|
||||||
|
@property
|
||||||
|
def source_component(self) -> str:
|
||||||
|
return self.endpoint_a.component
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_port(self) -> str:
|
||||||
|
return self.endpoint_a.port
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_component(self) -> str:
|
||||||
|
return self.endpoint_b.component
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_port(self) -> str:
|
||||||
|
return self.endpoint_b.port
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"endpoints": [
|
||||||
|
{"component": endpoint.component, "port": endpoint.port}
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SimulationNetwork:
|
class SimulationNetwork:
|
||||||
@@ -28,19 +81,159 @@ class SimulationNetwork:
|
|||||||
|
|
||||||
def connect(
|
def connect(
|
||||||
self,
|
self,
|
||||||
source_component: str,
|
endpoint_a_component: str,
|
||||||
source_port: str,
|
endpoint_a_port: str,
|
||||||
target_component: str,
|
endpoint_b_component: str,
|
||||||
target_port: str,
|
endpoint_b_port: str,
|
||||||
) -> None:
|
*,
|
||||||
self.connections.append(
|
connection_id: str | None = None,
|
||||||
Connection(
|
) -> Connection:
|
||||||
source_component=source_component,
|
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
|
||||||
source_port=source_port,
|
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
|
||||||
target_component=target_component,
|
if endpoint_a == endpoint_b:
|
||||||
target_port=target_port,
|
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
|
||||||
|
|
||||||
|
first_port = self._port_for(endpoint_a)
|
||||||
|
second_port = self._port_for(endpoint_b)
|
||||||
|
first_definition = first_port.definition
|
||||||
|
second_definition = second_port.definition
|
||||||
|
if first_definition is None or second_definition is None:
|
||||||
|
raise ValueError("Connected ports must expose interface definitions.")
|
||||||
|
if first_definition.kind != second_definition.kind:
|
||||||
|
raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.")
|
||||||
|
if first_definition.domain != second_definition.domain:
|
||||||
|
raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.")
|
||||||
|
if first_definition.variables != second_definition.variables:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
|
||||||
)
|
)
|
||||||
|
if first_definition.kind == "signal" and {
|
||||||
|
first_definition.nominal_role,
|
||||||
|
second_definition.nominal_role,
|
||||||
|
} != {"input", "output"}:
|
||||||
|
raise ValueError("A signal connection must contain one output and one input.")
|
||||||
|
|
||||||
|
occupied_endpoints = {
|
||||||
|
endpoint
|
||||||
|
for item in self.connections
|
||||||
|
for endpoint in item.endpoints
|
||||||
|
}
|
||||||
|
if first_definition.kind == "physical":
|
||||||
|
occupied = [
|
||||||
|
str(endpoint)
|
||||||
|
for endpoint in (endpoint_a, endpoint_b)
|
||||||
|
if endpoint in occupied_endpoints
|
||||||
|
]
|
||||||
|
if occupied:
|
||||||
|
raise ValueError(
|
||||||
|
"Physical ports accept one connection; already connected: "
|
||||||
|
+ ", ".join(occupied)
|
||||||
|
+ ". Use a junction component for branching."
|
||||||
|
)
|
||||||
|
|
||||||
|
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
|
||||||
|
endpoint_a, endpoint_b = endpoint_b, endpoint_a
|
||||||
|
|
||||||
|
connection = Connection(
|
||||||
|
id=connection_id or f"connection_{len(self.connections) + 1}",
|
||||||
|
kind=first_definition.kind,
|
||||||
|
domain=first_definition.domain,
|
||||||
|
endpoint_a=endpoint_a,
|
||||||
|
endpoint_b=endpoint_b,
|
||||||
)
|
)
|
||||||
|
if any(item.undirected_key == connection.undirected_key for item in self.connections):
|
||||||
|
raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.")
|
||||||
|
if any(item.id == connection.id for item in self.connections):
|
||||||
|
raise ValueError(f"Duplicate connection id: {connection.id}.")
|
||||||
|
self.connections.append(connection)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _port_for(self, endpoint: Endpoint) -> PortState:
|
||||||
|
try:
|
||||||
|
component = self.components[endpoint.component]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
|
||||||
|
return component.get_port(endpoint.port)
|
||||||
|
|
||||||
|
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Evaluate connector equations that have a direct scalar residual.
|
||||||
|
|
||||||
|
Stream variables are resolved by the stream-mixing layer and therefore do
|
||||||
|
not incorrectly appear here as an equality between outflow properties.
|
||||||
|
"""
|
||||||
|
|
||||||
|
residuals: list[EquationResidual] = []
|
||||||
|
for connection in self.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_port = self._port_for(connection.endpoint_a)
|
||||||
|
second_port = self._port_for(connection.endpoint_b)
|
||||||
|
definition = first_port.definition
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connected port {connection.endpoint_a} has no interface definition."
|
||||||
|
)
|
||||||
|
|
||||||
|
for variable in definition.variables:
|
||||||
|
if variable.connection_rule == "equal":
|
||||||
|
value = float(getattr(first_port, variable.name)) - float(
|
||||||
|
getattr(second_port, variable.name)
|
||||||
|
)
|
||||||
|
elif variable.connection_rule == "sumToZero":
|
||||||
|
value = float(getattr(first_port, variable.name)) + float(
|
||||||
|
getattr(second_port, variable.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
residuals.append(
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{connection.id}:{variable.name}",
|
||||||
|
owner="connection",
|
||||||
|
owner_id=connection.id,
|
||||||
|
relation=variable.connection_rule,
|
||||||
|
variables=(
|
||||||
|
f"{connection.endpoint_a}.{variable.name}",
|
||||||
|
f"{connection.endpoint_b}.{variable.name}",
|
||||||
|
),
|
||||||
|
role=variable.role,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(residuals)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
|
||||||
|
|
||||||
|
component_residuals = tuple(
|
||||||
|
residual
|
||||||
|
for component in self.components.values()
|
||||||
|
for residual in component.pressure_flow_equation_residuals()
|
||||||
|
)
|
||||||
|
return component_residuals + self.connection_equation_residuals()
|
||||||
|
|
||||||
|
def pressure_flow_unknowns(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
f"{component.name}.{definition.name}.{variable.name}"
|
||||||
|
for component in self.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
for variable in definition.variables
|
||||||
|
if variable.role in {"effort", "flow"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_structure_dict(self) -> dict[str, object]:
|
||||||
|
unknowns = self.pressure_flow_unknowns()
|
||||||
|
equations = self.pressure_flow_equation_residuals()
|
||||||
|
return {
|
||||||
|
"unknownCount": len(unknowns),
|
||||||
|
"equationCount": len(equations),
|
||||||
|
"isSquare": len(unknowns) == len(equations),
|
||||||
|
"unknowns": list(unknowns),
|
||||||
|
"equations": [
|
||||||
|
equation.as_definition_dict() for equation in equations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def dynamic_components(self) -> list[DynamicComponent]:
|
def dynamic_components(self) -> list[DynamicComponent]:
|
||||||
return [
|
return [
|
||||||
@@ -70,9 +263,40 @@ class SimulationNetwork:
|
|||||||
lines.append(f" - {name}: {component.__class__.__name__}")
|
lines.append(f" - {name}: {component.__class__.__name__}")
|
||||||
lines.append("Connections:")
|
lines.append("Connections:")
|
||||||
for conn in self.connections:
|
for conn in self.connections:
|
||||||
|
connector = "<->" if conn.kind == "physical" else "->"
|
||||||
lines.append(
|
lines.append(
|
||||||
f" - {conn.source_component}.{conn.source_port}"
|
f" - {conn.endpoint_a} {connector} {conn.endpoint_b}"
|
||||||
f" -> {conn.target_component}.{conn.target_port}"
|
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
connected_endpoints = {
|
||||||
|
endpoint.key
|
||||||
|
for connection in self.connections
|
||||||
|
for endpoint in connection.endpoints
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": component.name,
|
||||||
|
"type": component.model_type,
|
||||||
|
"ports": [
|
||||||
|
definition.as_interface_dict()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for component in self.components.values()
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
connection.as_interface_dict() for connection in self.connections
|
||||||
|
],
|
||||||
|
"pressureFlowSystem": self.pressure_flow_structure_dict(),
|
||||||
|
"unconnectedPorts": [
|
||||||
|
{"component": component.name, "port": definition.name}
|
||||||
|
for component in self.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if (component.name, definition.name) not in connected_endpoints
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,70 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PortKind = Literal["physical", "signal"]
|
||||||
|
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
|
||||||
|
ActualFlowDirection = Literal["in", "out", "stagnant"]
|
||||||
|
VariableRole = Literal["effort", "flow", "stream", "signal"]
|
||||||
|
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortVariableDefinition:
|
||||||
|
name: str
|
||||||
|
role: VariableRole
|
||||||
|
connection_rule: ConnectionRule
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"role": self.role,
|
||||||
|
"connectionRule": self.connection_rule,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortDefinition:
|
||||||
|
"""Stable connector contract shared by components, XML, and the compiler."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: PortKind
|
||||||
|
domain: str
|
||||||
|
nominal_role: PortNominalRole
|
||||||
|
positive_flow_direction: Literal["intoComponent"] | None = None
|
||||||
|
variables: tuple[PortVariableDefinition, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pneumatic(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||||
|
) -> PortDefinition:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
kind="physical",
|
||||||
|
domain="pneumatic",
|
||||||
|
nominal_role=nominal_role,
|
||||||
|
positive_flow_direction="intoComponent",
|
||||||
|
variables=(
|
||||||
|
PortVariableDefinition("p", "effort", "equal"),
|
||||||
|
PortVariableDefinition("m_flow", "flow", "sumToZero"),
|
||||||
|
PortVariableDefinition("h_outflow", "stream", "streamMix"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"nominalRole": self.nominal_role,
|
||||||
|
"positiveFlowDirection": self.positive_flow_direction,
|
||||||
|
"variables": [variable.as_interface_dict() for variable in self.variables],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -10,4 +74,29 @@ class PortState:
|
|||||||
p: float = 0.0
|
p: float = 0.0
|
||||||
m_flow: float = 0.0
|
m_flow: float = 0.0
|
||||||
h_outflow: float = 0.0
|
h_outflow: float = 0.0
|
||||||
|
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pneumatic(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||||
|
) -> PortState:
|
||||||
|
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inflow_rate(self) -> float:
|
||||||
|
return max(self.m_flow, 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outflow_rate(self) -> float:
|
||||||
|
return max(-self.m_flow, 0.0)
|
||||||
|
|
||||||
|
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
|
||||||
|
if self.m_flow > tolerance:
|
||||||
|
return "in"
|
||||||
|
if self.m_flow < -tolerance:
|
||||||
|
return "out"
|
||||||
|
return "stagnant"
|
||||||
|
|
||||||
@@ -98,5 +98,6 @@ def integrate_ode(
|
|||||||
method=config.method,
|
method=config.method,
|
||||||
rtol=config.rtol,
|
rtol=config.rtol,
|
||||||
atol=config.atol,
|
atol=config.atol,
|
||||||
|
max_step=config.max_step,
|
||||||
t_eval=t_eval,
|
t_eval=t_eval,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from PythonModels.core.base import DynamicComponent
|
||||||
|
from PythonModels.core.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class StreamSolveError(RuntimeError):
|
||||||
|
def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StreamSolveDiagnostics:
|
||||||
|
converged: bool
|
||||||
|
iterations: int
|
||||||
|
max_delta: float
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"converged": self.converged,
|
||||||
|
"iterations": self.iterations,
|
||||||
|
"maxDelta": self.max_delta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StreamResolver:
|
||||||
|
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: SimulationNetwork,
|
||||||
|
*,
|
||||||
|
relative_tolerance: float = 1e-9,
|
||||||
|
max_iterations: int = 100,
|
||||||
|
) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.relative_tolerance = relative_tolerance
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
self._connected_endpoint = self._build_connection_map()
|
||||||
|
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||||
|
|
||||||
|
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||||
|
result: dict[Endpoint, Endpoint] = {}
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
first, second = connection.endpoints
|
||||||
|
result[first] = second
|
||||||
|
result[second] = first
|
||||||
|
return result
|
||||||
|
|
||||||
|
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||||
|
values: dict[str, dict[str, float]] = {
|
||||||
|
component.name: {} for component in self.network.components.values()
|
||||||
|
}
|
||||||
|
for endpoint, connected in self._connected_endpoint.items():
|
||||||
|
connected_port = self.network.components[connected.component].get_port(
|
||||||
|
connected.port
|
||||||
|
)
|
||||||
|
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
||||||
|
return values
|
||||||
|
|
||||||
|
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||||
|
dynamic_components = [
|
||||||
|
component
|
||||||
|
for component in self.network.components.values()
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
]
|
||||||
|
for component in dynamic_components:
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
|
max_delta = 0.0
|
||||||
|
for iteration in range(1, self.max_iterations + 1):
|
||||||
|
previous = {
|
||||||
|
(component.name, port_name): port.h_outflow
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port_name, port in component.ports.items()
|
||||||
|
}
|
||||||
|
connected = self.connected_enthalpies()
|
||||||
|
for component in self.network.components.values():
|
||||||
|
if isinstance(component, DynamicComponent):
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
else:
|
||||||
|
component.update_stream_outflows(connected[component.name])
|
||||||
|
|
||||||
|
deltas = [
|
||||||
|
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port_name, port in component.ports.items()
|
||||||
|
]
|
||||||
|
magnitudes = [
|
||||||
|
abs(port.h_outflow)
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port in component.ports.values()
|
||||||
|
]
|
||||||
|
max_delta = max(deltas, default=0.0)
|
||||||
|
scale = max(magnitudes + [1.0])
|
||||||
|
if max_delta <= self.relative_tolerance * scale:
|
||||||
|
diagnostics = StreamSolveDiagnostics(
|
||||||
|
converged=True,
|
||||||
|
iterations=iteration,
|
||||||
|
max_delta=max_delta,
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
return diagnostics, self.connected_enthalpies()
|
||||||
|
|
||||||
|
diagnostics = StreamSolveDiagnostics(
|
||||||
|
converged=False,
|
||||||
|
iterations=self.max_iterations,
|
||||||
|
max_delta=max_delta,
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
raise StreamSolveError(
|
||||||
|
"Stream enthalpy propagation did not converge.",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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
|
||||||
|
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.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
|
||||||
|
|
||||||
|
|
||||||
|
ComponentFactory = Callable[
|
||||||
|
[str, IdealGasMedium, Mapping[str, float]],
|
||||||
|
Component,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComponentModelSpec:
|
||||||
|
model_type: str
|
||||||
|
ports: tuple[PortDefinition, ...]
|
||||||
|
parameters: tuple[ParameterSpec, ...]
|
||||||
|
factory: ComponentFactory
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameter_by_name(self) -> dict[str, ParameterSpec]:
|
||||||
|
return {parameter.name: parameter for parameter in self.parameters}
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
resolved = {
|
||||||
|
parameter.name: values.get(parameter.name, parameter.default)
|
||||||
|
for parameter in self.parameters
|
||||||
|
}
|
||||||
|
for parameter in self.parameters:
|
||||||
|
message = parameter.validation_message(resolved[parameter.name])
|
||||||
|
if message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Parameter '{parameter.name}' on component '{name}' {message}."
|
||||||
|
)
|
||||||
|
unknown = sorted(set(values) - set(self.parameter_by_name))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{name}' contains unsupported parameters: "
|
||||||
|
+ ", ".join(unknown)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
component = self.factory(name, medium, resolved)
|
||||||
|
component.model_type = self.model_type
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_factory(
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
return Cylinder(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
V=values["volume"],
|
||||||
|
p0=values["p0"],
|
||||||
|
T0=values["T0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tank_factory(
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
return Tank(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
V=values["volume"],
|
||||||
|
p0=values["p0"],
|
||||||
|
T0=values["T0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pipe_factory(
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
return ResistivePipe(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
L=values["length"],
|
||||||
|
D=values["diameter"],
|
||||||
|
lambda_darcy=values["lambda_darcy"],
|
||||||
|
p0=values["p0"],
|
||||||
|
T0=values["T0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orifice_factory(
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
return Orifice(name=name, opening=values["opening"], K=values["K"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tee_factory(
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
return Tee(name=name)
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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=(),
|
||||||
|
factory=_tee_factory,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_component_model_spec(model_type: str) -> ComponentModelSpec:
|
||||||
|
try:
|
||||||
|
return COMPONENT_MODEL_REGISTRY[model_type]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unsupported model type: {model_type}.") from exc
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import floor, isfinite
|
||||||
|
|
||||||
|
from PythonModels.core.algebraic import PressureFlowSolver
|
||||||
|
from PythonModels.core.base import DynamicComponent
|
||||||
|
from PythonModels.core.network import Endpoint, SimulationNetwork
|
||||||
|
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
||||||
|
from PythonModels.core.stream import StreamResolver
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimulationPreparationIssue:
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {"code": self.code, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPreparationError(ValueError):
|
||||||
|
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
||||||
|
super().__init__("The compiled model is not ready for simulation.")
|
||||||
|
self.issues = issues
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GenericSimulationResult:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
series: dict[str, list[float]]
|
||||||
|
final: dict[str, float]
|
||||||
|
diagnostics: dict[str, object]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"success": self.success,
|
||||||
|
"message": self.message,
|
||||||
|
"series": self.series,
|
||||||
|
"final": self.final,
|
||||||
|
"diagnostics": self.diagnostics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _UnionFind:
|
||||||
|
def __init__(self, items: set[Endpoint]) -> None:
|
||||||
|
self.parent = {item: item for item in items}
|
||||||
|
|
||||||
|
def find(self, item: Endpoint) -> Endpoint:
|
||||||
|
parent = self.parent[item]
|
||||||
|
if parent != item:
|
||||||
|
self.parent[item] = self.find(parent)
|
||||||
|
return self.parent[item]
|
||||||
|
|
||||||
|
def union(self, first: Endpoint, second: Endpoint) -> None:
|
||||||
|
first_root = self.find(first)
|
||||||
|
second_root = self.find(second)
|
||||||
|
if first_root != second_root:
|
||||||
|
self.parent[second_root] = first_root
|
||||||
|
|
||||||
|
|
||||||
|
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
|
||||||
|
parts = variable.rsplit(".", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
prefix, port_name, variable_name = parts
|
||||||
|
if prefix != component_name or variable_name != "p":
|
||||||
|
return None
|
||||||
|
return Endpoint(component_name, port_name)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation_preparation_issues(
|
||||||
|
network: SimulationNetwork,
|
||||||
|
) -> tuple[SimulationPreparationIssue, ...]:
|
||||||
|
issues: list[SimulationPreparationIssue] = []
|
||||||
|
physical_endpoints = {
|
||||||
|
Endpoint(component.name, definition.name)
|
||||||
|
for component in network.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
}
|
||||||
|
connected_endpoints = {
|
||||||
|
endpoint
|
||||||
|
for connection in network.connections
|
||||||
|
if connection.kind == "physical"
|
||||||
|
for endpoint in connection.endpoints
|
||||||
|
}
|
||||||
|
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"PORT_UNCONNECTED",
|
||||||
|
f"Physical port {endpoint} must be connected before simulation.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
definition.kind == "signal"
|
||||||
|
for component in network.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"SIGNAL_PORT_UNSUPPORTED",
|
||||||
|
"Signal-port simulation is not implemented in the current MVP solver.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
structure = network.pressure_flow_structure_dict()
|
||||||
|
if not structure["isSquare"]:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
|
||||||
|
"Pressure-flow equation count does not match the unknown count: "
|
||||||
|
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dynamic_names = {
|
||||||
|
component.name
|
||||||
|
for component in network.components.values()
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
}
|
||||||
|
if not dynamic_names:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"DYNAMIC_STATE_MISSING",
|
||||||
|
"Each simulated network requires at least one storage component.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
adjacency = {name: set() for name in network.components}
|
||||||
|
for connection in network.connections:
|
||||||
|
first, second = connection.endpoints
|
||||||
|
adjacency[first.component].add(second.component)
|
||||||
|
adjacency[second.component].add(first.component)
|
||||||
|
remaining = set(adjacency)
|
||||||
|
while remaining:
|
||||||
|
start = remaining.pop()
|
||||||
|
group = {start}
|
||||||
|
stack = [start]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
for neighbour in adjacency[current] - group:
|
||||||
|
group.add(neighbour)
|
||||||
|
remaining.discard(neighbour)
|
||||||
|
stack.append(neighbour)
|
||||||
|
if not (group & dynamic_names):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
|
||||||
|
"A connected physical network has no pressure/enthalpy storage anchor: "
|
||||||
|
+ ", ".join(sorted(group))
|
||||||
|
+ ".",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if physical_endpoints:
|
||||||
|
effort_groups = _UnionFind(physical_endpoints)
|
||||||
|
for connection in network.connections:
|
||||||
|
if connection.kind == "physical":
|
||||||
|
effort_groups.union(*connection.endpoints)
|
||||||
|
storage_ports: dict[Endpoint, str] = {}
|
||||||
|
for component in network.components.values():
|
||||||
|
for equation in component.pressure_flow_equation_residuals():
|
||||||
|
pressure_ports = [
|
||||||
|
endpoint
|
||||||
|
for variable in equation.variables
|
||||||
|
if (endpoint := _equation_port(component.name, variable)) is not None
|
||||||
|
]
|
||||||
|
if equation.relation == "equal" and len(pressure_ports) == 2:
|
||||||
|
effort_groups.union(pressure_ports[0], pressure_ports[1])
|
||||||
|
if equation.relation == "state":
|
||||||
|
for endpoint in pressure_ports:
|
||||||
|
storage_ports[endpoint] = component.name
|
||||||
|
|
||||||
|
storages_by_group: dict[Endpoint, set[str]] = {}
|
||||||
|
for endpoint, component_name in storage_ports.items():
|
||||||
|
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add(
|
||||||
|
component_name
|
||||||
|
)
|
||||||
|
for storage_names in storages_by_group.values():
|
||||||
|
if len(storage_names) > 1:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||||
|
"Storage components are connected without a resistance: "
|
||||||
|
+ ", ".join(sorted(storage_names))
|
||||||
|
+ ". Insert an orifice or pipe between them.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation_sample_times(
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
step: float,
|
||||||
|
*,
|
||||||
|
max_points: int = 10001,
|
||||||
|
) -> list[float]:
|
||||||
|
if step <= 0.0 or not isfinite(step):
|
||||||
|
raise ValueError("Simulation sample step must be finite and greater than zero.")
|
||||||
|
duration = config.t_stop - config.t_start
|
||||||
|
if duration <= 0.0:
|
||||||
|
raise ValueError("Simulation stop time must be greater than start time.")
|
||||||
|
interval_count = int(floor(duration / step + 1e-12))
|
||||||
|
times = [config.t_start + index * step for index in range(interval_count + 1)]
|
||||||
|
if times[-1] < config.t_stop - 1e-12:
|
||||||
|
times.append(config.t_stop)
|
||||||
|
else:
|
||||||
|
times[-1] = config.t_stop
|
||||||
|
if len(times) > max_points:
|
||||||
|
raise ValueError(
|
||||||
|
f"Simulation requests {len(times)} samples; the limit is {max_points}."
|
||||||
|
)
|
||||||
|
return times
|
||||||
|
|
||||||
|
|
||||||
|
class GenericFluidSystem:
|
||||||
|
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
||||||
|
|
||||||
|
def __init__(self, network: SimulationNetwork) -> None:
|
||||||
|
issues = simulation_preparation_issues(network)
|
||||||
|
if issues:
|
||||||
|
raise SimulationPreparationError(issues)
|
||||||
|
self.network = network
|
||||||
|
self.dynamic_components = network.dynamic_components()
|
||||||
|
self.pressure_flow_solver = PressureFlowSolver(network)
|
||||||
|
self.stream_resolver = StreamResolver(network)
|
||||||
|
self.algebraic_solve_count = 0
|
||||||
|
self.max_algebraic_residual = 0.0
|
||||||
|
self.max_algebraic_evaluations = 0
|
||||||
|
self.max_stream_iterations = 0
|
||||||
|
|
||||||
|
def initial_state_vector(self) -> list[float]:
|
||||||
|
return self.network.initial_state_vector()
|
||||||
|
|
||||||
|
def apply_state_vector(self, values: list[float]) -> None:
|
||||||
|
self.network.apply_state_vector(values)
|
||||||
|
|
||||||
|
def _close_current_state(self) -> dict[str, dict[str, float]]:
|
||||||
|
for component in self.dynamic_components:
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
algebraic = self.pressure_flow_solver.solve()
|
||||||
|
stream, connected_h = self.stream_resolver.solve()
|
||||||
|
self.algebraic_solve_count += 1
|
||||||
|
self.max_algebraic_residual = max(
|
||||||
|
self.max_algebraic_residual,
|
||||||
|
algebraic.max_scaled_residual,
|
||||||
|
)
|
||||||
|
self.max_algebraic_evaluations = max(
|
||||||
|
self.max_algebraic_evaluations,
|
||||||
|
algebraic.evaluations,
|
||||||
|
)
|
||||||
|
self.max_stream_iterations = max(
|
||||||
|
self.max_stream_iterations,
|
||||||
|
stream.iterations,
|
||||||
|
)
|
||||||
|
return connected_h
|
||||||
|
|
||||||
|
def consistent_initial_state_vector(self) -> list[float]:
|
||||||
|
state = self.initial_state_vector()
|
||||||
|
self.apply_state_vector(state)
|
||||||
|
self._close_current_state()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||||
|
self.apply_state_vector(state_vector)
|
||||||
|
connected_h = self._close_current_state()
|
||||||
|
derivatives: list[float] = []
|
||||||
|
for component in self.dynamic_components:
|
||||||
|
derivatives.extend(
|
||||||
|
component.state_derivative_from_ports(connected_h[component.name])
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
def simulate(
|
||||||
|
self,
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
*,
|
||||||
|
sample_step: float,
|
||||||
|
) -> GenericSimulationResult:
|
||||||
|
t_eval = simulation_sample_times(config, sample_step)
|
||||||
|
initial_state = self.consistent_initial_state_vector()
|
||||||
|
solution = integrate_ode(
|
||||||
|
rhs=self.rhs,
|
||||||
|
initial_state=initial_state,
|
||||||
|
config=config,
|
||||||
|
t_eval=t_eval,
|
||||||
|
)
|
||||||
|
times = [float(value) for value in solution.t]
|
||||||
|
series: dict[str, list[float]] = {"time": times}
|
||||||
|
for time_index in range(len(times)):
|
||||||
|
state = [
|
||||||
|
float(solution.y[state_index][time_index])
|
||||||
|
for state_index in range(len(solution.y))
|
||||||
|
]
|
||||||
|
self.apply_state_vector(state)
|
||||||
|
self._close_current_state()
|
||||||
|
self._append_current_state(series)
|
||||||
|
|
||||||
|
final = {
|
||||||
|
key: values[-1]
|
||||||
|
for key, values in series.items()
|
||||||
|
if key != "time" and values
|
||||||
|
}
|
||||||
|
diagnostics = {
|
||||||
|
"pressureFlow": {
|
||||||
|
"solveCount": self.algebraic_solve_count,
|
||||||
|
"maxScaledResidual": self.max_algebraic_residual,
|
||||||
|
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||||
|
"last": (
|
||||||
|
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||||
|
if self.pressure_flow_solver.last_diagnostics is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||||
|
"last": (
|
||||||
|
self.stream_resolver.last_diagnostics.as_dict()
|
||||||
|
if self.stream_resolver.last_diagnostics is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stateCount": len(initial_state),
|
||||||
|
"sampleCount": len(times),
|
||||||
|
}
|
||||||
|
return GenericSimulationResult(
|
||||||
|
success=bool(solution.success),
|
||||||
|
message=str(solution.message),
|
||||||
|
series=series,
|
||||||
|
final=final,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
@@ -586,7 +586,7 @@ class TestModelClosure:
|
|||||||
self.components.upstream_tee.port_in.p = cylinder.p
|
self.components.upstream_tee.port_in.p = cylinder.p
|
||||||
self.components.upstream_tee.port_out1.p = cylinder.p
|
self.components.upstream_tee.port_out1.p = cylinder.p
|
||||||
self.components.upstream_tee.port_out2.p = cylinder.p
|
self.components.upstream_tee.port_out2.p = cylinder.p
|
||||||
self.components.upstream_tee.port_in.m_flow = cylinder_m_flow
|
self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow
|
||||||
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
|
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
|
||||||
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
|
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
|
||||||
self.components.upstream_tee.port_out2.h_outflow = cylinder.h
|
self.components.upstream_tee.port_out2.h_outflow = cylinder.h
|
||||||
|
|||||||
@@ -2,8 +2,30 @@
|
|||||||
|
|
||||||
ReactFlow 系统建模与 PythonModels 仿真应用。
|
ReactFlow 系统建模与 PythonModels 仿真应用。
|
||||||
|
|
||||||
|
## 后端接口
|
||||||
|
|
||||||
|
- `POST /api/reactflow/system-xml`:导出 System XML v2。
|
||||||
|
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为 PythonModels 网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||||
|
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
||||||
|
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
|
||||||
|
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
|
||||||
|
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 PythonModels 组件网络。
|
||||||
|
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
||||||
|
|
||||||
|
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
||||||
|
|
||||||
|
当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||||
|
|
||||||
|
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [System XML v1 协议](docs/system-xml-v1.md)
|
- [System XML v2 协议](docs/system-xml-v2.md)
|
||||||
- [System XML v1 XSD](schemas/system-simulation-v1.xsd)
|
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
||||||
|
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
||||||
|
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
|
||||||
|
|
||||||
+418
-15
@@ -3,18 +3,28 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.system_xml import (
|
||||||
|
SystemXmlDocument,
|
||||||
|
SystemXmlValidationReport,
|
||||||
|
validate_system_xml_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from PythonModels.core.network import SimulationNetwork
|
||||||
|
from PythonModels.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="System Simulation ReactFlow App")
|
app = FastAPI(title="System Simulation ReactFlow App")
|
||||||
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||||
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
|
||||||
SYSTEM_XML_SCHEMA_VERSION = "1"
|
SYSTEM_XML_SCHEMA_VERSION = "2"
|
||||||
SYSTEM_XML_UNIT_SYSTEM = "SI"
|
SYSTEM_XML_UNIT_SYSTEM = "SI"
|
||||||
|
|
||||||
|
|
||||||
@@ -23,12 +33,29 @@ class ReactFlowPosition(BaseModel):
|
|||||||
y: float = 0.0
|
y: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ReactFlowPortDefinition(BaseModel):
|
||||||
|
name: str
|
||||||
|
kind: Literal["physical", "signal"] = "physical"
|
||||||
|
domain: str = "pneumatic"
|
||||||
|
nominalRole: Literal[
|
||||||
|
"inlet",
|
||||||
|
"outlet",
|
||||||
|
"bidirectional",
|
||||||
|
"input",
|
||||||
|
"output",
|
||||||
|
] = "bidirectional"
|
||||||
|
positiveFlowDirection: Literal["intoComponent"] | None = None
|
||||||
|
side: Literal["left", "right"] = "left"
|
||||||
|
|
||||||
|
|
||||||
class ReactFlowNodeData(BaseModel):
|
class ReactFlowNodeData(BaseModel):
|
||||||
label: str = ""
|
label: str = ""
|
||||||
componentType: str = "component"
|
componentType: str = "component"
|
||||||
modelType: str = "component"
|
modelType: str = "component"
|
||||||
ports: list[str] = Field(default_factory=list)
|
ports: list[ReactFlowPortDefinition | str] = Field(default_factory=list)
|
||||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
rotation: Literal[0, 90, 180, 270] = 0
|
||||||
|
mirrored: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ReactFlowNodePayload(BaseModel):
|
class ReactFlowNodePayload(BaseModel):
|
||||||
@@ -130,10 +157,11 @@ def frontend_asset(path: str) -> FileResponse:
|
|||||||
|
|
||||||
@app.post("/api/reactflow/system-xml")
|
@app.post("/api/reactflow/system-xml")
|
||||||
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
|
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
|
||||||
return Response(
|
try:
|
||||||
content=build_reactflow_system_xml(payload),
|
xml = build_reactflow_system_xml(payload)
|
||||||
media_type="application/xml",
|
except ValueError as exc:
|
||||||
)
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/reactflow/projects")
|
@app.get("/api/reactflow/projects")
|
||||||
@@ -191,6 +219,178 @@ def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str,
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/reactflow/compile-model")
|
||||||
|
def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
network = compile_reactflow_network(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"success": True, **network.as_interface_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system-xml/validate")
|
||||||
|
async def validate_system_xml(request: Request) -> dict[str, object]:
|
||||||
|
report = validate_system_xml_document(await request.body())
|
||||||
|
return report.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system-xml/parse")
|
||||||
|
async def parse_system_xml(request: Request) -> dict[str, object]:
|
||||||
|
report = validate_system_xml_document(await request.body())
|
||||||
|
document = _validated_xml_document_or_422(report)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"validation": report.as_dict(),
|
||||||
|
"project": document.as_project_data(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system-xml/compile-model")
|
||||||
|
async def compile_system_xml_model(request: Request) -> dict[str, object]:
|
||||||
|
report = validate_system_xml_document(await request.body())
|
||||||
|
document = _validated_xml_document_or_422(report)
|
||||||
|
project, network = _compile_xml_document_or_422(document)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"validation": report.as_dict(),
|
||||||
|
"simulation": pydantic_to_jsonable(project.simulation),
|
||||||
|
**network.as_interface_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system-xml/simulate")
|
||||||
|
async def simulate_system_xml(request: Request) -> dict[str, object]:
|
||||||
|
from PythonModels.core.algebraic import AlgebraicSolveError
|
||||||
|
from PythonModels.core.solver import SolveIVPConfig
|
||||||
|
from PythonModels.core.stream import StreamSolveError
|
||||||
|
from PythonModels.systems.generic import (
|
||||||
|
GenericFluidSystem,
|
||||||
|
SimulationPreparationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = validate_system_xml_document(await request.body())
|
||||||
|
document = _validated_xml_document_or_422(report)
|
||||||
|
project, network = _compile_xml_document_or_422(document)
|
||||||
|
try:
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
result = system.simulate(
|
||||||
|
SolveIVPConfig(
|
||||||
|
t_start=project.simulation.t_start,
|
||||||
|
t_stop=project.simulation.t_stop,
|
||||||
|
method=project.simulation.method,
|
||||||
|
max_step=project.simulation.max_step,
|
||||||
|
),
|
||||||
|
sample_step=project.simulation.step,
|
||||||
|
)
|
||||||
|
except SimulationPreparationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "The compiled model is not ready for simulation.",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "simulation",
|
||||||
|
**issue.as_dict(),
|
||||||
|
}
|
||||||
|
for issue in exc.issues
|
||||||
|
],
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except AlgebraicSolveError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(exc),
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "simulation",
|
||||||
|
"code": "PRESSURE_FLOW_SOLVE_FAILED",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnostics": exc.diagnostics.as_dict(),
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except StreamSolveError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(exc),
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "simulation",
|
||||||
|
"code": "STREAM_SOLVE_FAILED",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnostics": exc.diagnostics.as_dict(),
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Simulation failed while evaluating the compiled model.",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "simulation",
|
||||||
|
"code": "SIMULATION_EXECUTION_FAILED",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"validation": report.as_dict(),
|
||||||
|
"simulation": pydantic_to_jsonable(project.simulation),
|
||||||
|
"model": network.as_interface_dict(),
|
||||||
|
**result.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_xml_document_or_422(
|
||||||
|
report: SystemXmlValidationReport,
|
||||||
|
) -> SystemXmlDocument:
|
||||||
|
if not report.valid or report.document is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "System XML validation failed.",
|
||||||
|
"issues": [issue.as_dict() for issue in report.issues],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return report.document
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_xml_document_or_422(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
) -> tuple[ReactFlowProjectPayload, "SimulationNetwork"]:
|
||||||
|
project = ReactFlowProjectPayload(**document.as_project_data())
|
||||||
|
try:
|
||||||
|
network = compile_reactflow_network(project)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "System XML passed protocol validation but model compilation failed.",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "semantic",
|
||||||
|
"code": "MODEL_COMPILATION_FAILED",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
return project, network
|
||||||
|
|
||||||
|
|
||||||
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
||||||
system = ET.Element(
|
system = ET.Element(
|
||||||
"System",
|
"System",
|
||||||
@@ -213,6 +413,7 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
|||||||
)
|
)
|
||||||
components_node = ET.SubElement(system, "Components")
|
components_node = ET.SubElement(system, "Components")
|
||||||
connections_node = ET.SubElement(system, "Connections")
|
connections_node = ET.SubElement(system, "Connections")
|
||||||
|
port_index: dict[tuple[str, str], ReactFlowPortDefinition] = {}
|
||||||
|
|
||||||
for node in project.nodes:
|
for node in project.nodes:
|
||||||
component_node = ET.SubElement(
|
component_node = ET.SubElement(
|
||||||
@@ -225,10 +426,34 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
|||||||
"componentType": node.data.componentType,
|
"componentType": node.data.componentType,
|
||||||
"x": f"{node.position.x:g}",
|
"x": f"{node.position.x:g}",
|
||||||
"y": f"{node.position.y:g}",
|
"y": f"{node.position.y:g}",
|
||||||
|
"rotation": str(node.data.rotation),
|
||||||
|
"mirrored": str(node.data.mirrored).lower(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for port in node.data.ports:
|
for index, port in enumerate(node.data.ports):
|
||||||
ET.SubElement(component_node, "Port", {"name": port})
|
port_definition = normalize_port_definition(
|
||||||
|
port,
|
||||||
|
index=index,
|
||||||
|
component_type=node.data.componentType,
|
||||||
|
)
|
||||||
|
port_key = (node.id, port_definition.name)
|
||||||
|
if port_key in port_index:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {node.id} contains duplicate port {port_definition.name}."
|
||||||
|
)
|
||||||
|
port_index[port_key] = port_definition
|
||||||
|
port_attributes = {
|
||||||
|
"name": port_definition.name,
|
||||||
|
"kind": port_definition.kind,
|
||||||
|
"domain": port_definition.domain,
|
||||||
|
"nominalRole": port_definition.nominalRole,
|
||||||
|
"side": port_definition.side,
|
||||||
|
}
|
||||||
|
if port_definition.kind == "physical":
|
||||||
|
port_attributes["positiveFlowDirection"] = (
|
||||||
|
port_definition.positiveFlowDirection or "intoComponent"
|
||||||
|
)
|
||||||
|
ET.SubElement(component_node, "Port", port_attributes)
|
||||||
for name, value in node.data.parameters.items():
|
for name, value in node.data.parameters.items():
|
||||||
ET.SubElement(
|
ET.SubElement(
|
||||||
component_node,
|
component_node,
|
||||||
@@ -237,22 +462,200 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for edge in project.edges:
|
for edge in project.edges:
|
||||||
ET.SubElement(
|
first = require_connection_port(
|
||||||
|
port_index,
|
||||||
|
edge.source,
|
||||||
|
edge.sourceHandle,
|
||||||
|
edge.id,
|
||||||
|
)
|
||||||
|
second = require_connection_port(
|
||||||
|
port_index,
|
||||||
|
edge.target,
|
||||||
|
edge.targetHandle,
|
||||||
|
edge.id,
|
||||||
|
)
|
||||||
|
validate_compatible_ports(first, second, edge.id)
|
||||||
|
|
||||||
|
connection_node = ET.SubElement(
|
||||||
connections_node,
|
connections_node,
|
||||||
"Connection",
|
"Connection",
|
||||||
{
|
{
|
||||||
"id": edge.id,
|
"id": edge.id,
|
||||||
"source": edge.source,
|
"kind": first.kind,
|
||||||
"sourcePort": edge.sourceHandle or "",
|
"domain": first.domain,
|
||||||
"target": edge.target,
|
|
||||||
"targetPort": edge.targetHandle or "",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
endpoints = [
|
||||||
|
(edge.source, first, None),
|
||||||
|
(edge.target, second, None),
|
||||||
|
]
|
||||||
|
if first.kind == "signal":
|
||||||
|
if first.nominalRole == "input":
|
||||||
|
endpoints.reverse()
|
||||||
|
endpoints = [
|
||||||
|
(endpoints[0][0], endpoints[0][1], "source"),
|
||||||
|
(endpoints[1][0], endpoints[1][1], "target"),
|
||||||
|
]
|
||||||
|
for component_id, port, role in endpoints:
|
||||||
|
attributes = {"component": component_id, "port": port.name}
|
||||||
|
if role is not None:
|
||||||
|
attributes["role"] = role
|
||||||
|
ET.SubElement(connection_node, "Endpoint", attributes)
|
||||||
|
|
||||||
ET.indent(system, space=" ")
|
ET.indent(system, space=" ")
|
||||||
return ET.tostring(system, encoding="utf-8", xml_declaration=True)
|
return ET.tostring(system, encoding="utf-8", xml_declaration=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_port_definition(
|
||||||
|
port: ReactFlowPortDefinition | str,
|
||||||
|
*,
|
||||||
|
index: int,
|
||||||
|
component_type: str,
|
||||||
|
) -> ReactFlowPortDefinition:
|
||||||
|
if isinstance(port, ReactFlowPortDefinition):
|
||||||
|
return port
|
||||||
|
|
||||||
|
registered_legacy_ports: dict[str, dict[str, tuple[str, str]]] = {
|
||||||
|
"cylinder": {"port_b": ("outlet", "right")},
|
||||||
|
"tank": {"port_a": ("inlet", "left")},
|
||||||
|
"pipe": {
|
||||||
|
"port_a": ("inlet", "left"),
|
||||||
|
"port_b": ("outlet", "right"),
|
||||||
|
},
|
||||||
|
"orifice": {
|
||||||
|
"port_a": ("inlet", "left"),
|
||||||
|
"port_b": ("outlet", "right"),
|
||||||
|
},
|
||||||
|
"tee": {
|
||||||
|
"port_in": ("bidirectional", "left"),
|
||||||
|
"port_out1": ("bidirectional", "right"),
|
||||||
|
"port_out2": ("bidirectional", "right"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registered = registered_legacy_ports.get(component_type, {}).get(port)
|
||||||
|
if registered is not None:
|
||||||
|
nominal_role, side = registered
|
||||||
|
return ReactFlowPortDefinition(
|
||||||
|
name=port,
|
||||||
|
nominalRole=nominal_role,
|
||||||
|
positiveFlowDirection="intoComponent",
|
||||||
|
side=side,
|
||||||
|
)
|
||||||
|
|
||||||
|
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional"
|
||||||
|
if "out" in port or port == "port_b":
|
||||||
|
nominal_role = "outlet"
|
||||||
|
elif "in" in port or port == "port_a":
|
||||||
|
nominal_role = "inlet"
|
||||||
|
return ReactFlowPortDefinition(
|
||||||
|
name=port,
|
||||||
|
nominalRole=nominal_role,
|
||||||
|
positiveFlowDirection="intoComponent",
|
||||||
|
side="left" if index == 0 else "right",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_connection_port(
|
||||||
|
port_index: dict[tuple[str, str], ReactFlowPortDefinition],
|
||||||
|
component_id: str,
|
||||||
|
port_name: str | None,
|
||||||
|
connection_id: str,
|
||||||
|
) -> ReactFlowPortDefinition:
|
||||||
|
if port_name is None or (component_id, port_name) not in port_index:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connection {connection_id} references missing endpoint "
|
||||||
|
f"{component_id}.{port_name or '<empty>'}."
|
||||||
|
)
|
||||||
|
return port_index[(component_id, port_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compatible_ports(
|
||||||
|
first: ReactFlowPortDefinition,
|
||||||
|
second: ReactFlowPortDefinition,
|
||||||
|
connection_id: str,
|
||||||
|
) -> None:
|
||||||
|
if first.kind != second.kind:
|
||||||
|
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
|
||||||
|
if first.domain != second.domain:
|
||||||
|
raise ValueError(f"Connection {connection_id} connects incompatible domains.")
|
||||||
|
if first.kind == "signal" and {first.nominalRole, second.nominalRole} != {
|
||||||
|
"input",
|
||||||
|
"output",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Signal connection {connection_id} must connect one output to one input."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_reactflow_network(project: ReactFlowProjectPayload) -> "SimulationNetwork":
|
||||||
|
from PythonModels.core.medium import IdealGasMedium
|
||||||
|
from PythonModels.core.network import SimulationNetwork
|
||||||
|
from PythonModels.registry import get_component_model_spec
|
||||||
|
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
network = SimulationNetwork(name=project.name)
|
||||||
|
|
||||||
|
for node in project.nodes:
|
||||||
|
spec = get_component_model_spec(node.data.modelType)
|
||||||
|
parameter_values = {
|
||||||
|
parameter.name: parameter_float(node, parameter.name, parameter.default)
|
||||||
|
for parameter in spec.parameters
|
||||||
|
}
|
||||||
|
unknown_parameters = set(node.data.parameters) - set(spec.parameter_by_name)
|
||||||
|
if unknown_parameters:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{node.id}' contains unsupported parameters: "
|
||||||
|
+ ", ".join(sorted(unknown_parameters))
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
component = spec.create(node.id, medium, parameter_values)
|
||||||
|
validate_component_port_interface(node, component.port_definitions)
|
||||||
|
network.add_component(component)
|
||||||
|
|
||||||
|
for edge in project.edges:
|
||||||
|
network.connect(
|
||||||
|
edge.source,
|
||||||
|
edge.sourceHandle or "",
|
||||||
|
edge.target,
|
||||||
|
edge.targetHandle or "",
|
||||||
|
connection_id=edge.id,
|
||||||
|
)
|
||||||
|
return network
|
||||||
|
|
||||||
|
|
||||||
|
def validate_component_port_interface(
|
||||||
|
node: ReactFlowNodePayload,
|
||||||
|
component_ports: tuple["PortDefinition", ...],
|
||||||
|
) -> None:
|
||||||
|
payload_ports = [
|
||||||
|
normalize_port_definition(
|
||||||
|
port,
|
||||||
|
index=index,
|
||||||
|
component_type=node.data.componentType,
|
||||||
|
)
|
||||||
|
for index, port in enumerate(node.data.ports)
|
||||||
|
]
|
||||||
|
expected_by_name = {port.name: port for port in component_ports}
|
||||||
|
payload_by_name = {port.name: port for port in payload_ports}
|
||||||
|
if len(payload_by_name) != len(payload_ports):
|
||||||
|
raise ValueError(f"Component {node.id} contains duplicate port names.")
|
||||||
|
if set(payload_by_name) != set(expected_by_name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {node.id} port names do not match model {node.data.modelType}."
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, payload_port in payload_by_name.items():
|
||||||
|
expected = expected_by_name[name]
|
||||||
|
if payload_port.kind != expected.kind or payload_port.domain != expected.domain:
|
||||||
|
raise ValueError(f"Component {node.id}.{name} has an incompatible port type.")
|
||||||
|
if payload_port.nominalRole != expected.nominal_role:
|
||||||
|
raise ValueError(f"Component {node.id}.{name} has an incompatible nominal role.")
|
||||||
|
if expected.kind == "physical" and (
|
||||||
|
payload_port.positiveFlowDirection or "intoComponent"
|
||||||
|
) != expected.positive_flow_direction:
|
||||||
|
raise ValueError(f"Component {node.id}.{name} has an incompatible flow sign.")
|
||||||
|
|
||||||
|
|
||||||
def reactflow_project_path(project_id: str) -> Path:
|
def reactflow_project_path(project_id: str) -> Path:
|
||||||
safe_id = sanitize_project_id(project_id)
|
safe_id = sanitize_project_id(project_id)
|
||||||
return PROJECT_STORAGE_DIR / f"{safe_id}.json"
|
return PROJECT_STORAGE_DIR / f"{safe_id}.json"
|
||||||
|
|||||||
@@ -0,0 +1,844 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from math import isfinite
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from PythonModels.core.ports import PortDefinition
|
||||||
|
from PythonModels.registry import (
|
||||||
|
COMPONENT_MODEL_REGISTRY,
|
||||||
|
ParameterSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ValidationLayer = Literal["xml", "schema", "semantic"]
|
||||||
|
ValidationSeverity = Literal["error", "warning"]
|
||||||
|
SYSTEM_XML_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
SYSTEM_XML_V2_SCHEMA_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd"
|
||||||
|
)
|
||||||
|
SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationIssue:
|
||||||
|
layer: ValidationLayer
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: ValidationSeverity = "error"
|
||||||
|
path: str | None = None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"severity": self.severity,
|
||||||
|
"layer": self.layer,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
if self.path is not None:
|
||||||
|
result["path"] = self.path
|
||||||
|
if self.line is not None:
|
||||||
|
result["line"] = self.line
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlSimulation:
|
||||||
|
t_start: float
|
||||||
|
t_stop: float
|
||||||
|
step: float
|
||||||
|
max_step: float
|
||||||
|
method: str
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlPort:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
domain: str
|
||||||
|
nominal_role: str
|
||||||
|
positive_flow_direction: str | None
|
||||||
|
side: str
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
def as_project_data(self) -> dict[str, object]:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"name": self.name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"nominalRole": self.nominal_role,
|
||||||
|
"side": self.side,
|
||||||
|
}
|
||||||
|
if self.positive_flow_direction is not None:
|
||||||
|
data["positiveFlowDirection"] = self.positive_flow_direction
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlParameter:
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlComponent:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
model_type: str
|
||||||
|
component_type: str
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
rotation: int
|
||||||
|
mirrored: bool
|
||||||
|
ports: tuple[SystemXmlPort, ...]
|
||||||
|
parameters: tuple[SystemXmlParameter, ...]
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_by_name(self) -> dict[str, SystemXmlPort]:
|
||||||
|
return {port.name: port for port in self.ports}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlEndpoint:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
role: str | None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> tuple[str, str]:
|
||||||
|
return self.component, self.port
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlConnection:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
domain: str
|
||||||
|
endpoints: tuple[SystemXmlEndpoint, SystemXmlEndpoint]
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||||
|
first, second = sorted(endpoint.key for endpoint in self.endpoints)
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlDocument:
|
||||||
|
name: str
|
||||||
|
schema_version: str
|
||||||
|
unit_system: str
|
||||||
|
simulation: SystemXmlSimulation
|
||||||
|
components: tuple[SystemXmlComponent, ...]
|
||||||
|
connections: tuple[SystemXmlConnection, ...]
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"schemaVersion": self.schema_version,
|
||||||
|
"unitSystem": self.unit_system,
|
||||||
|
"componentCount": len(self.components),
|
||||||
|
"connectionCount": len(self.connections),
|
||||||
|
}
|
||||||
|
|
||||||
|
def as_project_data(self) -> dict[str, object]:
|
||||||
|
edges = []
|
||||||
|
for connection in self.connections:
|
||||||
|
first, second = connection.endpoints
|
||||||
|
if connection.kind == "signal":
|
||||||
|
by_role = {endpoint.role: endpoint for endpoint in connection.endpoints}
|
||||||
|
first = by_role.get("source", first)
|
||||||
|
second = by_role.get("target", second)
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": connection.id,
|
||||||
|
"source": first.component,
|
||||||
|
"sourceHandle": first.port,
|
||||||
|
"target": second.component,
|
||||||
|
"targetHandle": second.port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": component.id,
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": component.x, "y": component.y},
|
||||||
|
"data": {
|
||||||
|
"label": component.name,
|
||||||
|
"componentType": component.component_type,
|
||||||
|
"modelType": component.model_type,
|
||||||
|
"ports": [port.as_project_data() for port in component.ports],
|
||||||
|
"parameters": {
|
||||||
|
parameter.name: parameter.value
|
||||||
|
for parameter in component.parameters
|
||||||
|
},
|
||||||
|
"rotation": component.rotation,
|
||||||
|
"mirrored": component.mirrored,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for component in self.components
|
||||||
|
],
|
||||||
|
"edges": edges,
|
||||||
|
"simulation": {
|
||||||
|
"t_start": self.simulation.t_start,
|
||||||
|
"t_stop": self.simulation.t_stop,
|
||||||
|
"step": self.simulation.step,
|
||||||
|
"max_step": self.simulation.max_step,
|
||||||
|
"method": self.simulation.method,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlValidationReport:
|
||||||
|
document: SystemXmlDocument | None
|
||||||
|
issues: tuple[ValidationIssue, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid(self) -> bool:
|
||||||
|
return self.document is not None and not any(
|
||||||
|
issue.severity == "error" for issue in self.issues
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
errors = sum(issue.severity == "error" for issue in self.issues)
|
||||||
|
warnings = sum(issue.severity == "warning" for issue in self.issues)
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"valid": self.valid,
|
||||||
|
"errorCount": errors,
|
||||||
|
"warningCount": warnings,
|
||||||
|
"issues": [issue.as_dict() for issue in self.issues],
|
||||||
|
}
|
||||||
|
if self.document is not None:
|
||||||
|
result["system"] = self.document.summary()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_system_xml_document(
|
||||||
|
source: bytes | str,
|
||||||
|
) -> SystemXmlValidationReport:
|
||||||
|
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
||||||
|
if not xml_bytes.strip():
|
||||||
|
return _failed_report("xml", "XML_EMPTY", "The XML document is empty.")
|
||||||
|
if len(xml_bytes) > SYSTEM_XML_MAX_BYTES:
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_TOO_LARGE",
|
||||||
|
f"The XML document exceeds {SYSTEM_XML_MAX_BYTES} bytes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = etree.XMLParser(
|
||||||
|
resolve_entities=False,
|
||||||
|
no_network=True,
|
||||||
|
load_dtd=False,
|
||||||
|
recover=False,
|
||||||
|
huge_tree=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
root = etree.fromstring(xml_bytes, parser=parser)
|
||||||
|
except etree.XMLSyntaxError as exc:
|
||||||
|
line, _ = exc.position
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_SYNTAX_ERROR",
|
||||||
|
str(exc).split(", line", maxsplit=1)[0],
|
||||||
|
line=line,
|
||||||
|
)
|
||||||
|
|
||||||
|
if root.getroottree().docinfo.doctype:
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_DTD_NOT_ALLOWED",
|
||||||
|
"DTD and entity declarations are not allowed.",
|
||||||
|
line=root.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = _system_xml_v2_schema()
|
||||||
|
if not schema.validate(root):
|
||||||
|
issues = tuple(
|
||||||
|
ValidationIssue(
|
||||||
|
layer="schema",
|
||||||
|
code="XSD_VALIDATION_ERROR",
|
||||||
|
message=entry.message.strip(),
|
||||||
|
path=entry.path or None,
|
||||||
|
line=entry.line or None,
|
||||||
|
)
|
||||||
|
for entry in schema.error_log
|
||||||
|
)
|
||||||
|
return SystemXmlValidationReport(document=None, issues=issues)
|
||||||
|
|
||||||
|
document = _parse_validated_root(root)
|
||||||
|
issues = tuple(_semantic_issues(document))
|
||||||
|
return SystemXmlValidationReport(document=document, issues=issues)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _system_xml_v2_schema() -> etree.XMLSchema:
|
||||||
|
schema_document = etree.parse(str(SYSTEM_XML_V2_SCHEMA_PATH))
|
||||||
|
return etree.XMLSchema(schema_document)
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_report(
|
||||||
|
layer: ValidationLayer,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
line: int | None = None,
|
||||||
|
) -> SystemXmlValidationReport:
|
||||||
|
return SystemXmlValidationReport(
|
||||||
|
document=None,
|
||||||
|
issues=(ValidationIssue(layer=layer, code=code, message=message, line=line),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
|
||||||
|
simulation_element = root.find("Simulation")
|
||||||
|
components_element = root.find("Components")
|
||||||
|
connections_element = root.find("Connections")
|
||||||
|
assert simulation_element is not None
|
||||||
|
assert components_element is not None
|
||||||
|
assert connections_element is not None
|
||||||
|
|
||||||
|
simulation = SystemXmlSimulation(
|
||||||
|
t_start=float(simulation_element.get("tStart")),
|
||||||
|
t_stop=float(simulation_element.get("tStop")),
|
||||||
|
step=float(simulation_element.get("step")),
|
||||||
|
max_step=float(simulation_element.get("maxStep")),
|
||||||
|
method=str(simulation_element.get("method")),
|
||||||
|
line=simulation_element.sourceline,
|
||||||
|
)
|
||||||
|
components = tuple(
|
||||||
|
_parse_component(component) for component in components_element.findall("Component")
|
||||||
|
)
|
||||||
|
connections = tuple(
|
||||||
|
_parse_connection(connection)
|
||||||
|
for connection in connections_element.findall("Connection")
|
||||||
|
)
|
||||||
|
return SystemXmlDocument(
|
||||||
|
name=str(root.get("name")),
|
||||||
|
schema_version=str(root.get("schemaVersion")),
|
||||||
|
unit_system=str(root.get("unitSystem")),
|
||||||
|
simulation=simulation,
|
||||||
|
components=components,
|
||||||
|
connections=connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_component(element: etree._Element) -> SystemXmlComponent:
|
||||||
|
ports = tuple(
|
||||||
|
SystemXmlPort(
|
||||||
|
name=str(port.get("name")),
|
||||||
|
kind=str(port.get("kind")),
|
||||||
|
domain=str(port.get("domain")),
|
||||||
|
nominal_role=str(port.get("nominalRole")),
|
||||||
|
positive_flow_direction=port.get("positiveFlowDirection"),
|
||||||
|
side=str(port.get("side")),
|
||||||
|
line=port.sourceline,
|
||||||
|
)
|
||||||
|
for port in element.findall("Port")
|
||||||
|
)
|
||||||
|
parameters = tuple(
|
||||||
|
SystemXmlParameter(
|
||||||
|
name=str(parameter.get("name")),
|
||||||
|
value=float(parameter.get("value")),
|
||||||
|
line=parameter.sourceline,
|
||||||
|
)
|
||||||
|
for parameter in element.findall("Parameter")
|
||||||
|
)
|
||||||
|
return SystemXmlComponent(
|
||||||
|
id=str(element.get("id")),
|
||||||
|
name=str(element.get("name")),
|
||||||
|
model_type=str(element.get("type")),
|
||||||
|
component_type=str(element.get("componentType")),
|
||||||
|
x=float(element.get("x")),
|
||||||
|
y=float(element.get("y")),
|
||||||
|
rotation=int(element.get("rotation", "0")),
|
||||||
|
mirrored=element.get("mirrored", "false") in {"true", "1"},
|
||||||
|
ports=ports,
|
||||||
|
parameters=parameters,
|
||||||
|
line=element.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_connection(element: etree._Element) -> SystemXmlConnection:
|
||||||
|
endpoints = tuple(
|
||||||
|
SystemXmlEndpoint(
|
||||||
|
component=str(endpoint.get("component")),
|
||||||
|
port=str(endpoint.get("port")),
|
||||||
|
role=endpoint.get("role"),
|
||||||
|
line=endpoint.sourceline,
|
||||||
|
)
|
||||||
|
for endpoint in element.findall("Endpoint")
|
||||||
|
)
|
||||||
|
assert len(endpoints) == 2
|
||||||
|
return SystemXmlConnection(
|
||||||
|
id=str(element.get("id")),
|
||||||
|
kind=str(element.get("kind")),
|
||||||
|
domain=str(element.get("domain")),
|
||||||
|
endpoints=(endpoints[0], endpoints[1]),
|
||||||
|
line=element.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]:
|
||||||
|
issues: list[ValidationIssue] = []
|
||||||
|
_validate_system_and_simulation(document, issues)
|
||||||
|
component_by_id = _validate_components(document, issues)
|
||||||
|
_validate_connections(document, component_by_id, issues)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_system_and_simulation(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
if not document.name.strip():
|
||||||
|
issues.append(_semantic_issue("SYSTEM_NAME_EMPTY", "System name cannot be blank.", "/System"))
|
||||||
|
if not document.components:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SYSTEM_HAS_NO_COMPONENTS",
|
||||||
|
"The system must contain at least one component.",
|
||||||
|
"/System/Components",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation = document.simulation
|
||||||
|
values = {
|
||||||
|
"tStart": simulation.t_start,
|
||||||
|
"tStop": simulation.t_stop,
|
||||||
|
"step": simulation.step,
|
||||||
|
"maxStep": simulation.max_step,
|
||||||
|
}
|
||||||
|
for name, value in values.items():
|
||||||
|
if not isfinite(value):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_VALUE_NOT_FINITE",
|
||||||
|
f"Simulation value {name} must be finite.",
|
||||||
|
f"/System/Simulation/@{name}",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isfinite(simulation.t_start) and isfinite(simulation.t_stop):
|
||||||
|
if simulation.t_stop <= simulation.t_start:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_TIME_RANGE_INVALID",
|
||||||
|
"Simulation tStop must be greater than tStart.",
|
||||||
|
"/System/Simulation",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name, value in {
|
||||||
|
"step": simulation.step,
|
||||||
|
"maxStep": simulation.max_step,
|
||||||
|
}.items():
|
||||||
|
if isfinite(value) and value <= 0.0:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_STEP_INVALID",
|
||||||
|
f"Simulation value {name} must be greater than zero.",
|
||||||
|
f"/System/Simulation/@{name}",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if simulation.method not in SUPPORTED_SOLVER_METHODS:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_METHOD_UNSUPPORTED",
|
||||||
|
f"Unsupported solver method: {simulation.method}.",
|
||||||
|
"/System/Simulation/@method",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_components(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> dict[str, SystemXmlComponent]:
|
||||||
|
component_by_id: dict[str, SystemXmlComponent] = {}
|
||||||
|
names: dict[str, str] = {}
|
||||||
|
for index, component in enumerate(document.components, start=1):
|
||||||
|
path = f"/System/Components/Component[{index}]"
|
||||||
|
if component.id in component_by_id:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_ID_DUPLICATE",
|
||||||
|
f"Duplicate component id: {component.id}.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
component_by_id[component.id] = component
|
||||||
|
|
||||||
|
if component.name in names:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_NAME_DUPLICATE",
|
||||||
|
f"Duplicate component name: {component.name}.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
names[component.name] = component.id
|
||||||
|
|
||||||
|
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||||
|
if spec is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_TYPE_UNSUPPORTED",
|
||||||
|
f"Unsupported component model type: {component.model_type}.",
|
||||||
|
f"{path}/@type",
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if component.component_type != component.model_type:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_TYPE_MISMATCH",
|
||||||
|
f"componentType '{component.component_type}' does not match model type '{component.model_type}'.",
|
||||||
|
f"{path}/@componentType",
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not isfinite(component.x) or not isfinite(component.y):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_POSITION_NOT_FINITE",
|
||||||
|
f"Component {component.id} position must be finite.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_validate_component_ports(component, spec.ports, path, issues)
|
||||||
|
_validate_component_parameters(component, spec.parameter_by_name, path, issues)
|
||||||
|
return component_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_component_ports(
|
||||||
|
component: SystemXmlComponent,
|
||||||
|
expected_ports: tuple[PortDefinition, ...],
|
||||||
|
component_path: str,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
actual_by_name: dict[str, SystemXmlPort] = {}
|
||||||
|
for port_index, port in enumerate(component.ports, start=1):
|
||||||
|
path = f"{component_path}/Port[{port_index}]"
|
||||||
|
if port.name in actual_by_name:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_NAME_DUPLICATE",
|
||||||
|
f"Component {component.id} contains duplicate port {port.name}.",
|
||||||
|
path,
|
||||||
|
port.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actual_by_name[port.name] = port
|
||||||
|
|
||||||
|
expected_by_name = {port.name: port for port in expected_ports}
|
||||||
|
for name in sorted(set(expected_by_name) - set(actual_by_name)):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_REQUIRED_MISSING",
|
||||||
|
f"Component {component.id} is missing registered port {name}.",
|
||||||
|
component_path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) - set(expected_by_name)):
|
||||||
|
port = actual_by_name[name]
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_UNSUPPORTED",
|
||||||
|
f"Component {component.id} contains unsupported port {name}.",
|
||||||
|
component_path,
|
||||||
|
port.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(set(actual_by_name) & set(expected_by_name)):
|
||||||
|
actual = actual_by_name[name]
|
||||||
|
expected = expected_by_name[name]
|
||||||
|
path = f"{component_path}/Port[@name='{name}']"
|
||||||
|
if actual.kind != expected.kind or actual.domain != expected.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_INTERFACE_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} has an incompatible kind or domain.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if actual.nominal_role != expected.nominal_role:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_NOMINAL_ROLE_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} has nominalRole '{actual.nominal_role}', expected '{expected.nominal_role}'.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if actual.kind == "physical" and (
|
||||||
|
actual.positive_flow_direction != expected.positive_flow_direction
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_FLOW_SIGN_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} must use positiveFlowDirection='intoComponent'.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_component_parameters(
|
||||||
|
component: SystemXmlComponent,
|
||||||
|
expected_parameters: Mapping[str, ParameterSpec],
|
||||||
|
component_path: str,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
actual_by_name: dict[str, SystemXmlParameter] = {}
|
||||||
|
for parameter_index, parameter in enumerate(component.parameters, start=1):
|
||||||
|
path = f"{component_path}/Parameter[{parameter_index}]"
|
||||||
|
if parameter.name in actual_by_name:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_NAME_DUPLICATE",
|
||||||
|
f"Component {component.id} contains duplicate parameter {parameter.name}.",
|
||||||
|
path,
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actual_by_name[parameter.name] = parameter
|
||||||
|
|
||||||
|
for name in sorted(set(expected_parameters) - set(actual_by_name)):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_REQUIRED_MISSING",
|
||||||
|
f"Component {component.id} is missing required parameter {name}.",
|
||||||
|
component_path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) - set(expected_parameters)):
|
||||||
|
parameter = actual_by_name[name]
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_UNSUPPORTED",
|
||||||
|
f"Component {component.id} contains unsupported parameter {name}.",
|
||||||
|
component_path,
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) & set(expected_parameters)):
|
||||||
|
parameter = actual_by_name[name]
|
||||||
|
message = expected_parameters[name].validation_message(parameter.value)
|
||||||
|
if message is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_VALUE_INVALID",
|
||||||
|
f"Parameter {component.id}.{name} {message}.",
|
||||||
|
f"{component_path}/Parameter[@name='{name}']",
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_connections(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
component_by_id: dict[str, SystemXmlComponent],
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
connection_ids: set[str] = set()
|
||||||
|
connection_keys: set[tuple[tuple[str, str], tuple[str, str]]] = set()
|
||||||
|
occupied_physical_ports: dict[tuple[str, str], str] = {}
|
||||||
|
referenced_ports: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for index, connection in enumerate(document.connections, start=1):
|
||||||
|
path = f"/System/Connections/Connection[{index}]"
|
||||||
|
if connection.id in connection_ids:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_ID_DUPLICATE",
|
||||||
|
f"Duplicate connection id: {connection.id}.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection_ids.add(connection.id)
|
||||||
|
|
||||||
|
if connection.undirected_key in connection_keys:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_DUPLICATE",
|
||||||
|
f"Connection {connection.id} duplicates an existing endpoint pair.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection_keys.add(connection.undirected_key)
|
||||||
|
|
||||||
|
if connection.endpoints[0].key == connection.endpoints[1].key:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_SELF_REFERENCE",
|
||||||
|
f"Connection {connection.id} connects an endpoint to itself.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_endpoints: list[tuple[SystemXmlEndpoint, SystemXmlPort]] = []
|
||||||
|
for endpoint_index, endpoint in enumerate(connection.endpoints, start=1):
|
||||||
|
endpoint_path = f"{path}/Endpoint[{endpoint_index}]"
|
||||||
|
component = component_by_id.get(endpoint.component)
|
||||||
|
if component is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"ENDPOINT_COMPONENT_UNKNOWN",
|
||||||
|
f"Connection {connection.id} references unknown component {endpoint.component}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
port = component.port_by_name.get(endpoint.port)
|
||||||
|
if port is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"ENDPOINT_PORT_UNKNOWN",
|
||||||
|
f"Connection {connection.id} references unknown port {endpoint.component}.{endpoint.port}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
resolved_endpoints.append((endpoint, port))
|
||||||
|
referenced_ports.add(endpoint.key)
|
||||||
|
if port.kind != connection.kind or port.domain != connection.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_INTERFACE_MISMATCH",
|
||||||
|
f"Connection {connection.id} kind/domain does not match {endpoint.component}.{endpoint.port}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if connection.kind == "physical":
|
||||||
|
if endpoint.role is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PHYSICAL_ENDPOINT_HAS_ROLE",
|
||||||
|
f"Physical endpoint {endpoint.component}.{endpoint.port} must not declare a source/target role.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previous = occupied_physical_ports.get(endpoint.key)
|
||||||
|
if previous is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PHYSICAL_PORT_ALREADY_CONNECTED",
|
||||||
|
f"Physical port {endpoint.component}.{endpoint.port} is already used by connection {previous}; use a Tee for branching.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
occupied_physical_ports[endpoint.key] = connection.id
|
||||||
|
|
||||||
|
if len(resolved_endpoints) == 2:
|
||||||
|
first_port = resolved_endpoints[0][1]
|
||||||
|
second_port = resolved_endpoints[1][1]
|
||||||
|
if first_port.kind != second_port.kind:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_MIXES_PORT_KINDS",
|
||||||
|
f"Connection {connection.id} mixes physical and signal ports.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if first_port.domain != second_port.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_DOMAIN_MISMATCH",
|
||||||
|
f"Connection {connection.id} connects different physical domains.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if connection.kind == "signal":
|
||||||
|
roles = {endpoint.role for endpoint in connection.endpoints}
|
||||||
|
if roles != {"source", "target"}:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIGNAL_ENDPOINT_ROLES_INVALID",
|
||||||
|
f"Signal connection {connection.id} must contain source and target roles.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for endpoint, port in resolved_endpoints:
|
||||||
|
expected_role = "source" if port.nominal_role == "output" else "target"
|
||||||
|
if endpoint.role != expected_role:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIGNAL_DIRECTION_MISMATCH",
|
||||||
|
f"Signal endpoint {endpoint.component}.{endpoint.port} has role '{endpoint.role}', expected '{expected_role}'.",
|
||||||
|
path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for component in document.components:
|
||||||
|
for port in component.ports:
|
||||||
|
if (component.id, port.name) not in referenced_ports:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_UNCONNECTED",
|
||||||
|
f"Port {component.id}.{port.name} is not connected.",
|
||||||
|
f"/System/Components/Component[@id='{component.id}']/Port[@name='{port.name}']",
|
||||||
|
port.line,
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_issue(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
path: str,
|
||||||
|
line: int | None = None,
|
||||||
|
*,
|
||||||
|
severity: ValidationSeverity = "error",
|
||||||
|
) -> ValidationIssue:
|
||||||
|
return ValidationIssue(
|
||||||
|
layer="semantic",
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
severity=severity,
|
||||||
|
path=path,
|
||||||
|
line=line,
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# System XML v1 协议
|
# System XML v1 协议
|
||||||
|
|
||||||
|
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
|
||||||
|
|
||||||
System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换格式。
|
System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换格式。
|
||||||
|
|
||||||
## 基本约定
|
## 基本约定
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# System XML v2 协议
|
||||||
|
|
||||||
|
System XML v2 是 ReactFlow 建模前端与 PythonModels 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
|
||||||
|
|
||||||
|
## 基本约定
|
||||||
|
|
||||||
|
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
|
||||||
|
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||||
|
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
|
||||||
|
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
|
||||||
|
- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。
|
||||||
|
- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。
|
||||||
|
- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<System name="transfer-system" schemaVersion="2" unitSystem="SI">
|
||||||
|
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
|
||||||
|
<Components>
|
||||||
|
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
|
||||||
|
<Port name="port_b" kind="physical" domain="pneumatic" nominalRole="outlet" positiveFlowDirection="intoComponent" side="right"/>
|
||||||
|
<Parameter name="volume" value="0.01"/>
|
||||||
|
<Parameter name="p0" value="35000000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180" rotation="0" mirrored="false">
|
||||||
|
<Port name="port_a" kind="physical" domain="pneumatic" nominalRole="inlet" positiveFlowDirection="intoComponent" side="left"/>
|
||||||
|
<Parameter name="volume" value="0.1"/>
|
||||||
|
<Parameter name="p0" value="100000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
</Components>
|
||||||
|
<Connections>
|
||||||
|
<Connection id="edge-1" kind="physical" domain="pneumatic">
|
||||||
|
<Endpoint component="cylinder_1" port="port_b"/>
|
||||||
|
<Endpoint component="tank_1" port="port_a"/>
|
||||||
|
</Connection>
|
||||||
|
</Connections>
|
||||||
|
</System>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port
|
||||||
|
|
||||||
|
组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。
|
||||||
|
|
||||||
|
| 属性 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `name` | 组件模型中的稳定端口名 |
|
||||||
|
| `kind` | `physical` 或 `signal` |
|
||||||
|
| `domain` | 端口物理域,当前流体组件使用 `pneumatic` |
|
||||||
|
| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` |
|
||||||
|
| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 |
|
||||||
|
| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 |
|
||||||
|
|
||||||
|
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
|
||||||
|
|
||||||
|
三通的三个端口当前保留 PythonModels 已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
物理连接包含两个无序 `Endpoint`。第一个端点不代表上游,第二个端点也不代表下游;交换二者顺序不得改变仿真结果。
|
||||||
|
|
||||||
|
信号连接也使用两个 `Endpoint`,但必须分别携带 `role="source"` 和 `role="target"`。信号端口只允许 `output` 与 `input` 相连。
|
||||||
|
|
||||||
|
连接生成前必须验证:
|
||||||
|
|
||||||
|
- 组件和端口存在。
|
||||||
|
- 两端 `kind` 相同。
|
||||||
|
- 两端 `domain` 相同。
|
||||||
|
- 信号连接一端为 `output`,另一端为 `input`。
|
||||||
|
|
||||||
|
## v1 迁移
|
||||||
|
|
||||||
|
- v1 的字符串端口在加载时按组件注册表迁移成 v2 端口对象。
|
||||||
|
- v1 的 `source/sourcePort/target/targetPort` 在导出 v2 时转换为两个 `Endpoint`。
|
||||||
|
- 物理连接不继承 v1 的 source/target 方向。
|
||||||
|
- v1 文件仍由原 XSD 描述;新生成文件只输出 v2。
|
||||||
|
|
||||||
|
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
|
||||||
|
|
||||||
|
## PythonModels 编译接口
|
||||||
|
|
||||||
|
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
|
||||||
|
|
||||||
|
1. 按 `node.data.modelType` 创建 PythonModels 组件实例,并写入 SI 参数。
|
||||||
|
2. 将前端端口声明与组件注册端口逐项比对。
|
||||||
|
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
|
||||||
|
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
|
||||||
|
|
||||||
|
成功响应中的物理连接只包含两个 `endpoints`,不包含 `source/target`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"name": "transfer-system",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "cylinder_1",
|
||||||
|
"type": "cylinder",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"name": "port_b",
|
||||||
|
"kind": "physical",
|
||||||
|
"domain": "pneumatic",
|
||||||
|
"nominalRole": "outlet",
|
||||||
|
"positiveFlowDirection": "intoComponent",
|
||||||
|
"variables": [
|
||||||
|
{"name": "p", "role": "effort", "connectionRule": "equal"},
|
||||||
|
{"name": "m_flow", "role": "flow", "connectionRule": "sumToZero"},
|
||||||
|
{"name": "h_outflow", "role": "stream", "connectionRule": "streamMix"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"id": "edge-1",
|
||||||
|
"kind": "physical",
|
||||||
|
"domain": "pneumatic",
|
||||||
|
"endpoints": [
|
||||||
|
{"component": "cylinder_1", "port": "port_b"},
|
||||||
|
{"component": "tank_1", "port": "port_a"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unconnectedPorts": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一个物理端口当前只允许一条连接;需要分支时必须显式放置 `Tee` 等结点组件。这样拓扑不会通过“一个端口连多条线”隐式产生结点方程。
|
||||||
|
|
||||||
|
此接口完成模型实例化、端口契约校验、拓扑编译和压力-流量方程结构组装。编译结果中的 `pressureFlowSystem` 包含未知量、方程、数量及 `isSquare` 状态;方阵只表示结构数量平衡,不代表方程一定可解。
|
||||||
|
|
||||||
|
当前组件已提供可执行残差:气瓶和贮箱提供状态-压力约束,孔板提供流量守恒和压差-流量本构关系,三通提供等压零结点和流量守恒。XML 注册表中的管段使用准稳态 Darcy 阻性模型,同时提供流量守恒和双向压降关系。连接层根据端口契约生成 `p` 相等及 `m_flow` 代数和为零的残差。
|
||||||
|
|
||||||
|
`/api/reactflow/simulate-testmodel` 继续保留固定 TestModel 和动态管段,用于已有基线对比。XML 驱动仿真使用独立的通用半显式求解链路,不调用固定 TestModel 闭合器。
|
||||||
|
|
||||||
|
## 第二阶段:XML 解析与校验
|
||||||
|
|
||||||
|
第二阶段已经实现从 System XML v2 回到 PythonModels 网络的完整入口。解析过程固定分为三层:
|
||||||
|
|
||||||
|
| 层级 | `layer` | 负责内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| XML | `xml` | 文档大小、XML 语法、禁止 DTD 和实体声明 |
|
||||||
|
| XSD | `schema` | v2 版本、元素顺序、必填属性、枚举、基础数值类型 |
|
||||||
|
| 模型语义 | `semantic` | 组件注册、端口契约、参数集合和范围、端点引用、物理域、连接占用及仿真设置 |
|
||||||
|
|
||||||
|
校验诊断统一包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "semantic",
|
||||||
|
"code": "ENDPOINT_PORT_UNKNOWN",
|
||||||
|
"message": "Connection edge-1 references unknown port tank_1.port_x.",
|
||||||
|
"path": "/System/Connections/Connection[1]/Endpoint[2]",
|
||||||
|
"line": 18
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
未连接端口使用 `PORT_UNCONNECTED` 警告,不会阻止解析和网络编译;结构错误、接口不一致、参数错误和非法拓扑会使 `valid=false`。
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
四个接口均直接接收 `Content-Type: application/xml` 的原始 XML 请求体:
|
||||||
|
|
||||||
|
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
|
||||||
|
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。
|
||||||
|
- `POST /api/system-xml/compile-model`:成功时返回 PythonModels 网络、仿真设置和校验报告;失败时返回 HTTP `422`。
|
||||||
|
- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri http://127.0.0.1:8000/api/system-xml/validate `
|
||||||
|
-ContentType application/xml `
|
||||||
|
-InFile .\test\system.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
组件参数和端口定义集中在 `PythonModels/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
|
||||||
|
|
||||||
|
## 第三阶段:XML 驱动仿真 MVP
|
||||||
|
|
||||||
|
第三阶段当前已经打通:
|
||||||
|
|
||||||
|
1. XML 中的组件、参数和无方向物理连接编译成 PythonModels 网络。
|
||||||
|
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
|
||||||
|
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
|
||||||
|
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
|
||||||
|
5. 动态组件自动拼装质量及内能导数,使用 XML 的 `tStart/tStop/step/maxStep/method` 开展积分。
|
||||||
|
6. 结果包含动态组件的 `m/U/p/T/rho/u/h`,以及全部物理端口的 `p/m_flow/h_outflow` 时间序列。
|
||||||
|
|
||||||
|
当前限制:
|
||||||
|
|
||||||
|
- 只支持注册表中的气动物理组件,不支持信号端口仿真。
|
||||||
|
- 所有物理端口在运行前必须完成连接;分支必须显式使用三通。
|
||||||
|
- 每个独立物理网络必须包含至少一个气瓶或贮箱作为压力和焓的储能锚点。
|
||||||
|
- 两个储能组件不能通过理想连接或纯三通直接耦合,必须在中间放置孔板或管段。
|
||||||
|
- XML 管段当前是准稳态阻性元件,`p0/T0` 用于名义密度和初始代数猜测,不包含管内储气动态。
|
||||||
|
- 当前 stream 混合是适合 MVP 的正则化近似,还不是 Modelica `inStream/actualStream` 的严格复刻。
|
||||||
|
- 当前是半显式 ODE/代数求解链路,不支持一般高指数 DAE 和事件系统。
|
||||||
|
|
||||||
|
运行示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri http://127.0.0.1:8000/api/system-xml/simulate `
|
||||||
|
-ContentType application/xml `
|
||||||
|
-InFile .\test\system.xml
|
||||||
|
```
|
||||||
+729
-94
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,932 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
DragEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Background,
|
||||||
|
ConnectionLineType,
|
||||||
|
ConnectionMode,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
ReactFlow,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
type NodeTypes,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
WorkspaceViewTabs,
|
||||||
|
type WorkspaceView,
|
||||||
|
} from "./WorkspaceViewTabs";
|
||||||
|
|
||||||
|
type ResultPortDefinition = {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
domain: string;
|
||||||
|
nominalRole: string;
|
||||||
|
positiveFlowDirection?: string;
|
||||||
|
side: "left" | "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultNodeData = {
|
||||||
|
label: string;
|
||||||
|
componentType: string;
|
||||||
|
modelType: string;
|
||||||
|
ports: ResultPortDefinition[];
|
||||||
|
parameters: Record<string, number | string>;
|
||||||
|
parameterUnits?: Record<string, string>;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
mirrored: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultProjectNode = {
|
||||||
|
id: string;
|
||||||
|
type: "simulationComponent";
|
||||||
|
position: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
data: ResultNodeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultProjectEdge = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceHandle: string | null;
|
||||||
|
targetHandle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultSimulationConfig = {
|
||||||
|
t_start: number;
|
||||||
|
t_stop: number;
|
||||||
|
step: number;
|
||||||
|
max_step: number;
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultPayload = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
final: Record<string, number>;
|
||||||
|
series: Record<string, number[]>;
|
||||||
|
diagnostics: {
|
||||||
|
pressureFlow: {
|
||||||
|
solveCount: number;
|
||||||
|
maxScaledResidual: number;
|
||||||
|
maxEvaluationsPerSolve: number;
|
||||||
|
};
|
||||||
|
stream: {
|
||||||
|
maxIterationsPerSolve: number;
|
||||||
|
};
|
||||||
|
stateCount: number;
|
||||||
|
sampleCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SimulationResultsSnapshot = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
project: {
|
||||||
|
name: string;
|
||||||
|
nodes: ResultProjectNode[];
|
||||||
|
edges: ResultProjectEdge[];
|
||||||
|
simulation: ResultSimulationConfig;
|
||||||
|
};
|
||||||
|
result: ResultPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SimulationResultsViewProps = {
|
||||||
|
snapshot: SimulationResultsSnapshot;
|
||||||
|
nodeTypes: NodeTypes;
|
||||||
|
onViewChange: (view: WorkspaceView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmptySimulationResultsViewProps = {
|
||||||
|
onViewChange: (view: WorkspaceView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultVariable = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
group: "component" | "port";
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartWindow = {
|
||||||
|
id: string;
|
||||||
|
variableKey: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowDragState = {
|
||||||
|
windowId: string;
|
||||||
|
pointerId: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_VARIABLE_MIME = "application/x-system-simulation-variable";
|
||||||
|
const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:";
|
||||||
|
const CHART_WINDOW_WIDTH = 420;
|
||||||
|
const CHART_WINDOW_HEIGHT = 260;
|
||||||
|
const CHART_COLORS = [
|
||||||
|
"#1d6fb8",
|
||||||
|
"#c2413b",
|
||||||
|
"#2f7d55",
|
||||||
|
"#b36b16",
|
||||||
|
"#6b5ca5",
|
||||||
|
"#087f8c",
|
||||||
|
"#9a4f76",
|
||||||
|
];
|
||||||
|
|
||||||
|
const variableDefinitions: Record<string, { label: string; unit: string }> = {
|
||||||
|
p: { label: "压力", unit: "Pa" },
|
||||||
|
T: { label: "温度", unit: "K" },
|
||||||
|
m: { label: "质量", unit: "kg" },
|
||||||
|
U: { label: "内能", unit: "J" },
|
||||||
|
rho: { label: "密度", unit: "kg/m³" },
|
||||||
|
u: { label: "比内能", unit: "J/kg" },
|
||||||
|
h: { label: "比焓", unit: "J/kg" },
|
||||||
|
m_flow: { label: "质量流量", unit: "kg/s" },
|
||||||
|
h_outflow: { label: "流出比焓", unit: "J/kg" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SimulationResultsView({
|
||||||
|
snapshot,
|
||||||
|
nodeTypes,
|
||||||
|
onViewChange,
|
||||||
|
}: SimulationResultsViewProps) {
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
|
||||||
|
snapshot.project.nodes[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
const [chartWindows, setChartWindows] = useState<ChartWindow[]>(() =>
|
||||||
|
loadChartWindows(snapshot.id),
|
||||||
|
);
|
||||||
|
const workspaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragStateRef = useRef<WindowDragState | null>(null);
|
||||||
|
const nextWindowIdRef = useRef(nextChartWindowNumber(chartWindows));
|
||||||
|
const topZIndexRef = useRef(
|
||||||
|
Math.max(1, ...chartWindows.map((window) => window.zIndex)),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storeChartWindows(snapshot.id, chartWindows);
|
||||||
|
}, [chartWindows, snapshot.id]);
|
||||||
|
|
||||||
|
const selectedNode =
|
||||||
|
snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||||||
|
const readOnlyNodes = useMemo<Node<ResultNodeData, "simulationComponent">[]>(
|
||||||
|
() =>
|
||||||
|
snapshot.project.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
className: "results-readonly-node",
|
||||||
|
connectable: false,
|
||||||
|
draggable: false,
|
||||||
|
focusable: true,
|
||||||
|
selectable: true,
|
||||||
|
selected: node.id === selectedNodeId,
|
||||||
|
})),
|
||||||
|
[selectedNodeId, snapshot.project.nodes],
|
||||||
|
);
|
||||||
|
const readOnlyEdges = useMemo<Edge[]>(
|
||||||
|
() =>
|
||||||
|
snapshot.project.edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
focusable: false,
|
||||||
|
selectable: false,
|
||||||
|
type: "step",
|
||||||
|
})),
|
||||||
|
[snapshot.project.edges],
|
||||||
|
);
|
||||||
|
const selectedVariables = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedNode
|
||||||
|
? resultVariablesForNode(
|
||||||
|
snapshot.result.series,
|
||||||
|
selectedNode.id,
|
||||||
|
selectedNode.data.ports,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[selectedNode, snapshot.result.series],
|
||||||
|
);
|
||||||
|
const componentVariables = selectedVariables.filter(
|
||||||
|
(variable) => variable.group === "component",
|
||||||
|
);
|
||||||
|
const portVariables = selectedVariables.filter(
|
||||||
|
(variable) => variable.group === "port",
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusChartWindow = (windowId: string) => {
|
||||||
|
const zIndex = ++topZIndexRef.current;
|
||||||
|
setChartWindows((current) =>
|
||||||
|
current.map((window) =>
|
||||||
|
window.id === windowId ? { ...window, zIndex } : window,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChartWindow = (
|
||||||
|
variableKey: string,
|
||||||
|
requestedPosition?: { x: number; y: number },
|
||||||
|
) => {
|
||||||
|
const workspace = workspaceRef.current;
|
||||||
|
if (!workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = chartWindows.find(
|
||||||
|
(window) => window.variableKey === variableKey,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
focusChartWindow(existing.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = workspace.getBoundingClientRect();
|
||||||
|
const index = chartWindows.length;
|
||||||
|
const fallback = {
|
||||||
|
x: 18 + (index % 3) * 26,
|
||||||
|
y: 18 + (index % 4) * 26,
|
||||||
|
};
|
||||||
|
const position = requestedPosition ?? fallback;
|
||||||
|
const zIndex = ++topZIndexRef.current;
|
||||||
|
setChartWindows((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `chart-${nextWindowIdRef.current++}`,
|
||||||
|
variableKey,
|
||||||
|
x: clampWindowCoordinate(
|
||||||
|
position.x,
|
||||||
|
bounds.width,
|
||||||
|
CHART_WINDOW_WIDTH,
|
||||||
|
),
|
||||||
|
y: clampWindowCoordinate(
|
||||||
|
position.y,
|
||||||
|
bounds.height,
|
||||||
|
CHART_WINDOW_HEIGHT,
|
||||||
|
),
|
||||||
|
zIndex,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableDragStart = (
|
||||||
|
event: DragEvent<HTMLButtonElement>,
|
||||||
|
variableKey: string,
|
||||||
|
) => {
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData(RESULT_VARIABLE_MIME, variableKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWorkspaceDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME);
|
||||||
|
const workspace = workspaceRef.current;
|
||||||
|
if (!variableKey || !workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = workspace.getBoundingClientRect();
|
||||||
|
addChartWindow(variableKey, {
|
||||||
|
x: event.clientX - bounds.left - CHART_WINDOW_WIDTH / 2,
|
||||||
|
y: event.clientY - bounds.top - 34,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startWindowDrag = (
|
||||||
|
event: ReactPointerEvent<HTMLDivElement>,
|
||||||
|
chartWindow: ChartWindow,
|
||||||
|
) => {
|
||||||
|
const windowElement = event.currentTarget.closest(".result-chart-window");
|
||||||
|
if (!(windowElement instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = windowElement.getBoundingClientRect();
|
||||||
|
dragStateRef.current = {
|
||||||
|
windowId: chartWindow.id,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
offsetX: event.clientX - bounds.left,
|
||||||
|
offsetY: event.clientY - bounds.top,
|
||||||
|
};
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
focusChartWindow(chartWindow.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveChartWindow = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
const workspace = workspaceRef.current;
|
||||||
|
if (
|
||||||
|
!dragState ||
|
||||||
|
dragState.pointerId !== event.pointerId ||
|
||||||
|
!workspace
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = workspace.getBoundingClientRect();
|
||||||
|
const x = clampWindowCoordinate(
|
||||||
|
event.clientX - bounds.left - dragState.offsetX,
|
||||||
|
bounds.width,
|
||||||
|
CHART_WINDOW_WIDTH,
|
||||||
|
);
|
||||||
|
const y = clampWindowCoordinate(
|
||||||
|
event.clientY - bounds.top - dragState.offsetY,
|
||||||
|
bounds.height,
|
||||||
|
CHART_WINDOW_HEIGHT,
|
||||||
|
);
|
||||||
|
setChartWindows((current) =>
|
||||||
|
current.map((window) =>
|
||||||
|
window.id === dragState.windowId ? { ...window, x, y } : window,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopWindowDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (dragStateRef.current?.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
dragStateRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChartWindow = (windowId: string) => {
|
||||||
|
setChartWindows((current) =>
|
||||||
|
current.filter((window) => window.id !== windowId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="results-shell">
|
||||||
|
<header className="results-topbar">
|
||||||
|
<div>
|
||||||
|
<div className="product-name">System Simulation Flow</div>
|
||||||
|
<div className="product-meta">仿真结果查看</div>
|
||||||
|
</div>
|
||||||
|
<WorkspaceViewTabs
|
||||||
|
activeView="results"
|
||||||
|
hasResults
|
||||||
|
onViewChange={onViewChange}
|
||||||
|
/>
|
||||||
|
<div className="results-run-meta">
|
||||||
|
<strong>{snapshot.project.name}</strong>
|
||||||
|
<span>
|
||||||
|
{snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "}
|
||||||
|
{formatResultTimestamp(snapshot.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="results-workbench">
|
||||||
|
<section className="results-system-panel">
|
||||||
|
<div className="results-section-heading">
|
||||||
|
<strong>系统图</strong>
|
||||||
|
<span>{snapshot.project.nodes.length} 个组件</span>
|
||||||
|
</div>
|
||||||
|
<div className="results-system-canvas">
|
||||||
|
<ReactFlow
|
||||||
|
colorMode="light"
|
||||||
|
connectionLineType={ConnectionLineType.Step}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
deleteKeyCode={null}
|
||||||
|
edges={readOnlyEdges}
|
||||||
|
edgesFocusable={false}
|
||||||
|
edgesReconnectable={false}
|
||||||
|
elementsSelectable
|
||||||
|
fitView
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
nodes={readOnlyNodes}
|
||||||
|
nodesConnectable={false}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesFocusable
|
||||||
|
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||||
|
onPaneClick={() => setSelectedNodeId(null)}
|
||||||
|
panOnDrag
|
||||||
|
selectionOnDrag={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
>
|
||||||
|
<Background gap={18} />
|
||||||
|
<MiniMap pannable zoomable />
|
||||||
|
<Controls />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="results-chart-panel">
|
||||||
|
<div className="results-section-heading">
|
||||||
|
<strong>曲线窗口</strong>
|
||||||
|
<span>{chartWindows.length} 条曲线</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`results-chart-workspace ${
|
||||||
|
chartWindows.length === 0 ? "empty" : ""
|
||||||
|
}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={handleWorkspaceDrop}
|
||||||
|
ref={workspaceRef}
|
||||||
|
>
|
||||||
|
{chartWindows.map((chartWindow) => {
|
||||||
|
const variable = describeResultVariable(
|
||||||
|
chartWindow.variableKey,
|
||||||
|
selectedNodeIdForVariable(
|
||||||
|
chartWindow.variableKey,
|
||||||
|
snapshot.project.nodes,
|
||||||
|
),
|
||||||
|
snapshot.project.nodes,
|
||||||
|
);
|
||||||
|
const component = snapshot.project.nodes.find((node) =>
|
||||||
|
chartWindow.variableKey.startsWith(`${node.id}.`),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="result-chart-window"
|
||||||
|
key={chartWindow.id}
|
||||||
|
onPointerDown={() => focusChartWindow(chartWindow.id)}
|
||||||
|
style={{
|
||||||
|
left: chartWindow.x,
|
||||||
|
top: chartWindow.y,
|
||||||
|
zIndex: chartWindow.zIndex,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="result-chart-window-header"
|
||||||
|
onPointerCancel={stopWindowDrag}
|
||||||
|
onPointerDown={(event) =>
|
||||||
|
startWindowDrag(event, chartWindow)
|
||||||
|
}
|
||||||
|
onPointerMove={moveChartWindow}
|
||||||
|
onPointerUp={stopWindowDrag}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{variable.label}</strong>
|
||||||
|
<span>{component?.data.label ?? variable.componentId}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label={`关闭 ${variable.label} 曲线`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
removeChartWindow(chartWindow.id);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" size={16} strokeWidth={1.9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<CurvePlot
|
||||||
|
color={colorForVariable(chartWindow.variableKey)}
|
||||||
|
label={variable.label}
|
||||||
|
time={snapshot.result.series.time ?? []}
|
||||||
|
unit={variable.unit}
|
||||||
|
values={
|
||||||
|
snapshot.result.series[chartWindow.variableKey] ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{chartWindows.length === 0 ? (
|
||||||
|
<div className="results-workspace-empty">暂无曲线</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="results-variable-panel">
|
||||||
|
<div className="results-section-heading">
|
||||||
|
<strong>结果变量</strong>
|
||||||
|
<span>{selectedVariables.length}</span>
|
||||||
|
</div>
|
||||||
|
{selectedNode ? (
|
||||||
|
<>
|
||||||
|
<div className="results-selected-component">
|
||||||
|
<strong>{selectedNode.data.label}</strong>
|
||||||
|
<span>{selectedNode.data.modelType}</span>
|
||||||
|
</div>
|
||||||
|
<VariableGroup
|
||||||
|
chartWindows={chartWindows}
|
||||||
|
onClick={addChartWindow}
|
||||||
|
onDragStart={handleVariableDragStart}
|
||||||
|
title="组件变量"
|
||||||
|
variables={componentVariables}
|
||||||
|
/>
|
||||||
|
<VariableGroup
|
||||||
|
chartWindows={chartWindows}
|
||||||
|
onClick={addChartWindow}
|
||||||
|
onDragStart={handleVariableDragStart}
|
||||||
|
title="端口变量"
|
||||||
|
variables={portVariables}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="results-variable-empty">未选择组件</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySimulationResultsView({
|
||||||
|
onViewChange,
|
||||||
|
}: EmptySimulationResultsViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="results-shell">
|
||||||
|
<header className="results-topbar">
|
||||||
|
<div>
|
||||||
|
<div className="product-name">System Simulation Flow</div>
|
||||||
|
<div className="product-meta">仿真结果查看</div>
|
||||||
|
</div>
|
||||||
|
<WorkspaceViewTabs
|
||||||
|
activeView="results"
|
||||||
|
hasResults={false}
|
||||||
|
onViewChange={onViewChange}
|
||||||
|
/>
|
||||||
|
<div className="results-run-meta empty">
|
||||||
|
<span>尚无结果</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="results-empty-view">
|
||||||
|
<strong>尚未生成仿真结果</strong>
|
||||||
|
<button onClick={() => onViewChange("modeling")} type="button">
|
||||||
|
进入建模
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariableGroupProps = {
|
||||||
|
title: string;
|
||||||
|
variables: ResultVariable[];
|
||||||
|
chartWindows: ChartWindow[];
|
||||||
|
onClick: (variableKey: string) => void;
|
||||||
|
onDragStart: (
|
||||||
|
event: DragEvent<HTMLButtonElement>,
|
||||||
|
variableKey: string,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function VariableGroup({
|
||||||
|
title,
|
||||||
|
variables,
|
||||||
|
chartWindows,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
}: VariableGroupProps) {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="results-variable-group">
|
||||||
|
<div className="results-variable-group-title">{title}</div>
|
||||||
|
<div className="results-variable-list">
|
||||||
|
{variables.map((variable) => {
|
||||||
|
const visible = chartWindows.some(
|
||||||
|
(window) => window.variableKey === variable.key,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={visible ? "visible" : ""}
|
||||||
|
draggable
|
||||||
|
key={variable.key}
|
||||||
|
onClick={() => onClick(variable.key)}
|
||||||
|
onDragStart={(event) => onDragStart(event, variable.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="result-variable-swatch"
|
||||||
|
style={{ backgroundColor: colorForVariable(variable.key) }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{variable.label}</strong>
|
||||||
|
<small>{variable.unit || "无量纲"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurvePlotProps = {
|
||||||
|
time: number[];
|
||||||
|
values: number[];
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CurvePlot({ time, values, label, unit, color }: CurvePlotProps) {
|
||||||
|
const samples = time
|
||||||
|
.map((timeValue, index) => ({
|
||||||
|
x: Number(timeValue),
|
||||||
|
y: Number(values[index]),
|
||||||
|
}))
|
||||||
|
.filter((sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y));
|
||||||
|
if (samples.length < 2) {
|
||||||
|
return <div className="result-chart-empty">无可绘制数据</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 400;
|
||||||
|
const height = 190;
|
||||||
|
const margin = { top: 14, right: 16, bottom: 30, left: 58 };
|
||||||
|
const plotWidth = width - margin.left - margin.right;
|
||||||
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
|
const xMin = Math.min(...samples.map((sample) => sample.x));
|
||||||
|
const xMax = Math.max(...samples.map((sample) => sample.x));
|
||||||
|
const rawYMin = Math.min(...samples.map((sample) => sample.y));
|
||||||
|
const rawYMax = Math.max(...samples.map((sample) => sample.y));
|
||||||
|
const yPadding =
|
||||||
|
rawYMin === rawYMax
|
||||||
|
? Math.max(Math.abs(rawYMin) * 0.05, 1)
|
||||||
|
: (rawYMax - rawYMin) * 0.08;
|
||||||
|
const yMin = rawYMin - yPadding;
|
||||||
|
const yMax = rawYMax + yPadding;
|
||||||
|
const xRange = Math.max(xMax - xMin, 1e-12);
|
||||||
|
const yRange = Math.max(yMax - yMin, 1e-12);
|
||||||
|
const xPosition = (value: number) =>
|
||||||
|
margin.left + ((value - xMin) / xRange) * plotWidth;
|
||||||
|
const yPosition = (value: number) =>
|
||||||
|
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
|
||||||
|
const path = samples
|
||||||
|
.map(
|
||||||
|
(sample, index) =>
|
||||||
|
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(
|
||||||
|
sample.y,
|
||||||
|
).toFixed(2)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="result-chart-body">
|
||||||
|
<svg
|
||||||
|
aria-label={`${label} 随时间变化曲线`}
|
||||||
|
role="img"
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
|
||||||
|
const y = margin.top + fraction * plotHeight;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
className="result-chart-grid-line"
|
||||||
|
key={`y-${fraction}`}
|
||||||
|
x1={margin.left}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((fraction) => {
|
||||||
|
const x = margin.left + fraction * plotWidth;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
className="result-chart-grid-line"
|
||||||
|
key={`x-${fraction}`}
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={margin.top}
|
||||||
|
y2={height - margin.bottom}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line
|
||||||
|
className="result-chart-axis"
|
||||||
|
x1={margin.left}
|
||||||
|
x2={margin.left}
|
||||||
|
y1={margin.top}
|
||||||
|
y2={height - margin.bottom}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
className="result-chart-axis"
|
||||||
|
x1={margin.left}
|
||||||
|
x2={width - margin.right}
|
||||||
|
y1={height - margin.bottom}
|
||||||
|
y2={height - margin.bottom}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="result-chart-label"
|
||||||
|
textAnchor="end"
|
||||||
|
x={margin.left - 6}
|
||||||
|
y={margin.top + 4}
|
||||||
|
>
|
||||||
|
{formatChartNumber(rawYMax)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
className="result-chart-label"
|
||||||
|
textAnchor="end"
|
||||||
|
x={margin.left - 6}
|
||||||
|
y={height - margin.bottom}
|
||||||
|
>
|
||||||
|
{formatChartNumber(rawYMin)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
className="result-chart-label"
|
||||||
|
textAnchor="start"
|
||||||
|
x={margin.left}
|
||||||
|
y={height - 10}
|
||||||
|
>
|
||||||
|
{formatChartNumber(xMin)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
className="result-chart-label"
|
||||||
|
textAnchor="end"
|
||||||
|
x={width - margin.right}
|
||||||
|
y={height - 10}
|
||||||
|
>
|
||||||
|
{formatChartNumber(xMax)} s
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
className="result-chart-unit"
|
||||||
|
textAnchor="start"
|
||||||
|
x={margin.left}
|
||||||
|
y={10}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultVariablesForNode(
|
||||||
|
series: Record<string, number[]>,
|
||||||
|
nodeId: string,
|
||||||
|
ports: ResultPortDefinition[],
|
||||||
|
) {
|
||||||
|
const portNames = new Set(ports.map((port) => port.name));
|
||||||
|
return Object.entries(series)
|
||||||
|
.filter(
|
||||||
|
([key, values]) =>
|
||||||
|
key !== "time" &&
|
||||||
|
key.startsWith(`${nodeId}.`) &&
|
||||||
|
Array.isArray(values) &&
|
||||||
|
values.length > 0,
|
||||||
|
)
|
||||||
|
.map(([key]) => {
|
||||||
|
const described = describeResultVariable(key, nodeId);
|
||||||
|
const firstSegment = key.slice(nodeId.length + 1).split(".")[0];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: described.label,
|
||||||
|
unit: described.unit,
|
||||||
|
group: portNames.has(firstSegment) ? "port" : "component",
|
||||||
|
} satisfies ResultVariable;
|
||||||
|
})
|
||||||
|
.sort((first, second) => {
|
||||||
|
if (first.group !== second.group) {
|
||||||
|
return first.group === "component" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return first.label.localeCompare(second.label, "zh-CN");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeResultVariable(
|
||||||
|
key: string,
|
||||||
|
componentId: string,
|
||||||
|
nodes?: ResultProjectNode[],
|
||||||
|
) {
|
||||||
|
const prefix = `${componentId}.`;
|
||||||
|
const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : key;
|
||||||
|
const segments = suffix.split(".");
|
||||||
|
const variableName = segments.at(-1) ?? suffix;
|
||||||
|
const portName = segments.length > 1 ? segments.slice(0, -1).join(".") : null;
|
||||||
|
const definition = variableDefinitions[variableName] ?? {
|
||||||
|
label: variableName,
|
||||||
|
unit: "",
|
||||||
|
};
|
||||||
|
const component = nodes?.find((node) => node.id === componentId);
|
||||||
|
return {
|
||||||
|
componentId,
|
||||||
|
label: portName
|
||||||
|
? `${portName} · ${definition.label}`
|
||||||
|
: definition.label,
|
||||||
|
unit: definition.unit,
|
||||||
|
componentLabel: component?.data.label ?? componentId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedNodeIdForVariable(
|
||||||
|
variableKey: string,
|
||||||
|
nodes: ResultProjectNode[],
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
nodes.find((node) => variableKey.startsWith(`${node.id}.`))?.id ??
|
||||||
|
variableKey.split(".")[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForVariable(key: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (const character of key) {
|
||||||
|
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return CHART_COLORS[hash % CHART_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampWindowCoordinate(
|
||||||
|
value: number,
|
||||||
|
containerSize: number,
|
||||||
|
windowSize: number,
|
||||||
|
) {
|
||||||
|
return Math.min(
|
||||||
|
Math.max(12, value),
|
||||||
|
Math.max(12, containerSize - windowSize - 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChartWindows(snapshotId: string): ChartWindow[] {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const value = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.filter(isChartWindow);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextChartWindowNumber(windows: ChartWindow[]) {
|
||||||
|
return (
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...windows.map((window) => {
|
||||||
|
const match = /^chart-(\d+)$/.exec(window.id);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
}),
|
||||||
|
) + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeChartWindows(snapshotId: string, windows: ChartWindow[]) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`,
|
||||||
|
JSON.stringify(windows),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// The current mounted result view still retains its layout in memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChartWindow(value: unknown): value is ChartWindow {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const window = value as Partial<ChartWindow>;
|
||||||
|
return (
|
||||||
|
typeof window.id === "string" &&
|
||||||
|
typeof window.variableKey === "string" &&
|
||||||
|
typeof window.x === "number" &&
|
||||||
|
Number.isFinite(window.x) &&
|
||||||
|
typeof window.y === "number" &&
|
||||||
|
Number.isFinite(window.y) &&
|
||||||
|
typeof window.zIndex === "number" &&
|
||||||
|
Number.isFinite(window.zIndex)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResultTimestamp(value: string) {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime())
|
||||||
|
? value
|
||||||
|
: parsed.toLocaleString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChartNumber(value: number) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Math.abs(value) >= 100000 ||
|
||||||
|
(Math.abs(value) > 0 && Math.abs(value) < 0.001)
|
||||||
|
) {
|
||||||
|
return value.toExponential(2);
|
||||||
|
}
|
||||||
|
return value.toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type WorkspaceView = "modeling" | "results";
|
||||||
|
|
||||||
|
type WorkspaceViewTabsProps = {
|
||||||
|
activeView: WorkspaceView;
|
||||||
|
hasResults: boolean;
|
||||||
|
onViewChange: (view: WorkspaceView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceViewTabs({
|
||||||
|
activeView,
|
||||||
|
hasResults,
|
||||||
|
onViewChange,
|
||||||
|
}: WorkspaceViewTabsProps) {
|
||||||
|
return (
|
||||||
|
<div aria-label="工作区视图" className="workspace-view-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
aria-selected={activeView === "modeling"}
|
||||||
|
className={activeView === "modeling" ? "active" : ""}
|
||||||
|
onClick={() => onViewChange("modeling")}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
建模
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-selected={activeView === "results"}
|
||||||
|
className={activeView === "results" ? "active" : ""}
|
||||||
|
onClick={() => onViewChange("results")}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
结果
|
||||||
|
{hasResults ? <span aria-label="已有仿真结果" className="result-available-dot" /> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+522
-19
@@ -54,6 +54,53 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-view-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 34px;
|
||||||
|
border-bottom: 1px solid #bfcbd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view-tabs button {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 76px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #526172;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view-tabs button:hover {
|
||||||
|
color: #1f2933;
|
||||||
|
background: #f1f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view-tabs button.active {
|
||||||
|
border-bottom-color: #1d6fb8;
|
||||||
|
color: #155a94;
|
||||||
|
background: #eaf3fa;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view-tabs button:focus-visible {
|
||||||
|
outline: 2px solid #1d6fb8;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-available-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2f7d55;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -480,43 +527,80 @@ textarea {
|
|||||||
|
|
||||||
.sim-node {
|
.sim-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 132px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-node.orientation-vertical {
|
||||||
|
width: 84px;
|
||||||
|
height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-node-frame {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-width: 96px;
|
width: 132px;
|
||||||
min-height: 58px;
|
height: 84px;
|
||||||
padding: 8px 16px;
|
box-sizing: border-box;
|
||||||
border: 2px solid #4b5563;
|
border: 2px solid #4b5563;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sim-node.selected {
|
.sim-node.selected .sim-node-frame {
|
||||||
border-color: #1d6fb8;
|
border-color: #1d6fb8;
|
||||||
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15);
|
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__node.validation-error .sim-node {
|
.react-flow__node.validation-error .sim-node-frame {
|
||||||
border-color: #c63b32;
|
border-color: #c63b32;
|
||||||
box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14);
|
box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__node.validation-warning .sim-node {
|
.react-flow__node.validation-warning .sim-node-frame {
|
||||||
border-color: #c08a16;
|
border-color: #c08a16;
|
||||||
box-shadow: 0 0 0 3px rgba(192, 138, 22, 0.14);
|
box-shadow: 0 0 0 3px rgba(192, 138, 22, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-symbol-anchor {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
.node-symbol {
|
.node-symbol {
|
||||||
height: 28px;
|
display: grid;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
place-items: center;
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-label {
|
.node-label {
|
||||||
max-width: 120px;
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 50%;
|
||||||
|
max-width: calc(100% - 14px);
|
||||||
|
padding: 0 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
transform: translateX(-50%);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.symbol-cylinder,
|
.symbol-cylinder,
|
||||||
@@ -524,6 +608,7 @@ textarea {
|
|||||||
.symbol-pipe,
|
.symbol-pipe,
|
||||||
.symbol-orifice,
|
.symbol-orifice,
|
||||||
.symbol-tee {
|
.symbol-tee {
|
||||||
|
box-sizing: border-box;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: 2px solid #4b5563;
|
border: 2px solid #4b5563;
|
||||||
@@ -544,7 +629,6 @@ textarea {
|
|||||||
|
|
||||||
.symbol-pipe {
|
.symbol-pipe {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
margin-top: 8px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,12 +641,14 @@ textarea {
|
|||||||
|
|
||||||
.symbol-tee {
|
.symbol-tee {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.symbol-tee::before,
|
.symbol-tee::before,
|
||||||
.symbol-tee::after {
|
.symbol-tee::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
content: "";
|
content: "";
|
||||||
border: 2px solid #4b5563;
|
border: 2px solid #4b5563;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -570,7 +656,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.symbol-tee::before {
|
.symbol-tee::before {
|
||||||
top: 8px;
|
top: 10px;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@@ -578,18 +664,25 @@ textarea {
|
|||||||
|
|
||||||
.symbol-tee::after {
|
.symbol-tee::after {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 14px;
|
left: 15px;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.port-handle {
|
.port-handle {
|
||||||
|
z-index: 3;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border: 2px solid #374151;
|
border: 2px solid #374151;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-canvas .react-flow__edge-path,
|
||||||
|
.flow-canvas .react-flow__connection-path {
|
||||||
|
stroke: #64748b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -735,6 +828,18 @@ textarea {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title small {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.result-grid {
|
.result-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
|
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
|
||||||
@@ -762,25 +867,419 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-list {
|
.component-result {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e3e8ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-list div {
|
.component-result > strong {
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
grid-template-rows: 62px minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 980px;
|
||||||
|
background: #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #cfd8e3;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-run-meta {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-run-meta strong,
|
||||||
|
.results-run-meta span {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-run-meta strong {
|
||||||
|
color: #253342;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-run-meta span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-list code {
|
.results-run-meta.empty {
|
||||||
overflow-wrap: anywhere;
|
color: #7b8795;
|
||||||
color: #1f2933;
|
}
|
||||||
font-family: Consolas, "Courier New", monospace;
|
|
||||||
|
.results-workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(360px, 0.85fr)
|
||||||
|
minmax(480px, 1.15fr)
|
||||||
|
290px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-panel,
|
||||||
|
.results-chart-panel,
|
||||||
|
.results-variable-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 43px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-panel {
|
||||||
|
border-right: 1px solid #cfd8e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-chart-panel {
|
||||||
|
border-right: 1px solid #cfd8e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-panel {
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 43px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-bottom: 1px solid #dfe5ec;
|
||||||
|
color: #233142;
|
||||||
|
background: #f7f9fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section-heading strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section-heading span {
|
||||||
|
color: #64748b;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-system-canvas {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__pane {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__pane.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__handle {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .react-flow__edge-path {
|
||||||
|
stroke: #5f6f82;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-chart-workspace {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
background-image: radial-gradient(#cbd5e1 0.7px, transparent 0.7px);
|
||||||
|
background-position: 0 0;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-chart-workspace.empty {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-workspace-empty {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
color: #8a97a6;
|
||||||
|
font-size: 12px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window {
|
||||||
|
position: absolute;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 44px minmax(0, 1fr);
|
||||||
|
width: min(420px, calc(100% - 24px));
|
||||||
|
height: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #99a8b8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 7px 20px rgba(30, 41, 59, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px 7px 5px 11px;
|
||||||
|
border-bottom: 1px solid #dbe2ea;
|
||||||
|
background: #f6f8fa;
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header strong,
|
||||||
|
.result-chart-window-header span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header strong {
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header span {
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header button {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #526172;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window-header button:hover {
|
||||||
|
border-color: #c5cfda;
|
||||||
|
color: #b42318;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-body {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 5px 8px 7px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-body svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-grid-line {
|
||||||
|
stroke: #e1e7ee;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-axis {
|
||||||
|
stroke: #76869a;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-label,
|
||||||
|
.result-chart-unit {
|
||||||
|
fill: #5b6878;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-unit {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-empty {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
color: #8a97a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-selected-component {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 13px;
|
||||||
|
border-bottom: 1px solid #e3e8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-selected-component strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-selected-component span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-group {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e3e8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-group-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #526172;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4px minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 43px;
|
||||||
|
padding: 6px 8px 6px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d4dde7;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #263442;
|
||||||
|
text-align: left;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list button:hover {
|
||||||
|
border-color: #8ca0b5;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list button.visible {
|
||||||
|
border-color: #6b9ac4;
|
||||||
|
background: #edf6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list button:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-variable-swatch {
|
||||||
|
display: block;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list button > span:last-child {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list strong,
|
||||||
|
.results-variable-list small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-list small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-variable-empty {
|
||||||
|
padding: 18px 13px;
|
||||||
|
color: #7b8795;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-empty-view {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
color: #687586;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-empty-view strong {
|
||||||
|
color: #344252;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-empty-view button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #99aabc;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #263442;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-empty-view button:hover {
|
||||||
|
border-color: #66798d;
|
||||||
|
background: #f2f6f9;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
grid-template-columns: 180px minmax(0, 1fr) 280px;
|
grid-template-columns: 180px minmax(0, 1fr) 280px;
|
||||||
@@ -794,4 +1293,8 @@ textarea {
|
|||||||
.project-name-field input {
|
.project-name-field input {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-workbench {
|
||||||
|
grid-template-columns: 340px minmax(400px, 1fr) 260px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
||||||
Sort-Object Name -Descending |
|
Sort-Object Name -Descending |
|
||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
@@ -10,4 +11,4 @@ if (-not $nodeDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$env:Path = "$($nodeDir.FullName);$env:Path"
|
$env:Path = "$($nodeDir.FullName);$env:Path"
|
||||||
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
|
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
fastapi
|
fastapi
|
||||||
|
lxml>=5,<7
|
||||||
|
scipy>=1.13,<2
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
|
||||||
|
<xs:simpleType name="NonEmptyString">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:whiteSpace value="collapse"/>
|
||||||
|
<xs:minLength value="1"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PositiveDouble">
|
||||||
|
<xs:restriction base="xs:double">
|
||||||
|
<xs:minExclusive value="0"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortKind">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="physical"/>
|
||||||
|
<xs:enumeration value="signal"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortNominalRole">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="inlet"/>
|
||||||
|
<xs:enumeration value="outlet"/>
|
||||||
|
<xs:enumeration value="bidirectional"/>
|
||||||
|
<xs:enumeration value="input"/>
|
||||||
|
<xs:enumeration value="output"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortSide">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="left"/>
|
||||||
|
<xs:enumeration value="right"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="EndpointRole">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="source"/>
|
||||||
|
<xs:enumeration value="target"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="NodeRotation">
|
||||||
|
<xs:restriction base="xs:integer">
|
||||||
|
<xs:enumeration value="0"/>
|
||||||
|
<xs:enumeration value="90"/>
|
||||||
|
<xs:enumeration value="180"/>
|
||||||
|
<xs:enumeration value="270"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:element name="System">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Simulation">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="tStart" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="tStop" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="step" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="method" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Components">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="kind" type="PortKind" use="required"/>
|
||||||
|
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="nominalRole" type="PortNominalRole" use="required"/>
|
||||||
|
<xs:attribute name="positiveFlowDirection" use="optional" fixed="intoComponent"/>
|
||||||
|
<xs:attribute name="side" type="PortSide" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="value" type="xs:double" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="type" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="x" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="y" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="rotation" type="NodeRotation" use="optional" default="0"/>
|
||||||
|
<xs:attribute name="mirrored" type="xs:boolean" use="optional" default="false"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Connections">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Endpoint" minOccurs="2" maxOccurs="2">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="component" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="port" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="role" type="EndpointRole" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="kind" type="PortKind" use="required"/>
|
||||||
|
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
|
||||||
|
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:schema>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title SystemSimulationApp Launcher
|
||||||
|
|
||||||
|
if not exist "%~dp0start-backend.bat" (
|
||||||
|
echo [ERROR] start-backend.bat was not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%~dp0start-reactflow.bat" (
|
||||||
|
echo [ERROR] start-reactflow.bat was not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting FastAPI and ReactFlow in separate windows...
|
||||||
|
|
||||||
|
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%~dp0start-backend.bat"
|
||||||
|
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%~dp0start-reactflow.bat"
|
||||||
|
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title SystemSimulationApp FastAPI - 127.0.0.1:8000
|
||||||
|
|
||||||
|
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
|
||||||
|
|
||||||
|
if not exist "%PYTHON_EXE%" (
|
||||||
|
echo [ERROR] Python virtual environment was not found:
|
||||||
|
echo %PYTHON_EXE%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting FastAPI at http://127.0.0.1:8000
|
||||||
|
echo Press Ctrl+C to stop the service.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
"%PYTHON_EXE%" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] FastAPI exited with code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
|
||||||
|
|
||||||
|
set "FRONTEND_DIR=%~dp0frontend"
|
||||||
|
set "START_SCRIPT=%FRONTEND_DIR%\start-dev.bat"
|
||||||
|
|
||||||
|
if not exist "%START_SCRIPT%" (
|
||||||
|
echo [ERROR] ReactFlow start script was not found:
|
||||||
|
echo %START_SCRIPT%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting ReactFlow at http://127.0.0.1:5173
|
||||||
|
echo Press Ctrl+C to stop the service.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d "%FRONTEND_DIR%"
|
||||||
|
call "%START_SCRIPT%"
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.main import compile_reactflow_network
|
||||||
|
from PythonModels.components.cylinder import Cylinder
|
||||||
|
from PythonModels.components.tank import Tank
|
||||||
|
from PythonModels.components.tee import Tee
|
||||||
|
from PythonModels.core.base import Component
|
||||||
|
from PythonModels.core.medium import IdealGasMedium
|
||||||
|
from PythonModels.core.network import SimulationNetwork
|
||||||
|
from PythonModels.core.ports import PortDefinition, PortState, PortVariableDefinition
|
||||||
|
from PythonModels.systems.testmodel import TestModelSystem
|
||||||
|
from tests.test_system_xml_protocol import physical_connection_project
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentInterfaceTests(unittest.TestCase):
|
||||||
|
def test_pneumatic_port_exposes_equation_roles_and_signed_flow(self) -> None:
|
||||||
|
port = PortState.pneumatic("port_a", nominal_role="bidirectional")
|
||||||
|
assert port.definition is not None
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
(variable.name, variable.role, variable.connection_rule)
|
||||||
|
for variable in port.definition.variables
|
||||||
|
],
|
||||||
|
[
|
||||||
|
("p", "effort", "equal"),
|
||||||
|
("m_flow", "flow", "sumToZero"),
|
||||||
|
("h_outflow", "stream", "streamMix"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
port.m_flow = 10.0
|
||||||
|
self.assertEqual(port.actual_direction(), "in")
|
||||||
|
self.assertEqual(port.inflow_rate, 10.0)
|
||||||
|
self.assertEqual(port.outflow_rate, 0.0)
|
||||||
|
|
||||||
|
port.m_flow = -4.0
|
||||||
|
self.assertEqual(port.actual_direction(), "out")
|
||||||
|
self.assertEqual(port.inflow_rate, 0.0)
|
||||||
|
self.assertEqual(port.outflow_rate, 4.0)
|
||||||
|
|
||||||
|
def test_physical_connection_is_neutral_and_canonical(self) -> None:
|
||||||
|
forward = compile_reactflow_network(physical_connection_project())
|
||||||
|
reverse = compile_reactflow_network(
|
||||||
|
physical_connection_project(reverse_edge=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
forward.connections[0].undirected_key,
|
||||||
|
reverse.connections[0].undirected_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
forward.connections[0].as_interface_dict(),
|
||||||
|
reverse.connections[0].as_interface_dict(),
|
||||||
|
)
|
||||||
|
self.assertNotIn("source", forward.connections[0].as_interface_dict())
|
||||||
|
|
||||||
|
def test_compiler_instantiates_parameters_and_actual_edges(self) -> None:
|
||||||
|
project = physical_connection_project()
|
||||||
|
network = compile_reactflow_network(project)
|
||||||
|
|
||||||
|
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
|
||||||
|
self.assertEqual(network.components["cylinder_1"].V, 0.01)
|
||||||
|
self.assertEqual(network.components["tank_1"].V, 0.1)
|
||||||
|
self.assertEqual(len(network.connections), 1)
|
||||||
|
self.assertEqual(network.connections[0].id, "edge-1")
|
||||||
|
self.assertEqual(network.connections[0].kind, "physical")
|
||||||
|
self.assertEqual(network.connections[0].domain, "pneumatic")
|
||||||
|
equation_system = network.as_interface_dict()["pressureFlowSystem"]
|
||||||
|
self.assertEqual(equation_system["unknownCount"], 4)
|
||||||
|
self.assertEqual(equation_system["equationCount"], 4)
|
||||||
|
self.assertTrue(equation_system["isSquare"])
|
||||||
|
|
||||||
|
def test_physical_port_requires_a_tee_for_branching(self) -> None:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
network = SimulationNetwork("branch-check")
|
||||||
|
network.add_component(Cylinder("cylinder_1", medium))
|
||||||
|
network.add_component(Tank("tank_1", medium))
|
||||||
|
network.add_component(Tank("tank_2", medium))
|
||||||
|
network.connect("cylinder_1", "port_b", "tank_1", "port_a")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Use a junction component"):
|
||||||
|
network.connect("cylinder_1", "port_b", "tank_2", "port_a")
|
||||||
|
|
||||||
|
def test_connection_requires_the_same_variable_contract(self) -> None:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
network = SimulationNetwork("contract-check")
|
||||||
|
network.add_component(Cylinder("cylinder_1", medium))
|
||||||
|
incompatible = Component("incompatible_1")
|
||||||
|
incompatible.register_port(
|
||||||
|
PortState(
|
||||||
|
definition=PortDefinition(
|
||||||
|
name="port_a",
|
||||||
|
kind="physical",
|
||||||
|
domain="pneumatic",
|
||||||
|
nominal_role="bidirectional",
|
||||||
|
positive_flow_direction="intoComponent",
|
||||||
|
variables=(PortVariableDefinition("p", "effort", "equal"),),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
network.add_component(incompatible)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "variable contracts do not match"):
|
||||||
|
network.connect("cylinder_1", "port_b", "incompatible_1", "port_a")
|
||||||
|
|
||||||
|
def test_connection_equations_are_executable_and_endpoint_neutral(self) -> None:
|
||||||
|
medium = IdealGasMedium()
|
||||||
|
network = SimulationNetwork("residual-check")
|
||||||
|
cylinder = Cylinder("cylinder_1", medium)
|
||||||
|
tank = Tank("tank_1", medium)
|
||||||
|
network.add_component(cylinder)
|
||||||
|
network.add_component(tank)
|
||||||
|
network.connect("tank_1", "port_a", "cylinder_1", "port_b")
|
||||||
|
|
||||||
|
cylinder.port_b.p = 2.0e5
|
||||||
|
cylinder.port_b.m_flow = -3.0
|
||||||
|
tank.port_a.p = 2.0e5
|
||||||
|
tank.port_a.m_flow = 3.0
|
||||||
|
|
||||||
|
residuals = {
|
||||||
|
residual.variables[0].rsplit(".", 1)[-1]: residual
|
||||||
|
for residual in network.connection_equation_residuals()
|
||||||
|
}
|
||||||
|
self.assertEqual(set(residuals), {"p", "m_flow"})
|
||||||
|
self.assertEqual(residuals["p"].relation, "equal")
|
||||||
|
self.assertEqual(residuals["m_flow"].relation, "sumToZero")
|
||||||
|
self.assertEqual(residuals["p"].value, 0.0)
|
||||||
|
self.assertEqual(residuals["m_flow"].value, 0.0)
|
||||||
|
|
||||||
|
def test_tee_ports_are_physical_and_bidirectional(self) -> None:
|
||||||
|
tee = Tee("tee_1")
|
||||||
|
self.assertEqual(
|
||||||
|
{definition.name for definition in tee.port_definitions},
|
||||||
|
{"port_in", "port_out1", "port_out2"},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
definition.kind == "physical"
|
||||||
|
and definition.nominal_role == "bidirectional"
|
||||||
|
for definition in tee.port_definitions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_testmodel_topology_still_builds(self) -> None:
|
||||||
|
system = TestModelSystem()
|
||||||
|
self.assertEqual(len(system.network.components), 8)
|
||||||
|
self.assertEqual(len(system.network.connections), 8)
|
||||||
|
self.assertTrue(
|
||||||
|
all(connection.kind == "physical" for connection in system.network.connections)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(system.initial_state_vector()), 8)
|
||||||
|
|
||||||
|
def test_testmodel_snapshot_satisfies_acausal_connection_equations(self) -> None:
|
||||||
|
system = TestModelSystem()
|
||||||
|
state = system.consistent_initial_state_vector()
|
||||||
|
system.snapshot(state)
|
||||||
|
|
||||||
|
residuals = system.network.pressure_flow_equation_residuals()
|
||||||
|
self.assertEqual(len(residuals), 4 * len(system.network.connections))
|
||||||
|
self.assertEqual(len(system.network.pressure_flow_unknowns()), len(residuals))
|
||||||
|
for residual in residuals:
|
||||||
|
tolerance = 1e-6
|
||||||
|
self.assertLessEqual(
|
||||||
|
abs(residual.value),
|
||||||
|
tolerance,
|
||||||
|
msg=f"{residual.id} residual is {residual.value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_tee = system.mytee
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
upstream_tee.port_in.m_flow
|
||||||
|
+ upstream_tee.port_out1.m_flow
|
||||||
|
+ upstream_tee.port_out2.m_flow,
|
||||||
|
0.0,
|
||||||
|
places=12,
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out1.p)
|
||||||
|
self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out2.p)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
ReactFlowEdgePayload,
|
||||||
|
ReactFlowProjectPayload,
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
compile_reactflow_network,
|
||||||
|
simulate_system_xml,
|
||||||
|
)
|
||||||
|
from PythonModels.components.resistive_pipe import ResistivePipe
|
||||||
|
from PythonModels.core.solver import SolveIVPConfig
|
||||||
|
from PythonModels.systems.generic import (
|
||||||
|
GenericFluidSystem,
|
||||||
|
SimulationPreparationError,
|
||||||
|
)
|
||||||
|
from tests.test_system_xml_protocol import physical_port
|
||||||
|
|
||||||
|
|
||||||
|
def component_node(
|
||||||
|
component_id: str,
|
||||||
|
model_type: str,
|
||||||
|
ports: list[dict[str, str]],
|
||||||
|
parameters: dict[str, float] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": component_id,
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": 0.0, "y": 0.0},
|
||||||
|
"data": {
|
||||||
|
"label": component_id,
|
||||||
|
"componentType": model_type,
|
||||||
|
"modelType": model_type,
|
||||||
|
"ports": ports,
|
||||||
|
"parameters": parameters or {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def physical_edge(
|
||||||
|
edge_id: str,
|
||||||
|
first_component: str,
|
||||||
|
first_port: str,
|
||||||
|
second_component: str,
|
||||||
|
second_port: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"id": edge_id,
|
||||||
|
"source": first_component,
|
||||||
|
"sourceHandle": first_port,
|
||||||
|
"target": second_component,
|
||||||
|
"targetHandle": second_port,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def two_port_definitions() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
physical_port("port_a", "inlet", "left"),
|
||||||
|
physical_port("port_b", "outlet", "right"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def tee_port_definitions() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
physical_port("port_in", "bidirectional", "left"),
|
||||||
|
physical_port("port_out1", "bidirectional", "right"),
|
||||||
|
physical_port("port_out2", "bidirectional", "right"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def chain_project(*, reverse_edges: bool = False) -> ReactFlowProjectPayload:
|
||||||
|
edges = [
|
||||||
|
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
|
||||||
|
physical_edge("edge-2", "orifice_1", "port_b", "pipe_1", "port_a"),
|
||||||
|
physical_edge("edge-3", "pipe_1", "port_b", "tank_1", "port_a"),
|
||||||
|
]
|
||||||
|
if reverse_edges:
|
||||||
|
edges = [
|
||||||
|
physical_edge(
|
||||||
|
edge["id"],
|
||||||
|
edge["target"],
|
||||||
|
edge["targetHandle"],
|
||||||
|
edge["source"],
|
||||||
|
edge["sourceHandle"],
|
||||||
|
)
|
||||||
|
for edge in edges
|
||||||
|
]
|
||||||
|
return ReactFlowProjectPayload(
|
||||||
|
name="generic-chain",
|
||||||
|
nodes=[
|
||||||
|
component_node(
|
||||||
|
"cylinder_1",
|
||||||
|
"cylinder",
|
||||||
|
[physical_port("port_b", "outlet", "right")],
|
||||||
|
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"orifice_1",
|
||||||
|
"orifice",
|
||||||
|
two_port_definitions(),
|
||||||
|
{"K": 1e-5, "opening": 1.0},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"pipe_1",
|
||||||
|
"pipe",
|
||||||
|
two_port_definitions(),
|
||||||
|
{
|
||||||
|
"length": 1.0,
|
||||||
|
"diameter": 0.02,
|
||||||
|
"lambda_darcy": 0.02,
|
||||||
|
"p0": 100000.0,
|
||||||
|
"T0": 300.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"tank_1",
|
||||||
|
"tank",
|
||||||
|
[physical_port("port_a", "inlet", "left")],
|
||||||
|
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=edges,
|
||||||
|
simulation={
|
||||||
|
"t_start": 0.0,
|
||||||
|
"t_stop": 0.01,
|
||||||
|
"step": 0.005,
|
||||||
|
"max_step": 0.001,
|
||||||
|
"method": "BDF",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def branched_project() -> ReactFlowProjectPayload:
|
||||||
|
return ReactFlowProjectPayload(
|
||||||
|
name="generic-branch",
|
||||||
|
nodes=[
|
||||||
|
component_node(
|
||||||
|
"cylinder_1",
|
||||||
|
"cylinder",
|
||||||
|
[physical_port("port_b", "outlet", "right")],
|
||||||
|
{"volume": 0.01, "p0": 500000.0, "T0": 300.0},
|
||||||
|
),
|
||||||
|
component_node("tee_1", "tee", tee_port_definitions()),
|
||||||
|
component_node(
|
||||||
|
"orifice_1",
|
||||||
|
"orifice",
|
||||||
|
two_port_definitions(),
|
||||||
|
{"K": 1e-5, "opening": 1.0},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"pipe_1",
|
||||||
|
"pipe",
|
||||||
|
two_port_definitions(),
|
||||||
|
{
|
||||||
|
"length": 1.0,
|
||||||
|
"diameter": 0.02,
|
||||||
|
"lambda_darcy": 0.02,
|
||||||
|
"p0": 100000.0,
|
||||||
|
"T0": 300.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"orifice_2",
|
||||||
|
"orifice",
|
||||||
|
two_port_definitions(),
|
||||||
|
{"K": 1e-5, "opening": 1.0},
|
||||||
|
),
|
||||||
|
component_node(
|
||||||
|
"pipe_2",
|
||||||
|
"pipe",
|
||||||
|
two_port_definitions(),
|
||||||
|
{
|
||||||
|
"length": 1.0,
|
||||||
|
"diameter": 0.02,
|
||||||
|
"lambda_darcy": 0.02,
|
||||||
|
"p0": 100000.0,
|
||||||
|
"T0": 300.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
component_node("tee_2", "tee", tee_port_definitions()),
|
||||||
|
component_node(
|
||||||
|
"tank_1",
|
||||||
|
"tank",
|
||||||
|
[physical_port("port_a", "inlet", "left")],
|
||||||
|
{"volume": 0.1, "p0": 100000.0, "T0": 300.0},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
physical_edge("edge-1", "cylinder_1", "port_b", "tee_1", "port_in"),
|
||||||
|
physical_edge("edge-2", "tee_1", "port_out1", "orifice_1", "port_a"),
|
||||||
|
physical_edge("edge-3", "orifice_1", "port_b", "pipe_1", "port_a"),
|
||||||
|
physical_edge("edge-4", "pipe_1", "port_b", "tee_2", "port_out1"),
|
||||||
|
physical_edge("edge-5", "tee_1", "port_out2", "orifice_2", "port_a"),
|
||||||
|
physical_edge("edge-6", "orifice_2", "port_b", "pipe_2", "port_a"),
|
||||||
|
physical_edge("edge-7", "pipe_2", "port_b", "tee_2", "port_out2"),
|
||||||
|
physical_edge("edge-8", "tee_2", "port_in", "tank_1", "port_a"),
|
||||||
|
],
|
||||||
|
simulation={
|
||||||
|
"t_start": 0.0,
|
||||||
|
"t_stop": 0.005,
|
||||||
|
"step": 0.005,
|
||||||
|
"max_step": 0.001,
|
||||||
|
"method": "BDF",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def xml_request(body: bytes) -> Request:
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/system-xml/simulate",
|
||||||
|
"headers": [(b"content-type", b"application/xml")],
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericSystemXmlSimulationTests(unittest.TestCase):
|
||||||
|
def test_xml_pipe_compiles_to_quasi_steady_resistance(self) -> None:
|
||||||
|
network = compile_reactflow_network(chain_project())
|
||||||
|
|
||||||
|
self.assertIsInstance(network.components["pipe_1"], ResistivePipe)
|
||||||
|
structure = network.pressure_flow_structure_dict()
|
||||||
|
self.assertEqual(structure["unknownCount"], 12)
|
||||||
|
self.assertEqual(structure["equationCount"], 12)
|
||||||
|
self.assertTrue(structure["isSquare"])
|
||||||
|
|
||||||
|
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
|
||||||
|
network = compile_reactflow_network(chain_project())
|
||||||
|
result = GenericFluidSystem(network).simulate(
|
||||||
|
SolveIVPConfig(
|
||||||
|
t_start=0.0,
|
||||||
|
t_stop=0.01,
|
||||||
|
method="BDF",
|
||||||
|
max_step=0.001,
|
||||||
|
),
|
||||||
|
sample_step=0.005,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success)
|
||||||
|
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
|
||||||
|
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
|
||||||
|
total_mass = [
|
||||||
|
cylinder + tank
|
||||||
|
for cylinder, tank in zip(
|
||||||
|
result.series["cylinder_1.m"],
|
||||||
|
result.series["tank_1.m"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.assertLess(max(total_mass) - min(total_mass), 1e-12)
|
||||||
|
total_energy = [
|
||||||
|
cylinder + tank
|
||||||
|
for cylinder, tank in zip(
|
||||||
|
result.series["cylinder_1.U"],
|
||||||
|
result.series["tank_1.U"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.assertLess(max(total_energy) - min(total_energy), 1e-6)
|
||||||
|
self.assertLess(
|
||||||
|
result.diagnostics["pressureFlow"]["maxScaledResidual"],
|
||||||
|
1e-7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_physical_edge_order_does_not_change_simulation(self) -> None:
|
||||||
|
forward = GenericFluidSystem(
|
||||||
|
compile_reactflow_network(chain_project())
|
||||||
|
).simulate(
|
||||||
|
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||||
|
sample_step=0.005,
|
||||||
|
)
|
||||||
|
reverse = GenericFluidSystem(
|
||||||
|
compile_reactflow_network(chain_project(reverse_edges=True))
|
||||||
|
).simulate(
|
||||||
|
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||||
|
sample_step=0.005,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
forward.final["tank_1.p"],
|
||||||
|
reverse.final["tank_1.p"],
|
||||||
|
places=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_branched_topology_is_solved_without_fixed_testmodel_closure(self) -> None:
|
||||||
|
network = compile_reactflow_network(branched_project())
|
||||||
|
result = GenericFluidSystem(network).simulate(
|
||||||
|
SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001),
|
||||||
|
sample_step=0.005,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
result.final["pipe_1.port_a.m_flow"],
|
||||||
|
result.final["pipe_2.port_a.m_flow"],
|
||||||
|
places=10,
|
||||||
|
)
|
||||||
|
self.assertGreater(result.final["tank_1.p"], 100000.0)
|
||||||
|
|
||||||
|
def test_directly_coupled_storage_components_are_rejected(self) -> None:
|
||||||
|
project = chain_project()
|
||||||
|
project.nodes = [project.nodes[0], project.nodes[-1]]
|
||||||
|
project.edges = [
|
||||||
|
ReactFlowEdgePayload(
|
||||||
|
**physical_edge(
|
||||||
|
"edge-1",
|
||||||
|
"cylinder_1",
|
||||||
|
"port_b",
|
||||||
|
"tank_1",
|
||||||
|
"port_a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaises(SimulationPreparationError) as caught:
|
||||||
|
GenericFluidSystem(compile_reactflow_network(project))
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||||
|
{issue.code for issue in caught.exception.issues},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raw_system_xml_runs_through_generic_simulation_endpoint(self) -> None:
|
||||||
|
xml = build_reactflow_system_xml(chain_project())
|
||||||
|
|
||||||
|
response = asyncio.run(simulate_system_xml(xml_request(xml)))
|
||||||
|
|
||||||
|
self.assertTrue(response["success"])
|
||||||
|
self.assertEqual(response["simulation"]["method"], "BDF")
|
||||||
|
self.assertEqual(response["model"]["pressureFlowSystem"]["unknownCount"], 12)
|
||||||
|
self.assertEqual(response["diagnostics"]["stateCount"], 4)
|
||||||
|
self.assertGreater(response["final"]["tank_1.p"], 100000.0)
|
||||||
|
|
||||||
|
def test_simulation_endpoint_returns_422_for_ideal_storage_coupling(self) -> None:
|
||||||
|
project = chain_project()
|
||||||
|
project.nodes = [project.nodes[0], project.nodes[-1]]
|
||||||
|
project.edges = [
|
||||||
|
ReactFlowEdgePayload(
|
||||||
|
**physical_edge(
|
||||||
|
"edge-1",
|
||||||
|
"cylinder_1",
|
||||||
|
"port_b",
|
||||||
|
"tank_1",
|
||||||
|
"port_a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as caught:
|
||||||
|
asyncio.run(
|
||||||
|
simulate_system_xml(
|
||||||
|
xml_request(build_reactflow_system_xml(project))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(caught.exception.status_code, 422)
|
||||||
|
self.assertIn(
|
||||||
|
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||||
|
{
|
||||||
|
issue["code"]
|
||||||
|
for issue in caught.exception.detail["issues"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
ReactFlowEdgePayload,
|
||||||
|
ReactFlowNodePayload,
|
||||||
|
ReactFlowProjectPayload,
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
compile_system_xml_model,
|
||||||
|
compile_reactflow_network,
|
||||||
|
parse_system_xml,
|
||||||
|
)
|
||||||
|
from app.system_xml import validate_system_xml_document
|
||||||
|
from tests.test_system_xml_protocol import (
|
||||||
|
physical_connection_project,
|
||||||
|
physical_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_xml() -> bytes:
|
||||||
|
return build_reactflow_system_xml(physical_connection_project())
|
||||||
|
|
||||||
|
|
||||||
|
def issue_codes(xml: bytes | str) -> set[str]:
|
||||||
|
return {
|
||||||
|
issue.code
|
||||||
|
for issue in validate_system_xml_document(xml).issues
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def xml_request(body: bytes) -> Request:
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/system-xml/compile-model",
|
||||||
|
"headers": [(b"content-type", b"application/xml")],
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemXmlParserTests(unittest.TestCase):
|
||||||
|
def test_valid_v2_xml_round_trips_and_compiles(self) -> None:
|
||||||
|
report = validate_system_xml_document(valid_xml())
|
||||||
|
|
||||||
|
self.assertTrue(report.valid)
|
||||||
|
self.assertEqual(report.issues, ())
|
||||||
|
assert report.document is not None
|
||||||
|
project = ReactFlowProjectPayload(**report.document.as_project_data())
|
||||||
|
network = compile_reactflow_network(project)
|
||||||
|
|
||||||
|
self.assertEqual(project.name, "protocol-test")
|
||||||
|
self.assertEqual(project.simulation.method, "BDF")
|
||||||
|
self.assertEqual(set(network.components), {"cylinder_1", "tank_1"})
|
||||||
|
self.assertEqual(len(network.connections), 1)
|
||||||
|
self.assertEqual(network.connections[0].id, "edge-1")
|
||||||
|
|
||||||
|
def test_compile_api_consumes_raw_xml_body(self) -> None:
|
||||||
|
response = asyncio.run(compile_system_xml_model(xml_request(valid_xml())))
|
||||||
|
|
||||||
|
self.assertTrue(response["success"])
|
||||||
|
self.assertEqual(response["name"], "protocol-test")
|
||||||
|
self.assertEqual(len(response["components"]), 2)
|
||||||
|
self.assertEqual(len(response["connections"]), 1)
|
||||||
|
self.assertTrue(response["validation"]["valid"])
|
||||||
|
|
||||||
|
def test_parse_api_returns_structured_422_diagnostics(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as caught:
|
||||||
|
asyncio.run(parse_system_xml(xml_request(b"<broken>")))
|
||||||
|
|
||||||
|
self.assertEqual(caught.exception.status_code, 422)
|
||||||
|
detail = caught.exception.detail
|
||||||
|
self.assertEqual(detail["message"], "System XML validation failed.")
|
||||||
|
self.assertEqual(detail["issues"][0]["layer"], "xml")
|
||||||
|
self.assertEqual(detail["issues"][0]["code"], "XML_SYNTAX_ERROR")
|
||||||
|
|
||||||
|
def test_malformed_xml_reports_xml_layer_error(self) -> None:
|
||||||
|
report = validate_system_xml_document("<System><broken></System>")
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertIsNone(report.document)
|
||||||
|
self.assertEqual(report.issues[0].layer, "xml")
|
||||||
|
self.assertEqual(report.issues[0].code, "XML_SYNTAX_ERROR")
|
||||||
|
self.assertIsNotNone(report.issues[0].line)
|
||||||
|
|
||||||
|
def test_dtd_is_rejected_before_schema_validation(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b"<System ",
|
||||||
|
b"<!DOCTYPE System [<!ENTITY demo 'unsafe'>]>\n<System ",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = validate_system_xml_document(xml)
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertEqual(report.issues[0].layer, "xml")
|
||||||
|
self.assertEqual(report.issues[0].code, "XML_DTD_NOT_ALLOWED")
|
||||||
|
|
||||||
|
def test_wrong_schema_version_reports_xsd_error(self) -> None:
|
||||||
|
xml = valid_xml().replace(b'schemaVersion="2"', b'schemaVersion="1"')
|
||||||
|
report = validate_system_xml_document(xml)
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertIsNone(report.document)
|
||||||
|
self.assertTrue(report.issues)
|
||||||
|
self.assertTrue(all(issue.layer == "schema" for issue in report.issues))
|
||||||
|
self.assertIn("XSD_VALIDATION_ERROR", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_parameter_range_is_checked_semantically(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b'<Parameter name="volume" value="0.01"',
|
||||||
|
b'<Parameter name="volume" value="-1"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = validate_system_xml_document(xml)
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
|
||||||
|
issue = next(
|
||||||
|
issue for issue in report.issues if issue.code == "PARAMETER_VALUE_INVALID"
|
||||||
|
)
|
||||||
|
self.assertEqual(issue.layer, "semantic")
|
||||||
|
self.assertIn("cylinder_1.volume", issue.message)
|
||||||
|
|
||||||
|
def test_zero_initial_pressure_is_rejected_before_model_creation(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b'<Parameter name="p0" value="35000000"',
|
||||||
|
b'<Parameter name="p0" value="0"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = validate_system_xml_document(xml)
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertIn("PARAMETER_VALUE_INVALID", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_registered_port_contract_is_checked_semantically(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b'nominalRole="outlet"',
|
||||||
|
b'nominalRole="bidirectional"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("PORT_NOMINAL_ROLE_MISMATCH", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_unknown_connection_endpoint_is_reported(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b'component="tank_1" port="port_a"',
|
||||||
|
b'component="missing_tank" port="port_a"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("ENDPOINT_COMPONENT_UNKNOWN", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_physical_port_cannot_be_used_by_two_connections(self) -> None:
|
||||||
|
project = physical_connection_project()
|
||||||
|
project.nodes.append(
|
||||||
|
ReactFlowNodePayload(**{
|
||||||
|
"id": "tank_2",
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": 420, "y": 180},
|
||||||
|
"data": {
|
||||||
|
"label": "receiver-tank-2",
|
||||||
|
"componentType": "tank",
|
||||||
|
"modelType": "tank",
|
||||||
|
"ports": [physical_port("port_a", "inlet", "left")],
|
||||||
|
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
project.edges.append(
|
||||||
|
ReactFlowEdgePayload(**{
|
||||||
|
"id": "edge-2",
|
||||||
|
"source": "cylinder_1",
|
||||||
|
"sourceHandle": "port_b",
|
||||||
|
"target": "tank_2",
|
||||||
|
"targetHandle": "port_a",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
xml = build_reactflow_system_xml(project)
|
||||||
|
|
||||||
|
self.assertIn("PHYSICAL_PORT_ALREADY_CONNECTED", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_unconnected_registered_port_is_a_warning(self) -> None:
|
||||||
|
project = physical_connection_project()
|
||||||
|
project.edges.clear()
|
||||||
|
|
||||||
|
report = validate_system_xml_document(build_reactflow_system_xml(project))
|
||||||
|
|
||||||
|
self.assertTrue(report.valid)
|
||||||
|
self.assertEqual(report.as_dict()["warningCount"], 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{issue.code for issue in report.issues},
|
||||||
|
{"PORT_UNCONNECTED"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_required_parameter_is_reported(self) -> None:
|
||||||
|
xml = valid_xml().replace(
|
||||||
|
b' <Parameter name="T0" value="300" />\n',
|
||||||
|
b"",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("PARAMETER_REQUIRED_MISSING", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_unsupported_solver_method_is_reported(self) -> None:
|
||||||
|
xml = valid_xml().replace(b'method="BDF"', b'method="unknown"')
|
||||||
|
|
||||||
|
self.assertIn("SIMULATION_METHOD_UNSUPPORTED", issue_codes(xml))
|
||||||
|
|
||||||
|
def test_non_positive_simulation_steps_are_rejected_by_schema(self) -> None:
|
||||||
|
xml = valid_xml().replace(b'step="0.2"', b'step="0"')
|
||||||
|
xml = xml.replace(b'maxStep="0.01"', b'maxStep="-1"')
|
||||||
|
|
||||||
|
report = validate_system_xml_document(xml)
|
||||||
|
|
||||||
|
self.assertFalse(report.valid)
|
||||||
|
self.assertEqual(
|
||||||
|
[issue.code for issue in report.issues].count("XSD_VALIDATION_ERROR"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,44 +6,93 @@ from xml.etree import ElementTree as ET
|
|||||||
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
||||||
|
|
||||||
|
|
||||||
class SystemXmlProtocolTests(unittest.TestCase):
|
def physical_port(
|
||||||
def test_v1_xml_contains_simulation_and_stable_section_order(self) -> None:
|
name: str,
|
||||||
project = ReactFlowProjectPayload(
|
role: str,
|
||||||
name="protocol-test",
|
side: str,
|
||||||
nodes=[
|
*,
|
||||||
{
|
domain: str = "pneumatic",
|
||||||
"id": "cylinder_1",
|
) -> dict[str, str]:
|
||||||
"type": "simulationComponent",
|
return {
|
||||||
"position": {"x": 12.5, "y": 24.0},
|
"name": name,
|
||||||
"data": {
|
"kind": "physical",
|
||||||
"label": "source-cylinder",
|
"domain": domain,
|
||||||
"componentType": "cylinder",
|
"nominalRole": role,
|
||||||
"modelType": "cylinder",
|
"positiveFlowDirection": "intoComponent",
|
||||||
"ports": ["port_b"],
|
"side": side,
|
||||||
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
|
}
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges=[],
|
|
||||||
simulation={
|
|
||||||
"t_start": 1.0,
|
|
||||||
"t_stop": 5.0,
|
|
||||||
"step": 0.2,
|
|
||||||
"max_step": 0.01,
|
|
||||||
"method": "BDF",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
root = ET.fromstring(build_reactflow_system_xml(project))
|
|
||||||
|
def physical_connection_project(*, reverse_edge: bool = False) -> ReactFlowProjectPayload:
|
||||||
|
source = {
|
||||||
|
"id": "cylinder_1",
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": 12.5, "y": 24.0},
|
||||||
|
"data": {
|
||||||
|
"label": "source-cylinder",
|
||||||
|
"componentType": "cylinder",
|
||||||
|
"modelType": "cylinder",
|
||||||
|
"ports": [physical_port("port_b", "outlet", "right")],
|
||||||
|
"parameters": {"volume": 0.01, "p0": 35000000, "T0": 300},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
target = {
|
||||||
|
"id": "tank_1",
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": 420.0, "y": 24.0},
|
||||||
|
"data": {
|
||||||
|
"label": "receiver-tank",
|
||||||
|
"componentType": "tank",
|
||||||
|
"modelType": "tank",
|
||||||
|
"ports": [physical_port("port_a", "inlet", "left")],
|
||||||
|
"parameters": {"volume": 0.1, "p0": 100000, "T0": 300},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
edge = (
|
||||||
|
{
|
||||||
|
"id": "edge-1",
|
||||||
|
"source": "tank_1",
|
||||||
|
"sourceHandle": "port_a",
|
||||||
|
"target": "cylinder_1",
|
||||||
|
"targetHandle": "port_b",
|
||||||
|
}
|
||||||
|
if reverse_edge
|
||||||
|
else {
|
||||||
|
"id": "edge-1",
|
||||||
|
"source": "cylinder_1",
|
||||||
|
"sourceHandle": "port_b",
|
||||||
|
"target": "tank_1",
|
||||||
|
"targetHandle": "port_a",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ReactFlowProjectPayload(
|
||||||
|
name="protocol-test",
|
||||||
|
nodes=[source, target],
|
||||||
|
edges=[edge],
|
||||||
|
simulation={
|
||||||
|
"t_start": 1.0,
|
||||||
|
"t_stop": 5.0,
|
||||||
|
"step": 0.2,
|
||||||
|
"max_step": 0.01,
|
||||||
|
"method": "BDF",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemXmlProtocolTests(unittest.TestCase):
|
||||||
|
def test_v2_xml_contains_port_metadata_and_neutral_physical_endpoints(self) -> None:
|
||||||
|
root = ET.fromstring(build_reactflow_system_xml(physical_connection_project()))
|
||||||
|
|
||||||
self.assertEqual(root.tag, "System")
|
self.assertEqual(root.tag, "System")
|
||||||
self.assertEqual(root.attrib["name"], "protocol-test")
|
self.assertEqual(root.attrib["name"], "protocol-test")
|
||||||
self.assertEqual(root.attrib["schemaVersion"], "1")
|
self.assertEqual(root.attrib["schemaVersion"], "2")
|
||||||
self.assertEqual(root.attrib["unitSystem"], "SI")
|
self.assertEqual(root.attrib["unitSystem"], "SI")
|
||||||
self.assertEqual([child.tag for child in root], ["Simulation", "Components", "Connections"])
|
self.assertEqual(
|
||||||
|
[child.tag for child in root],
|
||||||
|
["Simulation", "Components", "Connections"],
|
||||||
|
)
|
||||||
|
|
||||||
simulation = root.find("Simulation")
|
simulation = root.find("Simulation")
|
||||||
self.assertIsNotNone(simulation)
|
|
||||||
assert simulation is not None
|
assert simulation is not None
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
simulation.attrib,
|
simulation.attrib,
|
||||||
@@ -56,11 +105,125 @@ class SystemXmlProtocolTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
component = root.find("./Components/Component")
|
port = root.find("./Components/Component[@id='cylinder_1']/Port")
|
||||||
self.assertIsNotNone(component)
|
assert port is not None
|
||||||
|
component = root.find("./Components/Component[@id='cylinder_1']")
|
||||||
assert component is not None
|
assert component is not None
|
||||||
self.assertEqual(component.attrib["id"], "cylinder_1")
|
self.assertEqual(component.attrib["rotation"], "0")
|
||||||
self.assertEqual(component.find("./Parameter[@name='p0']").attrib["value"], "35000000")
|
self.assertEqual(component.attrib["mirrored"], "false")
|
||||||
|
self.assertEqual(
|
||||||
|
port.attrib,
|
||||||
|
{
|
||||||
|
"name": "port_b",
|
||||||
|
"kind": "physical",
|
||||||
|
"domain": "pneumatic",
|
||||||
|
"nominalRole": "outlet",
|
||||||
|
"side": "right",
|
||||||
|
"positiveFlowDirection": "intoComponent",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = root.find("./Connections/Connection")
|
||||||
|
assert connection is not None
|
||||||
|
self.assertEqual(
|
||||||
|
connection.attrib,
|
||||||
|
{"id": "edge-1", "kind": "physical", "domain": "pneumatic"},
|
||||||
|
)
|
||||||
|
self.assertNotIn("source", connection.attrib)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
(endpoint.attrib["component"], endpoint.attrib["port"])
|
||||||
|
for endpoint in connection.findall("Endpoint")
|
||||||
|
},
|
||||||
|
{("cylinder_1", "port_b"), ("tank_1", "port_a")},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all("role" not in endpoint.attrib for endpoint in connection.findall("Endpoint"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_physical_connection_meaning_does_not_depend_on_drag_direction(self) -> None:
|
||||||
|
forward = ET.fromstring(
|
||||||
|
build_reactflow_system_xml(physical_connection_project())
|
||||||
|
)
|
||||||
|
reverse = ET.fromstring(
|
||||||
|
build_reactflow_system_xml(physical_connection_project(reverse_edge=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
def endpoint_set(root: ET.Element) -> set[tuple[str, str]]:
|
||||||
|
return {
|
||||||
|
(endpoint.attrib["component"], endpoint.attrib["port"])
|
||||||
|
for endpoint in root.findall("./Connections/Connection/Endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(endpoint_set(forward), endpoint_set(reverse))
|
||||||
|
|
||||||
|
def test_component_orientation_is_exported_as_layout_metadata(self) -> None:
|
||||||
|
project = physical_connection_project()
|
||||||
|
project.nodes[0].data.rotation = 90
|
||||||
|
project.nodes[0].data.mirrored = True
|
||||||
|
|
||||||
|
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||||
|
component = root.find("./Components/Component[@id='cylinder_1']")
|
||||||
|
assert component is not None
|
||||||
|
self.assertEqual(component.attrib["rotation"], "90")
|
||||||
|
self.assertEqual(component.attrib["mirrored"], "true")
|
||||||
|
|
||||||
|
def test_legacy_string_port_is_migrated_when_exporting_v2(self) -> None:
|
||||||
|
project = ReactFlowProjectPayload(
|
||||||
|
name="legacy-port",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "tank_1",
|
||||||
|
"data": {
|
||||||
|
"label": "tank_1",
|
||||||
|
"componentType": "tank",
|
||||||
|
"modelType": "tank",
|
||||||
|
"ports": ["port_a"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||||
|
port = root.find("./Components/Component/Port")
|
||||||
|
assert port is not None
|
||||||
|
self.assertEqual(port.attrib["kind"], "physical")
|
||||||
|
self.assertEqual(port.attrib["domain"], "pneumatic")
|
||||||
|
self.assertEqual(port.attrib["nominalRole"], "inlet")
|
||||||
|
self.assertEqual(port.attrib["positiveFlowDirection"], "intoComponent")
|
||||||
|
self.assertEqual(port.attrib["side"], "left")
|
||||||
|
|
||||||
|
def test_legacy_tee_port_names_do_not_restore_direction_constraints(self) -> None:
|
||||||
|
project = ReactFlowProjectPayload(
|
||||||
|
name="legacy-tee",
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"id": "tee_1",
|
||||||
|
"data": {
|
||||||
|
"label": "tee_1",
|
||||||
|
"componentType": "tee",
|
||||||
|
"modelType": "tee",
|
||||||
|
"ports": ["port_in", "port_out1", "port_out2"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
root = ET.fromstring(build_reactflow_system_xml(project))
|
||||||
|
ports = root.findall("./Components/Component/Port")
|
||||||
|
self.assertEqual(
|
||||||
|
[port.attrib["nominalRole"] for port in ports],
|
||||||
|
["bidirectional", "bidirectional", "bidirectional"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incompatible_physical_domains_are_rejected(self) -> None:
|
||||||
|
project = physical_connection_project()
|
||||||
|
tank_port = project.nodes[1].data.ports[0]
|
||||||
|
assert not isinstance(tank_port, str)
|
||||||
|
tank_port.domain = "hydraulic"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "incompatible domains"):
|
||||||
|
build_reactflow_system_xml(project)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user