From f1256a121de56b9190be2118c4d9f0032aa25194 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Tue, 21 Jul 2026 13:42:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20XML=20=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E4=BB=BF=E7=9C=9F=E4=B8=8E=E7=BB=93=E6=9E=9C=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PythonModels/README.md | 5 +- PythonModels/components/cylinder.py | 40 +- PythonModels/components/orifice.py | 44 +- PythonModels/components/pipe.py | 64 +- PythonModels/components/resistive_pipe.py | 95 ++ PythonModels/components/tank.py | 40 +- PythonModels/components/tee.py | 71 +- PythonModels/core/algebraic.py | 258 ++++++ PythonModels/core/base.py | 53 ++ PythonModels/core/equations.py | 36 + PythonModels/core/network.py | 258 +++++- PythonModels/core/ports.py | 91 +- PythonModels/core/solver.py | 1 + PythonModels/core/stream.py | 119 +++ PythonModels/registry.py | 208 +++++ PythonModels/systems/generic.py | 358 ++++++++ PythonModels/systems/testmodel_closure.py | 2 +- README.md | 26 +- app/main.py | 433 ++++++++- app/system_xml.py | 844 ++++++++++++++++++ docs/system-xml-v1.md | 2 + docs/system-xml-v2.md | 216 +++++ frontend/src/App.tsx | 823 +++++++++++++++-- frontend/src/SimulationResultsView.tsx | 932 ++++++++++++++++++++ frontend/src/WorkspaceViewTabs.tsx | 37 + frontend/src/styles.css | 541 +++++++++++- frontend/start-dev.ps1 | 3 +- requirements.txt | 2 + schemas/system-simulation-v2.xsd | 135 +++ start-all.bat | 24 + start-backend.bat | 30 + start-reactflow.bat | 31 + tests/test_component_interfaces.py | 185 ++++ tests/test_generic_system_xml_simulation.py | 381 ++++++++ tests/test_system_xml_parser.py | 241 +++++ tests/test_system_xml_protocol.py | 233 ++++- 36 files changed, 6666 insertions(+), 196 deletions(-) create mode 100644 PythonModels/components/resistive_pipe.py create mode 100644 PythonModels/core/algebraic.py create mode 100644 PythonModels/core/equations.py create mode 100644 PythonModels/core/stream.py create mode 100644 PythonModels/registry.py create mode 100644 PythonModels/systems/generic.py create mode 100644 app/system_xml.py create mode 100644 docs/system-xml-v2.md create mode 100644 frontend/src/SimulationResultsView.tsx create mode 100644 frontend/src/WorkspaceViewTabs.tsx create mode 100644 schemas/system-simulation-v2.xsd create mode 100644 start-all.bat create mode 100644 start-backend.bat create mode 100644 start-reactflow.bat create mode 100644 tests/test_component_interfaces.py create mode 100644 tests/test_generic_system_xml_simulation.py create mode 100644 tests/test_system_xml_parser.py diff --git a/PythonModels/README.md b/PythonModels/README.md index 35ccea7..b05b5e4 100644 --- a/PythonModels/README.md +++ b/PythonModels/README.md @@ -165,12 +165,13 @@ `testmodel_tank_temperature.svg` 11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。 12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。 +13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。 当前没有实现: - 通用 DAE 初始化器 - `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` 的严格复刻。 - 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。 - `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。 -- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。 +- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。 - 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。 - 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。 - 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。 diff --git a/PythonModels/components/cylinder.py b/PythonModels/components/cylinder.py index 066fb27..d6508dc 100644 --- a/PythonModels/components/cylinder.py +++ b/PythonModels/components/cylinder.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping + from PythonModels.core.base import DynamicComponent +from PythonModels.core.equations import EquationResidual from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from PythonModels.core.ports import PortState from PythonModels.core.state import VolumeState @@ -23,7 +26,9 @@ class Cylinder(DynamicComponent): m0 = p0 * V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) 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]: return self.state.as_vector() @@ -37,6 +42,39 @@ class Cylinder(DynamicComponent): self.port_b.h_outflow = props.h 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( self, *, diff --git a/PythonModels/components/orifice.py b/PythonModels/components/orifice.py index 9da6282..bff20cd 100644 --- a/PythonModels/components/orifice.py +++ b/PythonModels/components/orifice.py @@ -1,8 +1,10 @@ from __future__ import annotations +from collections.abc import Mapping from math import sqrt from PythonModels.core.base import AlgebraicComponent +from PythonModels.core.equations import EquationResidual from PythonModels.core.ports import PortState @@ -13,8 +15,12 @@ class Orifice(AlgebraicComponent): super().__init__(name=name) self.opening = opening self.K = K - self.port_a = PortState() - self.port_b = PortState() + self.port_a = self.register_port( + PortState.pneumatic("port_a", nominal_role="inlet") + ) + self.port_b = self.register_port( + PortState.pneumatic("port_b", nominal_role="outlet") + ) @property def K_eff(self) -> float: @@ -26,3 +32,37 @@ class Orifice(AlgebraicComponent): return 0.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"] + diff --git a/PythonModels/components/pipe.py b/PythonModels/components/pipe.py index 6fb9fe5..36cca46 100644 --- a/PythonModels/components/pipe.py +++ b/PythonModels/components/pipe.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping + from PythonModels.core.base import DynamicComponent +from PythonModels.core.equations import EquationResidual from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from PythonModels.core.ports import PortState from PythonModels.core.state import VolumeState @@ -29,8 +32,12 @@ class Pipe(DynamicComponent): m0 = p0 * self.V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) self.state = VolumeState(m=m0, U=U0) - self.port_a = PortState() - self.port_b = PortState() + self.port_a = self.register_port( + PortState.pneumatic("port_a", nominal_role="inlet") + ) + self.port_b = self.register_port( + PortState.pneumatic("port_b", nominal_role="outlet") + ) def get_state_vector(self) -> list[float]: return self.state.as_vector() @@ -45,11 +52,64 @@ class Pipe(DynamicComponent): self.port_b.h_outflow = props.h 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: resistance = self.lambda_darcy * (self.L / self.D) dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area) 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( self, *, diff --git a/PythonModels/components/resistive_pipe.py b/PythonModels/components/resistive_pipe.py new file mode 100644 index 0000000..cce4fe3 --- /dev/null +++ b/PythonModels/components/resistive_pipe.py @@ -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"] diff --git a/PythonModels/components/tank.py b/PythonModels/components/tank.py index 8f51d69..6fd4712 100644 --- a/PythonModels/components/tank.py +++ b/PythonModels/components/tank.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping + from PythonModels.core.base import DynamicComponent +from PythonModels.core.equations import EquationResidual from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties from PythonModels.core.ports import PortState from PythonModels.core.state import VolumeState @@ -23,7 +26,9 @@ class Tank(DynamicComponent): m0 = p0 * V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) 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]: return self.state.as_vector() @@ -37,6 +42,39 @@ class Tank(DynamicComponent): self.port_a.h_outflow = props.h 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( self, *, diff --git a/PythonModels/components/tee.py b/PythonModels/components/tee.py index 6602435..5c84522 100644 --- a/PythonModels/components/tee.py +++ b/PythonModels/components/tee.py @@ -1,6 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping + from PythonModels.core.base import AlgebraicComponent +from PythonModels.core.equations import EquationResidual from PythonModels.core.ports import PortState @@ -9,9 +12,71 @@ class Tee(AlgebraicComponent): def __init__(self, name: str) -> None: super().__init__(name=name) - self.port_in = PortState() - self.port_out1 = PortState() - self.port_out2 = PortState() + self.port_in = self.register_port( + PortState.pneumatic("port_in", nominal_role="bidirectional") + ) + self.port_out1 = self.register_port( + PortState.pneumatic("port_out1", nominal_role="bidirectional") + ) + self.port_out2 = self.register_port( + PortState.pneumatic("port_out2", nominal_role="bidirectional") + ) + + 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( self, diff --git a/PythonModels/core/algebraic.py b/PythonModels/core/algebraic.py new file mode 100644 index 0000000..02e1656 --- /dev/null +++ b/PythonModels/core/algebraic.py @@ -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 diff --git a/PythonModels/core/base.py b/PythonModels/core/base.py index bac666e..ca6710c 100644 --- a/PythonModels/core/base.py +++ b/PythonModels/core/base.py @@ -1,11 +1,55 @@ from __future__ import annotations 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): def __init__(self, name: str) -> None: 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): @@ -43,6 +87,15 @@ class DynamicComponent(Component): def set_state_vector(self, values: list[float]) -> None: raise NotImplementedError + def refresh_thermodynamic_ports(self) -> Any: + raise NotImplementedError + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + raise NotImplementedError + class AlgebraicComponent(Component): """Stateless element described by algebraic constraints only.""" diff --git a/PythonModels/core/equations.py b/PythonModels/core/equations.py new file mode 100644 index 0000000..930132e --- /dev/null +++ b/PythonModels/core/equations.py @@ -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} diff --git a/PythonModels/core/network.py b/PythonModels/core/network.py index e912867..c1a275a 100644 --- a/PythonModels/core/network.py +++ b/PythonModels/core/network.py @@ -3,14 +3,67 @@ from __future__ import annotations from dataclasses import dataclass 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) class Connection: - source_component: str - source_port: str - target_component: str - target_port: str + id: str + kind: str + domain: 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: @@ -28,19 +81,159 @@ class SimulationNetwork: def connect( self, - source_component: str, - source_port: str, - target_component: str, - target_port: str, - ) -> None: - self.connections.append( - Connection( - source_component=source_component, - source_port=source_port, - target_component=target_component, - target_port=target_port, + endpoint_a_component: str, + endpoint_a_port: str, + endpoint_b_component: str, + endpoint_b_port: str, + *, + connection_id: str | None = None, + ) -> Connection: + endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port) + endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port) + if endpoint_a == endpoint_b: + 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]: return [ @@ -70,9 +263,40 @@ class SimulationNetwork: lines.append(f" - {name}: {component.__class__.__name__}") lines.append("Connections:") for conn in self.connections: + connector = "<->" if conn.kind == "physical" else "->" lines.append( - f" - {conn.source_component}.{conn.source_port}" - f" -> {conn.target_component}.{conn.target_port}" + f" - {conn.endpoint_a} {connector} {conn.endpoint_b}" ) 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 + ], + } + diff --git a/PythonModels/core/ports.py b/PythonModels/core/ports.py index d340db2..6ee87ff 100644 --- a/PythonModels/core/ports.py +++ b/PythonModels/core/ports.py @@ -1,6 +1,70 @@ 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 @@ -10,4 +74,29 @@ class PortState: p: float = 0.0 m_flow: float = 0.0 h_outflow: float = 0.0 + definition: PortDefinition | None = field(default=None, repr=False, compare=False) + + @classmethod + def pneumatic( + cls, + name: str, + *, + nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional", + ) -> PortState: + return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role)) + + @property + def inflow_rate(self) -> float: + return max(self.m_flow, 0.0) + + @property + def outflow_rate(self) -> float: + return max(-self.m_flow, 0.0) + + def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection: + if self.m_flow > tolerance: + return "in" + if self.m_flow < -tolerance: + return "out" + return "stagnant" diff --git a/PythonModels/core/solver.py b/PythonModels/core/solver.py index 3e101e1..8533fcb 100644 --- a/PythonModels/core/solver.py +++ b/PythonModels/core/solver.py @@ -98,5 +98,6 @@ def integrate_ode( method=config.method, rtol=config.rtol, atol=config.atol, + max_step=config.max_step, t_eval=t_eval, ) diff --git a/PythonModels/core/stream.py b/PythonModels/core/stream.py new file mode 100644 index 0000000..a0eca86 --- /dev/null +++ b/PythonModels/core/stream.py @@ -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, + ) diff --git a/PythonModels/registry.py b/PythonModels/registry.py new file mode 100644 index 0000000..3950af0 --- /dev/null +++ b/PythonModels/registry.py @@ -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 diff --git a/PythonModels/systems/generic.py b/PythonModels/systems/generic.py new file mode 100644 index 0000000..44fac46 --- /dev/null +++ b/PythonModels/systems/generic.py @@ -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, + ) diff --git a/PythonModels/systems/testmodel_closure.py b/PythonModels/systems/testmodel_closure.py index cbcaff8..e0846e4 100644 --- a/PythonModels/systems/testmodel_closure.py +++ b/PythonModels/systems/testmodel_closure.py @@ -586,7 +586,7 @@ class TestModelClosure: self.components.upstream_tee.port_in.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_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_out1.h_outflow = cylinder.h self.components.upstream_tee.port_out2.h_outflow = cylinder.h diff --git a/README.md b/README.md index bc1f169..63b983b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,30 @@ 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 v1 XSD](schemas/system-simulation-v1.xsd) +- [System XML v2 协议](docs/system-xml-v2.md) +- [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) diff --git a/app/main.py b/app/main.py index 3cd340e..3ef9374 100644 --- a/app/main.py +++ b/app/main.py @@ -3,18 +3,28 @@ from __future__ import annotations from datetime import datetime, timezone import json from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, Literal 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 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") FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist" PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" -SYSTEM_XML_SCHEMA_VERSION = "1" +SYSTEM_XML_SCHEMA_VERSION = "2" SYSTEM_XML_UNIT_SYSTEM = "SI" @@ -23,12 +33,29 @@ class ReactFlowPosition(BaseModel): 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): label: str = "" componentType: 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) + rotation: Literal[0, 90, 180, 270] = 0 + mirrored: bool = False class ReactFlowNodePayload(BaseModel): @@ -130,10 +157,11 @@ def frontend_asset(path: str) -> FileResponse: @app.post("/api/reactflow/system-xml") def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response: - return Response( - content=build_reactflow_system_xml(payload), - media_type="application/xml", - ) + try: + xml = build_reactflow_system_xml(payload) + 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") @@ -191,6 +219,178 @@ def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str, 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: system = ET.Element( "System", @@ -213,6 +413,7 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: ) components_node = ET.SubElement(system, "Components") connections_node = ET.SubElement(system, "Connections") + port_index: dict[tuple[str, str], ReactFlowPortDefinition] = {} for node in project.nodes: component_node = ET.SubElement( @@ -225,10 +426,34 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: "componentType": node.data.componentType, "x": f"{node.position.x:g}", "y": f"{node.position.y:g}", + "rotation": str(node.data.rotation), + "mirrored": str(node.data.mirrored).lower(), }, ) - for port in node.data.ports: - ET.SubElement(component_node, "Port", {"name": port}) + for index, port in enumerate(node.data.ports): + 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(): ET.SubElement( component_node, @@ -237,22 +462,200 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: ) 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, "Connection", { "id": edge.id, - "source": edge.source, - "sourcePort": edge.sourceHandle or "", - "target": edge.target, - "targetPort": edge.targetHandle or "", + "kind": first.kind, + "domain": first.domain, }, ) + 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=" ") 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 ''}." + ) + 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: safe_id = sanitize_project_id(project_id) return PROJECT_STORAGE_DIR / f"{safe_id}.json" diff --git a/app/system_xml.py b/app/system_xml.py new file mode 100644 index 0000000..7262deb --- /dev/null +++ b/app/system_xml.py @@ -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, + ) diff --git a/docs/system-xml-v1.md b/docs/system-xml-v1.md index c91aa24..d3b5f85 100644 --- a/docs/system-xml-v1.md +++ b/docs/system-xml-v1.md @@ -1,5 +1,7 @@ # System XML v1 协议 +> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。 + System XML 是 ReactFlow 前端与 PythonModels 仿真层之间的稳定交换格式。 ## 基本约定 diff --git a/docs/system-xml-v2.md b/docs/system-xml-v2.md new file mode 100644 index 0000000..fab5bbb --- /dev/null +++ b/docs/system-xml-v2.md @@ -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 + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## 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 +``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afcbcb7..0f04c1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,16 +2,20 @@ import { ChangeEvent, DragEvent, useCallback, useEffect, useMemo, useRef, useSta import { ClipboardPaste, Copy, + FlipHorizontal2, ListChecks, Magnet, Maximize2, Redo2, + RotateCw, Trash2, Undo2, type LucideIcon, } from "lucide-react"; import { Background, + ConnectionLineType, + ConnectionMode, Controls, Handle, MiniMap, @@ -31,10 +35,33 @@ import { useEdgesState, useNodesState, useReactFlow, + useUpdateNodeInternals, } from "@xyflow/react"; +import { + EmptySimulationResultsView, + SimulationResultsView, + type SimulationResultsSnapshot, +} from "./SimulationResultsView"; +import { + WorkspaceViewTabs, + type WorkspaceView, +} from "./WorkspaceViewTabs"; type ParameterValue = number | string; type UnitQuantity = "pressure" | "volume" | "temperature" | "length"; +type PortKind = "physical" | "signal"; +type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output"; +type PortSide = "left" | "right"; +type NodeRotation = 0 | 90 | 180 | 270; + +type PortDefinition = { + name: string; + kind: PortKind; + domain: string; + nominalRole: PortNominalRole; + positiveFlowDirection?: "intoComponent"; + side: PortSide; +}; type UnitOption = { value: string; @@ -57,7 +84,7 @@ type ComponentDefinition = { type: string; label: string; modelType: string; - ports: string[]; + ports: PortDefinition[]; parameters: Record; }; @@ -65,9 +92,11 @@ type SimulationNodeData = { label: string; componentType: string; modelType: string; - ports: string[]; + ports: PortDefinition[]; parameters: Record; parameterUnits: Record; + rotation: NodeRotation; + mirrored: boolean; }; type SimulationNode = Node; @@ -111,16 +140,20 @@ type ReactFlowProjectPayload = { type SimulationResult = { success: boolean; message: string; - final: { - time: number; - tankPressure: number; - tankTemperature: number; - cylinderPressure: number; - cylinderTemperature: number; - }; + final: Record; series: Record; - artifacts: Record; - networkSummary: string; + diagnostics: { + pressureFlow: { + solveCount: number; + maxScaledResidual: number; + maxEvaluationsPerSolve: number; + }; + stream: { + maxIterationsPerSolve: number; + }; + stateCount: number; + sampleCount: number; + }; }; type ValidationIssue = { @@ -145,8 +178,11 @@ type EditorClipboard = { const AUTOSAVE_KEY = "system-simulation-flow:autosave"; const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; +const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; const GRID_SIZE = 18; const HISTORY_LIMIT = 50; +const NODE_FRAME_WIDTH = 132; +const NODE_FRAME_HEIGHT = 84; const identityUnit = (value: number) => value; const scaleUnit = (factor: number) => ({ @@ -189,13 +225,29 @@ const defaultSimulationConfig: SimulationConfig = { max_step: 0.005, method: "BDF", }; +const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; + +function physicalPort( + name: string, + nominalRole: Extract, + side: PortSide, +): PortDefinition { + return { + name, + kind: "physical", + domain: "pneumatic", + nominalRole, + positiveFlowDirection: "intoComponent", + side, + }; +} const componentDefinitions: ComponentDefinition[] = [ { type: "cylinder", label: "气瓶", modelType: "cylinder", - ports: ["port_b"], + ports: [physicalPort("port_b", "outlet", "right")], parameters: { volume: { label: "容积", @@ -220,7 +272,7 @@ const componentDefinitions: ComponentDefinition[] = [ type: "tank", label: "贮箱", modelType: "tank", - ports: ["port_a"], + ports: [physicalPort("port_a", "inlet", "left")], parameters: { volume: { label: "容积", @@ -245,7 +297,10 @@ const componentDefinitions: ComponentDefinition[] = [ type: "pipe", label: "管段", modelType: "pipe", - ports: ["port_a", "port_b"], + ports: [ + physicalPort("port_a", "inlet", "left"), + physicalPort("port_b", "outlet", "right"), + ], parameters: { length: { label: "长度", @@ -279,7 +334,10 @@ const componentDefinitions: ComponentDefinition[] = [ type: "orifice", label: "孔板/阀门", modelType: "orifice", - ports: ["port_a", "port_b"], + ports: [ + physicalPort("port_a", "inlet", "left"), + physicalPort("port_b", "outlet", "right"), + ], parameters: { K: { label: "流量系数", default: 0.00001, min: 0 }, opening: { label: "开度", default: 1, min: 0, max: 1 }, @@ -289,7 +347,11 @@ const componentDefinitions: ComponentDefinition[] = [ type: "tee", label: "三通", modelType: "tee", - ports: ["port_in", "port_out1", "port_out2"], + ports: [ + physicalPort("port_in", "bidirectional", "left"), + physicalPort("port_out1", "bidirectional", "right"), + physicalPort("port_out2", "bidirectional", "right"), + ], parameters: {}, }, ]; @@ -318,46 +380,165 @@ function buildNodeData(definition: ComponentDefinition): SimulationNodeData { label: definition.label, componentType: definition.type, modelType: definition.modelType, - ports: definition.ports, + ports: definition.ports.map((port) => ({ ...port })), parameters: defaultParameters(definition), parameterUnits: defaultParameterUnits(definition), + rotation: 0, + mirrored: false, }; } -function SimulationComponentNode({ data, selected }: NodeProps) { - const inputPorts = data.ports.filter((port) => port.includes("in") || port === "port_a"); - const outputPorts = data.ports.filter((port) => !inputPorts.includes(port)); +function SimulationComponentNode({ id, data, selected }: NodeProps) { + const updateNodeInternals = useUpdateNodeInternals(); + const rotation = normalizeNodeRotation(data.rotation); + const mirrored = Boolean(data.mirrored); + const vertical = rotation === 90 || rotation === 270; + const portPlacements = transformedPortPlacements(data.ports, rotation, mirrored); + + useEffect(() => { + updateNodeInternals(id); + }, [mirrored, rotation, id, updateNodeInternals]); return ( -
-