完善 XML 通用仿真与结果查看

This commit is contained in:
ljz committed 2026-07-21 13:42:55 +08:00
1 parent 104d41d294
commit f1256a121d
36 files changed
+6666 -196

No files matched your search

+3 -2
View File
@@ -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` 专名兼容层,还没有完全转成通用表达。
+39 -1
View File
@@ -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,
*, *,
+42 -2
View File
@@ -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"]
+62 -2
View File
@@ -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,
*, *,
+95
View File
@@ -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"]
+39 -1
View File
@@ -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,
*, *,
+68 -3
View File
@@ -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,
+258
View File
@@ -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
+53
View File
@@ -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."""
+36
View File
@@ -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
View File
@@ -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
],
}
+90 -1
View File
@@ -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"
+1
View File
@@ -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,
) )
+119
View File
@@ -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,
)
+208
View File
@@ -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
+358
View File
@@ -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,
)
+1 -1
View File
@@ -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
+24 -2
View File
@@ -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
View File
@@ -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"
+844
View File
@@ -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,
)
+2
View File
@@ -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 仿真层之间的稳定交换格式。
## 基本约定 ## 基本约定
+216
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+932
View File
@@ -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+$/, "");
}
+37
View File
@@ -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
View File
@@ -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;
}
} }
+2 -1
View File
@@ -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
+2
View File
@@ -1,2 +1,4 @@
fastapi fastapi
lxml>=5,<7
scipy>=1.13,<2
uvicorn[standard] uvicorn[standard]
+135
View File
@@ -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>
+24
View File
@@ -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
+30
View File
@@ -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%
+31
View File
@@ -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%
+185
View File
@@ -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()
+381
View File
@@ -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()
+241
View File
@@ -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()
+198 -35
View File
@@ -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__":