Compare commits

..
5 Commits
Author SHA1 Message Date
ljz bbc88a6bbb 规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。

完善控制台、默认节点、视图适配及前端自动化测试。
2026-07-29 15:44:16 +08:00
ljz f7f1078911 完善仿真交互、结果展示与模型元数据 2026-07-22 19:33:38 +08:00
ljz f1256a121d 完善 XML 通用仿真与结果查看 2026-07-21 13:42:55 +08:00
ljz 104d41d294 规范化xml内容,并在xml中添加仿真设置,规范化版本号等内容,补充xml规范md文件 2026-07-15 15:16:58 +08:00
ljz c52266ba11 添加图形化界面撤销、复制、粘贴、删除等功能 2026-07-15 15:03:27 +08:00
89 changed files with 21368 additions and 954 deletions

No files matched your search

+3 -1
View File
@@ -12,11 +12,13 @@ htmlcov/
# Local virtual environments
.venv/
.venv-win/
PythonModels/runs/
app/data/
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
venv/
env/
-2
View File
@@ -1,2 +0,0 @@
__pycache__/
*.pyc
-2
View File
@@ -1,2 +0,0 @@
"""Python port scaffold for the Modelica-based pressurization system."""
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Cylinder(DynamicComponent):
"""Python port of ModelicaModels.Mycylinder."""
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.01,
p0: float = 35e6,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_b = PortState()
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
self.port_b.h_outflow = props.h
return props
def derivatives_from_connection(
self,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
from math import sqrt
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.ports import PortState
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None:
super().__init__(name=name)
self.opening = opening
self.K = K
self.port_a = PortState()
self.port_b = PortState()
@property
def K_eff(self) -> float:
return self.K * max(self.opening, 0.001)
def mass_flow(self, p_a: float, p_b: float) -> float:
dp = p_a - p_b
if dp == 0.0:
return 0.0
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
-133
View File
@@ -1,133 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Pipe(DynamicComponent):
"""Python port of ModelicaModels.Mypipe."""
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.area = 3.141592653589793 * D * D / 4.0
self.V = self.area * L
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()
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
self.port_a.h_outflow = props.h
self.port_b.h_outflow = props.h
return props
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 port_a_inlet_enthalpy(
self,
*,
port_a_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_a_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def port_b_inlet_enthalpy(
self,
*,
port_b_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_b_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def connection_inlet_enthalpies(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> tuple[float, float]:
return (
self.port_a_inlet_enthalpy(
port_a_m_flow=port_a_m_flow,
connected_h=connected_h_a,
internal_h=internal_h,
),
self.port_b_inlet_enthalpy(
port_b_m_flow=port_b_m_flow,
connected_h=connected_h_b,
internal_h=internal_h,
),
)
def derivatives_from_connections(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> VolumeState:
inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies(
port_a_m_flow=port_a_m_flow,
connected_h_a=connected_h_a,
port_b_m_flow=port_b_m_flow,
connected_h_b=connected_h_b,
internal_h=internal_h,
)
return self.derivatives(
inlet_h_a=inlet_h_a,
inlet_h_b=inlet_h_b,
m_flow_a=port_a_m_flow,
m_flow_b=port_b_m_flow,
)
def derivatives(
self,
inlet_h_a: float,
inlet_h_b: float,
m_flow_a: float,
m_flow_b: float,
) -> VolumeState:
dm_dt = m_flow_a + m_flow_b
dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b
return VolumeState(m=dm_dt, U=dU_dt)
-55
View File
@@ -1,55 +0,0 @@
from __future__ import annotations
from PythonModels.core.base import DynamicComponent
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
class Tank(DynamicComponent):
"""Python port of ModelicaModels.Mytank."""
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.1,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = PortState()
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_a.p = props.p
self.port_a.h_outflow = props.h
return props
def derivatives_from_connection(
self,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
class Component(ABC):
def __init__(self, name: str) -> None:
self.name = name
class DynamicComponent(Component):
state_size = 2
@staticmethod
def actual_stream_enthalpy(
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
return connected_h if port_m_flow > 0.0 else internal_h
def connection_inlet_enthalpy(
self,
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Resolve the enthalpy convected into this control volume through one port."""
return self.actual_stream_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
@abstractmethod
def get_state_vector(self) -> list[float]:
raise NotImplementedError
@abstractmethod
def set_state_vector(self, values: list[float]) -> None:
raise NotImplementedError
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
-78
View File
@@ -1,78 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.core.base import Component, DynamicComponent
@dataclass(frozen=True)
class Connection:
source_component: str
source_port: str
target_component: str
target_port: str
class SimulationNetwork:
"""Container for components, topology, and state-vector bookkeeping."""
def __init__(self, name: str) -> None:
self.name = name
self.components: dict[str, Component] = {}
self.connections: list[Connection] = []
def add_component(self, component: Component) -> None:
if component.name in self.components:
raise ValueError(f"Duplicate component name: {component.name}")
self.components[component.name] = component
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,
)
)
def dynamic_components(self) -> list[DynamicComponent]:
return [
component
for component in self.components.values()
if isinstance(component, DynamicComponent)
]
def initial_state_vector(self) -> list[float]:
values: list[float] = []
for component in self.dynamic_components():
values.extend(component.get_state_vector())
return values
def apply_state_vector(self, values: list[float]) -> None:
cursor = 0
for component in self.dynamic_components():
next_cursor = cursor + component.state_size
component.set_state_vector(values[cursor:next_cursor])
cursor = next_cursor
if cursor != len(values):
raise ValueError("State vector length does not match dynamic components.")
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
lines.append(f" - {name}: {component.__class__.__name__}")
lines.append("Connections:")
for conn in self.connections:
lines.append(
f" - {conn.source_component}.{conn.source_port}"
f" -> {conn.target_component}.{conn.target_port}"
)
return "\n".join(lines)
-13
View File
@@ -1,13 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class PortState:
"""Python-side analogue of a Modelica fluid port."""
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
-102
View File
@@ -1,102 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class SolveIVPConfig:
t_start: float = 0.0
t_stop: float = 20.0
method: str = "BDF"
rtol: float = 1e-6
atol: float = 1e-8
max_step: float = 1e-3
@dataclass(frozen=True)
class ODESolution:
t: list[float]
y: list[list[float]]
success: bool
message: str
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
return [x + scale * y for x, y in zip(a, b)]
def _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
) -> ODESolution:
if t_eval is None:
point_count = max(
2,
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
)
step = (config.t_stop - config.t_start) / (point_count - 1)
t_eval = [config.t_start + index * step for index in range(point_count)]
state = list(initial_state)
states = [[value] for value in state]
times = [float(t_eval[0])]
current_time = float(t_eval[0])
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
times.append(float(target_time))
for index, value in enumerate(state):
states[index].append(value)
return ODESolution(
t=times,
y=states,
success=True,
message="Integrated with built-in RK4 fallback because SciPy is unavailable.",
)
def integrate_ode(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None = None,
):
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
if abs(config.t_stop - config.t_start) <= 1e-15:
return ODESolution(
t=[float(config.t_start)],
y=[[value] for value in initial_state],
success=True,
message="Skipped integration because t_start equals t_stop.",
)
try:
from scipy.integrate import solve_ivp
except ImportError:
return _runge_kutta_4(rhs, initial_state, config, t_eval)
return solve_ivp(
fun=rhs,
t_span=(config.t_start, config.t_stop),
y0=initial_state,
method=config.method,
rtol=config.rtol,
atol=config.atol,
t_eval=t_eval,
)
+35
View File
@@ -1,2 +1,37 @@
# SystemSimulationApp
ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
- `POST /api/reactflow/system-xml`:导出 System XML v2。
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
- `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,然后创建 `app.simulation` 组件网络。
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
气动端口的后端契约采用 `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
```
## 文档
- [开发文档索引](docs/README.md)
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [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)
+931 -18
View File
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
# PythonModels
# 仿真后端
`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
@@ -8,34 +8,44 @@
## 当前目录
- `core/`: 通用基础设施
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
- `components/`: 元件级 Python 实现
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
- `systems/`: 系统级装配与闭合
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
- `reporting/`: 结果导出与对比
当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。
- `scripts/`: 运行脚本
当前入口是 `run_testmodel.py`。
- `baselines/`: 提交进仓库的稳定基线
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。
- `runs/`: 每次实际运行的默认输出目录
当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
- `components/experimental/junctions/`: 三通等连接节点。
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
当前关键文件:
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔
- `components/tee.py`: 三通的最小 stream 混合 helper
- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口
- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `scripts/run_testmodel.py`: 基线运行与程序化执行入口
- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
## 当前阶段进度
@@ -73,7 +83,7 @@
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
1. `M2`:已完成当前阶段首版
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py`
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
2. `M3`:已完成当前阶段首版
@@ -165,31 +175,31 @@
`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` 求解器
## 当前怎么运行
最小运行方式:
```bash
python3 -m PythonModels.scripts.run_testmodel
python -m app.simulation.examples.testmodel.run
```
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
```python
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.scripts.run_testmodel import (
from app.simulation.examples.testmodel.run import (
TestModelRunConfig,
TestModelSamplingConfig,
run_testmodel,
)
from PythonModels.systems.testmodel import (
from app.simulation.examples.testmodel.system import (
BranchConfig,
CylinderConfig,
OrificeConfig,
@@ -197,6 +207,7 @@ from PythonModels.systems.testmodel import (
TankConfig,
TestModelConfig,
)
from app.simulation.solvers.solver import SolveIVPConfig
run_config = TestModelRunConfig(
model=TestModelConfig(
@@ -217,7 +228,7 @@ result = run_testmodel(run_config=run_config)
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
```python
from PythonModels.scripts.run_testmodel import (
from app.simulation.examples.testmodel.run import (
prepare_testmodel_run,
run_prepared_testmodel,
TestModelRunConfig,
@@ -237,12 +248,13 @@ print(result.used_modelica_reference)
1. 构建 `TestModelSystem`
2. 打印原始初值向量与约束一致后的初值向量
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如:
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
- `PythonModels/runs/testmodel_20260512_103000_123456/`
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
该目录里通常会包含:
@@ -256,7 +268,7 @@ print(result.used_modelica_reference)
## 基线结果
当前基线对比摘要来自:
[testmodel_modelica_comparison_summary.txt](/home/lujz/projects/pressurization-transfer-system/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt)
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为:
@@ -288,7 +300,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` 专名兼容层,还没有完全转成通用表达。
@@ -317,24 +329,24 @@ print(result.used_modelica_reference)
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
- `core/state.py`: 正常
`VolumeState` 只负责 `[m, U]` 状态打包。
- `core/network.py`: 正常
- `systems/network.py`: 正常
负责状态向量拼装和连接摘要,不参与物理求解。
- `core/solver.py`: 正常
- `solvers/solver.py`: 正常
已支持 SciPy、RK4 回退和零时长仿真。
- `components/*.py`: 正常
- `components/experimental/**/*.py`: 正常
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
- `systems/testmodel.py`: 是当前最重要的技术债集中区
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
- `scripts/run_testmodel.py`: 正常
- `examples/testmodel/run.py`: 正常
已不是“最小打印脚本”,而是当前结果导出和对比入口。
- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
## 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事:
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
+2
View File
@@ -0,0 +1,2 @@
"""Simulation domain models, solvers, system assembly, and result tools."""
File renamed without changes.
+282
View File
@@ -0,0 +1,282 @@
# 元件建模规范与示例
规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。
本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前
`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库,
不要继续堆放在 `experimental` 中。
目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML
校验、求解器和前端分别维护同一份含义。
## 一、元件类必须声明的内容
每个对外注册的元件类至少需要声明以下六个类属性:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
元件构造函数还必须:
1. 调用 `super().__init__(name)`。
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
3. 使用 `register_declared_port()` 创建已声明端口。
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
## 二、输入参数与结果变量
输入参数和仿真结果必须分开声明:
- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。
- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。
- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。
- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。
参数定义示例:
```python
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
)
```
结果变量定义示例:
```python
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
)
```
## 三、命名和单位约定
- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。
- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。
- 无量纲参数的 `unit` 使用空字符串。
- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。
- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。
- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。
## 四、完整示例:单端口储气容腔
下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。
```python
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class ExampleVolume(ThermodynamicVolumeComponent):
MODEL_TYPE = "example_volume"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="p0",
label="初始压力",
quantity="pressure",
unit="Pa",
default=100000.0,
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
name="T0",
label="初始温度",
quantity="temperature",
unit="K",
default=300.0,
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="示例容腔",
library_id="experimental",
category_id="storage",
symbol="generic",
ports=(PortDisplaySpec("port_a", "left"),),
order=90,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
volume: float = 0.1,
p0: float = 100000.0,
T0: float = 300.0,
) -> None:
super().__init__(name)
self.set_parameter_values(
{"volume": volume, "p0": p0, "T0": T0}
)
self.medium = medium
self.V = volume
initial_mass = p0 * volume / (medium.R_gas * T0)
initial_energy = initial_mass * medium.specific_internal_energy(T0)
self.state = VolumeState(m=initial_mass, U=initial_energy)
self.port_a = self.register_declared_port("port_a")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleVolume:
return cls(
name=name,
medium=medium,
volume=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
properties = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
)
self.port_a.p = properties.p
self.port_a.h_outflow = properties.h
return properties
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
properties = self.refresh_thermodynamic_ports()
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=self.port_a.m_flow,
connected_h=connected_h["port_a"],
internal_h=properties.h,
)
return [self.port_a.m_flow, self.port_a.m_flow * inlet_h]
def pressure_flow_equation_residuals(
self,
) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m, self.state.U, self.V
).p
return (
EquationResidual(
id=f"{self.name}:port_a_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
role="effort",
value=self.port_a.p - pressure,
),
)
```
模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
`library.py` 的 `models` 清单:
```python
models=(
# ...已有模型
"app.simulation.components.experimental.storage.example_volume:ExampleVolume",
)
```
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
前端刷新时即可加载。
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
`library_id` 指向正式库。
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
```json
{
"key": "example_volume_1.port_a.m_flow",
"componentId": "example_volume_1",
"componentType": "example_volume",
"scope": "port",
"portName": "port_a",
"name": "m_flow",
"label": "质量流量",
"quantity": "mass_flow",
"unit": "kg/s",
"category": "flow",
"order": 20
}
```
## 五、新增元件检查清单
1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
6. 是否只暴露有工程意义的结果,而非内部计算变量。
7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
9. 模型类路径是否只加入所属库的 `library.py` 清单。
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
@@ -0,0 +1,12 @@
"""Temporary component library used to validate the model authoring contract."""
from app.simulation.components.experimental.library import LIBRARY
# Compatibility aliases for code written before the v1 library manifest.
LIBRARY_ID = LIBRARY.id
LIBRARY_LABEL = LIBRARY.label
LIBRARY_VERSION = LIBRARY.version
LIBRARY_ORDER = LIBRARY.order
LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package
LIBRARY_TEMPORARY = LIBRARY.temporary
@@ -0,0 +1 @@
"""Flow-path and resistance components."""
@@ -0,0 +1,118 @@
from __future__ import annotations
from collections.abc import Mapping
from math import sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Orifice(AlgebraicComponent):
"""Python port of ModelicaModels.Myorifice."""
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"K",
1e-5,
label="流量系数",
quantity="flow_coefficient",
unit="kg/(s*Pa^0.5)",
minimum=0.0,
),
ParameterDefinition(
"opening",
1.0,
label="开度",
minimum=0.0,
maximum=1.0,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="孔板/阀门",
library_id="experimental",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=40,
)
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
super().__init__(name=name)
self.set_parameter_values({"K": K, "opening": opening})
self.opening = opening
self.K = K
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Orifice:
return cls(
name=name,
opening=parameters["opening"],
K=parameters["K"],
)
@property
def K_eff(self) -> float:
return self.K * max(self.opening, 0.001)
def mass_flow(self, p_a: float, p_b: float) -> float:
dp = p_a - p_b
if dp == 0.0:
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"]
@@ -0,0 +1,10 @@
"""Compatibility import for the TestModel-only dynamic pipe.
The public ``pipe`` catalog model is ``ResistivePipe``. New code should import
this legacy dynamic model from ``app.simulation.examples.testmodel.dynamic_pipe``.
"""
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
__all__ = ("Pipe",)
@@ -0,0 +1,185 @@
from __future__ import annotations
from collections.abc import Mapping
from math import pi
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class ResistivePipe(AlgebraicComponent):
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="管段",
library_id="experimental",
category_id="flow",
symbol="pipe",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=30,
)
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.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
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 = self.register_declared_port("port_a")
self.port_a.p = p0
self.port_a.h_outflow = initial_h
self.port_b = self.register_declared_port("port_b")
self.port_b.p = p0
self.port_b.h_outflow = initial_h
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ResistivePipe:
return cls(
name=name,
medium=medium,
L=parameters["length"],
D=parameters["diameter"],
lambda_darcy=parameters["lambda_darcy"],
p0=parameters["p0"],
T0=parameters["T0"],
)
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"]
@@ -0,0 +1 @@
"""Flow junction components."""
@@ -1,17 +1,111 @@
from __future__ import annotations
from PythonModels.core.base import AlgebraicComponent
from PythonModels.core.ports import PortState
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class Tee(AlgebraicComponent):
"""Python port of ModelicaModels.Mytee."""
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="三通",
library_id="experimental",
category_id="junctions",
symbol="tee",
ports=(
PortDisplaySpec("port_in", "left", order=10),
PortDisplaySpec("port_out1", "right", order=20),
PortDisplaySpec("port_out2", "right", order=30),
),
order=50,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.port_in = PortState()
self.port_out1 = PortState()
self.port_out2 = PortState()
self.set_parameter_values({})
self.port_in = self.register_declared_port("port_in")
self.port_out1 = self.register_declared_port("port_out1")
self.port_out2 = self.register_declared_port("port_out2")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Tee:
return cls(name=name)
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,
@@ -0,0 +1,28 @@
"""Manifest for the temporary library used to validate component authoring."""
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="experimental",
label="临时测试组件库",
version="0.1.0",
source_package="app.simulation.components.experimental",
temporary=True,
order=100,
categories=(
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
),
models=(
"app.simulation.components.experimental.storage.cylinder:Cylinder",
"app.simulation.components.experimental.storage.tank:Tank",
"app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe",
"app.simulation.components.experimental.flow.orifice:Orifice",
"app.simulation.components.experimental.junctions.tee:Tee",
),
)
@@ -0,0 +1 @@
"""Storage and thermodynamic volume components."""
@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = "cylinder"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.01,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
35e6,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="气瓶",
library_id="experimental",
category_id="storage",
symbol="cylinder",
ports=(PortDisplaySpec("port_b", "right"),),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.01,
p0: float = 35e6,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Cylinder:
return cls(
name=name,
medium=medium,
V=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
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,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank."""
MODEL_TYPE = "tank"
MODEL_VERSION = "1.0.0"
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
PARAMETERS = (
ParameterDefinition(
"volume",
0.1,
label="容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="贮箱",
library_id="experimental",
category_id="storage",
symbol="tank",
ports=(PortDisplaySpec("port_a", "left"),),
order=20,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
V: float = 0.1,
p0: float = 1e5,
T0: float = 300.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
self.medium = medium
self.V = V
m0 = p0 * V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_declared_port("port_a")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Tank:
return cls(
name=name,
medium=medium,
V=parameters["volume"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_a.p = props.p
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,
*,
connected_h: float,
port_m_flow: float,
internal_h: float,
) -> VolumeState:
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
return self.derivatives(inlet_h, port_m_flow)
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
return VolumeState(m=m_flow, U=m_flow * inlet_h)
File renamed without changes.
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
ResultVariableMetadata,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
class Component(ABC):
MODEL_TYPE: ClassVar[str | None] = None
MODEL_VERSION: ClassVar[str | None] = None
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
def __init__(self, name: str) -> None:
self.name = name
self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower()
self._ports: dict[str, PortState] = {}
self._parameter_values: dict[str, float] = {}
@property
def ports(self) -> dict[str, PortState]:
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 register_declared_port(self, name: str) -> PortState:
try:
definition = next(item for item in self.PORTS if item.name == name)
except StopIteration as exc:
raise ValueError(
f"Component model {self.model_type} does not declare port {name}."
) from exc
return self.register_port(PortState(definition=definition))
def set_parameter_values(self, values: Mapping[str, float]) -> None:
definitions = {definition.name: definition for definition in self.PARAMETERS}
unknown = sorted(set(values) - set(definitions))
if unknown:
raise ValueError(
f"Component {self.name} contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
missing = sorted(set(definitions) - set(values))
if missing:
raise ValueError(
f"Component {self.name} is missing parameters: "
+ ", ".join(missing)
+ "."
)
resolved: dict[str, float] = {}
for name, definition in definitions.items():
value = float(values[name])
message = definition.validation_message(value)
if message is not None:
raise ValueError(
f"Parameter '{name}' on component '{self.name}' {message}."
)
resolved[name] = value
self._parameter_values = resolved
@property
def parameter_values(self) -> dict[str, float]:
return dict(self._parameter_values)
def get_port(self, name: str) -> PortState:
try:
return self._ports[name]
except KeyError as exc:
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
def component_result_values(self) -> Mapping[str, float]:
return {}
def result_values(self) -> dict[str, float]:
component_values = dict(self.component_result_values())
declared = {definition.name: definition for definition in self.RESULT_VARIABLES}
unknown = sorted(set(component_values) - set(declared))
if unknown:
raise ValueError(
f"Component {self.name} returned undeclared result variables: "
+ ", ".join(unknown)
+ "."
)
values: dict[str, float] = {}
for name, definition in declared.items():
if not definition.visible:
continue
if name not in component_values:
raise ValueError(
f"Component {self.name} did not provide declared result variable {name}."
)
values[name] = float(component_values[name])
for port_definition in self.port_definitions:
port = self.get_port(port_definition.name)
for variable in port_definition.variables:
if not variable.result_visible:
continue
values[f"{port_definition.name}.{variable.name}"] = float(
getattr(port, variable.name)
)
return values
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
metadata = [
ResultVariableMetadata(
key=f"{self.name}.{definition.name}",
component_id=self.name,
component_type=self.model_type,
scope="component",
name=definition.name,
label=definition.label,
quantity=definition.quantity,
unit=definition.unit,
category=definition.category,
order=definition.order,
)
for definition in self.RESULT_VARIABLES
if definition.visible
]
for port_definition in self.port_definitions:
for variable in port_definition.variables:
if not variable.result_visible:
continue
metadata.append(
ResultVariableMetadata(
key=f"{self.name}.{port_definition.name}.{variable.name}",
component_id=self.name,
component_type=self.model_type,
scope="port",
port_name=port_definition.name,
name=variable.name,
label=variable.label or variable.name,
quantity=variable.quantity or variable.name,
unit=variable.unit,
category=variable.role,
order=variable.order,
)
)
return tuple(metadata)
def parameter_interface_dicts(self) -> list[dict[str, object]]:
return [
definition.as_interface_dict(
value=self._parameter_values.get(definition.name)
)
for definition in self.PARAMETERS
]
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
raise NotImplementedError(
f"Component model {cls.__name__} must implement create()."
)
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):
state_size = 2
@staticmethod
def actual_stream_enthalpy(
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
return connected_h if port_m_flow > 0.0 else internal_h
def connection_inlet_enthalpy(
self,
port_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
"""Resolve the enthalpy convected into this control volume through one port."""
return self.actual_stream_enthalpy(
port_m_flow=port_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
@abstractmethod
def get_state_vector(self) -> list[float]:
raise NotImplementedError
@abstractmethod
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 ThermodynamicVolumeComponent(DynamicComponent):
"""Two-state gas volume exposing the shared thermodynamic result contract."""
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def component_result_values(self) -> Mapping[str, float]:
state = self.get_state_vector()
if len(state) < 2:
raise ValueError(
f"Thermodynamic component {self.name} must expose mass and energy states."
)
properties = self.refresh_thermodynamic_ports()
return {
"m": float(state[0]),
"U": float(state[1]),
"p": float(properties.p),
"T": float(properties.T),
"rho": float(properties.rho),
"u": float(properties.u),
"h": float(properties.h),
}
class AlgebraicComponent(Component):
"""Stateless element described by algebraic constraints only."""
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
PortDisplaySide = Literal["left", "right"]
@dataclass(frozen=True)
class ComponentCategorySpec:
"""A presentation-only category declared by one component library."""
id: str
label: str
order: int = 0
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"order": self.order,
}
@dataclass(frozen=True)
class PortDisplaySpec:
"""Canvas placement for one port without changing its physical contract."""
name: str
side: PortDisplaySide
order: int = 0
@dataclass(frozen=True)
class ComponentDisplaySpec:
"""Frontend metadata co-located with a component implementation."""
label: str
library_id: str
category_id: str
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
return {port.name: port for port in self.ports}
@dataclass(frozen=True)
class ComponentLibrarySpec:
"""Manifest for one explicitly enabled component library."""
id: str
label: str
version: str
source_package: str
categories: tuple[ComponentCategorySpec, ...]
models: tuple[str, ...]
temporary: bool = False
order: int = 0
@property
def category_by_id(self) -> dict[str, ComponentCategorySpec]:
return {category.id: category for category in self.categories}
def as_catalog_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"version": self.version,
"sourcePackage": self.source_package,
"temporary": self.temporary,
"order": self.order,
"categories": [
category.as_catalog_dict()
for category in sorted(
self.categories,
key=lambda item: (item.order, item.id),
)
],
}
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.simulation.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}
File renamed without changes.
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Literal
ResultVariableScope = Literal["component", "port"]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
"dimensionless": "",
"density": "kg/m³",
"flow_coefficient": "kg/(s*Pa^0.5)",
"internal_energy": "J",
"length": "m",
"mass": "kg",
"mass_flow": "kg/s",
"pressure": "Pa",
"specific_enthalpy": "J/kg",
"specific_internal_energy": "J/kg",
"temperature": "K",
"volume": "m3",
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
name: str
default: float
label: str = ""
quantity: str = "dimensionless"
unit: str = ""
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
return "must be finite"
if self.minimum is not None:
if self.minimum_exclusive and value <= self.minimum:
return f"must be greater than {self.minimum:g}"
if not self.minimum_exclusive and value < self.minimum:
return f"must be at least {self.minimum:g}"
if self.maximum is not None and value > self.maximum:
return f"must be at most {self.maximum:g}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
payload: dict[str, object] = {
"name": self.name,
"label": self.label or self.name,
"quantity": self.quantity,
"unit": self.unit,
"default": self.default,
"minimumExclusive": self.minimum_exclusive,
}
if self.minimum is not None:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if value is not None:
payload["value"] = value
return payload
@dataclass(frozen=True)
class ResultVariableDefinition:
"""Component-relative declaration of a user-visible simulation result."""
name: str
label: str
quantity: str
unit: str = ""
category: str = "derived"
order: int = 0
visible: bool = True
@dataclass(frozen=True)
class ResultVariableMetadata:
"""A result declaration bound to one concrete component instance."""
key: str
component_id: str
component_type: str
scope: ResultVariableScope
name: str
label: str
quantity: str
unit: str
category: str
order: int
port_name: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"key": self.key,
"componentId": self.component_id,
"componentType": self.component_type,
"scope": self.scope,
"portName": self.port_name,
"name": self.name,
"label": self.label,
"quantity": self.quantity,
"unit": self.unit,
"category": self.category,
"order": self.order,
}
THERMODYNAMIC_VOLUME_RESULT_VARIABLES = (
ResultVariableDefinition(
name="m",
label="质量",
quantity="mass",
unit="kg",
category="state",
order=10,
),
ResultVariableDefinition(
name="U",
label="内能",
quantity="internal_energy",
unit="J",
category="state",
order=20,
),
ResultVariableDefinition(
name="p",
label="压力",
quantity="pressure",
unit="Pa",
category="thermodynamic",
order=30,
),
ResultVariableDefinition(
name="T",
label="温度",
quantity="temperature",
unit="K",
category="thermodynamic",
order=40,
),
ResultVariableDefinition(
name="rho",
label="密度",
quantity="density",
unit="kg/m³",
category="thermodynamic",
order=50,
),
ResultVariableDefinition(
name="u",
label="比内能",
quantity="specific_internal_energy",
unit="J/kg",
category="thermodynamic",
order=60,
),
ResultVariableDefinition(
name="h",
label="比焓",
quantity="specific_enthalpy",
unit="J/kg",
category="thermodynamic",
order=70,
),
)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
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
label: str = field(default="", compare=False)
quantity: str = field(default="", compare=False)
unit: str = field(default="", compare=False)
result_visible: bool = field(default=True, compare=False)
order: int = field(default=0, compare=False)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
"role": self.role,
"connectionRule": self.connection_rule,
"label": self.label or self.name,
"quantity": self.quantity or self.name,
"unit": self.unit,
"resultVisible": self.result_visible,
"order": self.order,
}
@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",
label="压力",
quantity="pressure",
unit="Pa",
order=10,
),
PortVariableDefinition(
"m_flow",
"flow",
"sumToZero",
label="质量流量",
quantity="mass_flow",
unit="kg/s",
order=20,
),
PortVariableDefinition(
"h_outflow",
"stream",
"streamMix",
label="流出比焓",
quantity="specific_enthalpy",
unit="J/kg",
order=30,
),
),
)
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
class PortState:
"""Python-side analogue of a Modelica fluid port."""
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"
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
"""Reference systems and regression examples."""
@@ -0,0 +1 @@
"""Legacy TestModel reference system."""
@@ -3,13 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.pipe import Pipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
from PythonModels.core.state import VolumeState
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from app.simulation.components.experimental.junctions.tee import Tee
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
@@ -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
@@ -0,0 +1,272 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class Pipe(ThermodynamicVolumeComponent):
"""Dynamic pipe retained for the fixed TestModel compatibility example."""
MODEL_TYPE = "pipe"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
)
PARAMETERS = (
ParameterDefinition(
"length",
5.0,
label="长度",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"diameter",
0.02,
label="直径",
quantity="length",
unit="m",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"lambda_darcy",
0.02,
label="摩阻系数",
minimum=0.0,
),
ParameterDefinition(
"p0",
1e5,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
300.0,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
def __init__(
self,
name: str,
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.set_parameter_values(
{
"length": L,
"diameter": D,
"lambda_darcy": lambda_darcy,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.L = L
self.D = D
self.lambda_darcy = lambda_darcy
self.area = 3.141592653589793 * D * D / 4.0
self.V = self.area * L
m0 = p0 * self.V / (medium.R_gas * T0)
U0 = m0 * medium.specific_internal_energy(T0)
self.state = VolumeState(m=m0, U=U0)
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Pipe:
return cls(
name=name,
medium=medium,
L=parameters["length"],
D=parameters["diameter"],
lambda_darcy=parameters["lambda_darcy"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
self.port_b.p = props.p
self.port_a.h_outflow = props.h
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,
*,
port_a_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_a_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def port_b_inlet_enthalpy(
self,
*,
port_b_m_flow: float,
connected_h: float,
internal_h: float,
) -> float:
return self.connection_inlet_enthalpy(
port_m_flow=port_b_m_flow,
connected_h=connected_h,
internal_h=internal_h,
)
def connection_inlet_enthalpies(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> tuple[float, float]:
return (
self.port_a_inlet_enthalpy(
port_a_m_flow=port_a_m_flow,
connected_h=connected_h_a,
internal_h=internal_h,
),
self.port_b_inlet_enthalpy(
port_b_m_flow=port_b_m_flow,
connected_h=connected_h_b,
internal_h=internal_h,
),
)
def derivatives_from_connections(
self,
*,
port_a_m_flow: float,
connected_h_a: float,
port_b_m_flow: float,
connected_h_b: float,
internal_h: float,
) -> VolumeState:
inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies(
port_a_m_flow=port_a_m_flow,
connected_h_a=connected_h_a,
port_b_m_flow=port_b_m_flow,
connected_h_b=connected_h_b,
internal_h=internal_h,
)
return self.derivatives(
inlet_h_a=inlet_h_a,
inlet_h_b=inlet_h_b,
m_flow_a=port_a_m_flow,
m_flow_b=port_b_m_flow,
)
def derivatives(
self,
inlet_h_a: float,
inlet_h_b: float,
m_flow_a: float,
m_flow_b: float,
) -> VolumeState:
dm_dt = m_flow_a + m_flow_b
dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b
return VolumeState(m=dm_dt, U=dU_dt)
@@ -4,7 +4,18 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.reporting import (
from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics
from app.simulation.examples.testmodel.system import (
InitializationDiagnostics,
TestModelConfig,
TestModelSystem,
)
from app.simulation.paths import (
MODELICA_TESTMODEL_RESULT_PATH,
PROJECT_ROOT,
SIMULATION_RUNS_DIR,
)
from app.simulation.reporting import (
COMPARISON_KEYS,
PRIMARY_KEYS,
TestModelArtifacts,
@@ -13,13 +24,7 @@ from PythonModels.reporting import (
load_modelica_series,
write_testmodel_run_report,
)
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.systems.testmodel import (
InitializationDiagnostics,
TestModelConfig,
TestModelSystem,
)
from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics
from app.simulation.solvers.solver import SolveIVPConfig
@dataclass(frozen=True)
@@ -90,9 +95,9 @@ def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
return [t_start + index * step for index in range(point_count + 1)]
def _default_run_output_dir(pythonmodels_root: Path) -> Path:
def _default_run_output_dir() -> Path:
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
return SIMULATION_RUNS_DIR / timestamp
def prepare_testmodel_run(
@@ -102,22 +107,20 @@ def prepare_testmodel_run(
modelica_result_path: Path | None = None,
) -> PreparedTestModelRun:
run_config = run_config or TestModelRunConfig()
repo_root = Path(__file__).resolve().parents[2]
pythonmodels_root = Path(__file__).resolve().parents[1]
resolved_output_dir = (
output_dir
or run_config.paths.output_dir
or _default_run_output_dir(pythonmodels_root)
or _default_run_output_dir()
)
resolved_modelica_result_path = (
modelica_result_path
or run_config.paths.modelica_result_path
or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
or MODELICA_TESTMODEL_RESULT_PATH
)
t_eval = tuple(run_config.sample_times())
return PreparedTestModelRun(
run_config=run_config,
repo_root=repo_root,
repo_root=PROJECT_ROOT,
output_dir=resolved_output_dir,
modelica_result_path=resolved_modelica_result_path,
t_eval=t_eval,
@@ -3,21 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from PythonModels.components.cylinder import Cylinder
from PythonModels.components.orifice import Orifice
from PythonModels.components.pipe import Pipe
from PythonModels.components.tank import Tank
from PythonModels.components.tee import Tee
from PythonModels.core.medium import IdealGasMedium
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.systems.testmodel_closure import (
from app.simulation.components.experimental.flow.orifice import Orifice
from app.simulation.components.experimental.junctions.tee import Tee
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.examples.testmodel.closure import (
BranchClosureComponents,
InitializationDiagnostics,
TestModelClosure,
TestModelClosureComponents,
TestModelSnapshot,
)
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
from app.simulation.systems.network import SimulationNetwork
@dataclass(frozen=True)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import os
from pathlib import Path
APP_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = APP_DIR.parent
_configured_data_dir = os.getenv("SIMULATIONAPP_DATA_DIR")
DATA_DIR = (
Path(_configured_data_dir).expanduser().resolve()
if _configured_data_dir
else APP_DIR / "data"
)
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
)
+717
View File
@@ -0,0 +1,717 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from importlib import import_module
from math import isfinite
import re
from typing import cast
from app.simulation.core.base import Component
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentDisplaySpec,
ComponentLibrarySpec,
PortDisplaySpec,
)
from app.simulation.core.metadata import (
SI_UNIT_BY_QUANTITY,
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
ParameterSpec = ParameterDefinition
ENABLED_COMPONENT_LIBRARIES = (
"app.simulation.components.experimental.library:LIBRARY",
)
_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*")
_MEMBER_ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
_SEMANTIC_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+")
@dataclass(frozen=True)
class ComponentModelSpec:
"""Validated registry entry backed by one component implementation class."""
component_class: type[Component]
library: ComponentLibrarySpec
@property
def model_type(self) -> str:
return cast(str, self.component_class.MODEL_TYPE)
@property
def model_version(self) -> str:
return cast(str, self.component_class.MODEL_VERSION)
@property
def ports(self) -> tuple[PortDefinition, ...]:
return self.component_class.PORTS
@property
def parameters(self) -> tuple[ParameterDefinition, ...]:
return self.component_class.PARAMETERS
@property
def result_variables(self) -> tuple[ResultVariableDefinition, ...]:
return self.component_class.RESULT_VARIABLES
@property
def display(self) -> ComponentDisplaySpec:
return cast(ComponentDisplaySpec, self.component_class.DISPLAY)
@property
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
return {parameter.name: parameter for parameter in self.parameters}
def as_catalog_dict(self) -> dict[str, object]:
category = self.library.category_by_id[self.display.category_id]
display_ports = self.display.port_by_name
ports: list[dict[str, object]] = []
for port in self.ports:
payload = port.as_interface_dict()
payload["side"] = display_ports[port.name].side
payload["order"] = display_ports[port.name].order
ports.append(payload)
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
return {
"type": self.model_type,
"modelType": self.model_type,
"modelVersion": self.model_version,
"label": self.display.label,
"symbol": self.display.symbol,
"order": self.display.order,
"category": category.as_catalog_dict(),
"ports": ports,
"parameters": [
parameter.as_interface_dict() for parameter in self.parameters
],
}
def create(
self,
name: str,
medium: IdealGasMedium,
values: Mapping[str, float],
) -> Component:
unknown = sorted(set(values) - set(self.parameter_by_name))
if unknown:
raise ValueError(
f"Component '{name}' contains unsupported parameters: "
+ ", ".join(unknown)
+ "."
)
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}."
)
component = self.component_class.create(
name=name,
medium=medium,
parameters=resolved,
)
self._validate_instance(component, resolved)
return component
def _validate_instance(
self,
component: Component,
resolved_parameters: Mapping[str, float],
) -> None:
if not isinstance(component, self.component_class):
raise ValueError(
f"Component model '{self.model_type}' create() returned "
f"{type(component).__name__}, expected {self.component_class.__name__}."
)
if component.model_type != self.model_type:
raise ValueError(
f"Component implementation '{self.model_type}' created instance "
f"with model type '{component.model_type}'."
)
if component.port_definitions != self.ports:
raise ValueError(
f"Component implementation '{self.model_type}' does not match "
"its declared ports."
)
if component.parameter_values != dict(resolved_parameters):
raise ValueError(
f"Component implementation '{self.model_type}' did not preserve "
"its normalized parameters."
)
def _load_reference(reference: str) -> object:
try:
module_name, attribute_name = reference.rsplit(":", maxsplit=1)
except ValueError as exc:
raise ValueError(
f"Component reference '{reference}' must use 'module:attribute'."
) from exc
if not module_name or not attribute_name:
raise ValueError(
f"Component reference '{reference}' must use 'module:attribute'."
)
try:
module = import_module(module_name)
except Exception as exc:
raise ValueError(
f"Could not import component module '{module_name}' from '{reference}'."
) from exc
try:
return getattr(module, attribute_name)
except AttributeError as exc:
raise ValueError(
f"Component module '{module_name}' has no attribute '{attribute_name}'."
) from exc
def _validate_machine_id(value: object, *, field: str) -> str:
if not isinstance(value, str) or _MACHINE_ID_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must start with a lowercase letter and contain only "
"lowercase letters, digits, and underscores."
)
return value
def _validate_member_id(value: object, *, field: str) -> str:
if not isinstance(value, str) or _MEMBER_ID_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must start with a letter and contain only letters, "
"digits, and underscores."
)
return value
def _validate_version(value: object, *, field: str) -> str:
if (
not isinstance(value, str)
or _SEMANTIC_VERSION_PATTERN.fullmatch(value) is None
):
raise ValueError(f"{field} must use 'major.minor.patch'.")
return value
def _validate_label(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must not be empty.")
return value
def _validate_order(value: object, *, field: str) -> int:
if type(value) is not int:
raise ValueError(f"{field} must be an integer.")
return value
def _validate_quantity_unit(
quantity: object,
unit: object,
*,
field: str,
) -> None:
if not isinstance(quantity, str) or not quantity:
raise ValueError(f"{field} quantity must not be empty.")
if not isinstance(unit, str):
raise ValueError(f"{field} unit must be a string.")
try:
expected_unit = SI_UNIT_BY_QUANTITY[quantity]
except KeyError as exc:
raise ValueError(
f"{field} uses unsupported quantity '{quantity}'."
) from exc
if unit != expected_unit:
raise ValueError(
f"{field} uses unit '{unit}' for quantity '{quantity}', "
f"expected SI unit '{expected_unit}'."
)
def _validate_unique_names(
names: Sequence[str],
*,
field: str,
) -> None:
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"{field} contains duplicate names: {', '.join(duplicates)}.")
def _validate_category(
category: ComponentCategorySpec,
*,
library_id: str,
) -> None:
if not isinstance(category, ComponentCategorySpec):
raise ValueError(
f"Library '{library_id}' categories must use ComponentCategorySpec."
)
_validate_machine_id(
category.id,
field=f"Library '{library_id}' category id",
)
_validate_label(
category.label,
field=f"Library '{library_id}' category '{category.id}' label",
)
_validate_order(
category.order,
field=f"Library '{library_id}' category '{category.id}' order",
)
def validate_component_library(library: ComponentLibrarySpec) -> None:
if not isinstance(library, ComponentLibrarySpec):
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
library_id = _validate_machine_id(library.id, field="Component library id")
_validate_label(library.label, field=f"Library '{library_id}' label")
_validate_version(library.version, field=f"Library '{library_id}' version")
_validate_order(library.order, field=f"Library '{library_id}' order")
if type(library.temporary) is not bool:
raise ValueError(f"Library '{library_id}' temporary must be a boolean.")
if (
not isinstance(library.source_package, str)
or not library.source_package.strip()
):
raise ValueError(f"Library '{library_id}' source package must not be empty.")
if not library.categories:
raise ValueError(f"Library '{library_id}' must declare at least one category.")
if not library.models:
raise ValueError(f"Library '{library_id}' must declare at least one model.")
for category in library.categories:
_validate_category(category, library_id=library_id)
category_ids = [category.id for category in library.categories]
_validate_unique_names(
category_ids,
field=f"Library '{library_id}' categories",
)
_validate_unique_names(
list(library.models),
field=f"Library '{library_id}' model references",
)
for reference in library.models:
if not isinstance(reference, str) or ":" not in reference:
raise ValueError(
f"Library '{library_id}' contains invalid model reference "
f"'{reference}'."
)
module_name = reference.rsplit(":", maxsplit=1)[0]
if not (
module_name == library.source_package
or module_name.startswith(f"{library.source_package}.")
):
raise ValueError(
f"Library '{library_id}' model '{reference}' is outside source "
f"package '{library.source_package}'."
)
def _validate_port_variable(
variable: PortVariableDefinition,
*,
model_type: str,
port_name: str,
) -> None:
field = f"Component '{model_type}' port '{port_name}' variable"
if not isinstance(variable, PortVariableDefinition):
raise ValueError(f"{field} must use PortVariableDefinition.")
_validate_member_id(variable.name, field=f"{field} name")
_validate_label(variable.label or variable.name, field=f"{field} label")
_validate_quantity_unit(
variable.quantity or variable.name,
variable.unit,
field=f"{field} '{variable.name}'",
)
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
if variable.role not in {"effort", "flow", "stream", "signal"}:
raise ValueError(f"{field} '{variable.name}' has invalid role.")
if variable.connection_rule not in {
"equal",
"sumToZero",
"streamMix",
"directed",
}:
raise ValueError(
f"{field} '{variable.name}' has invalid connection rule."
)
if type(variable.result_visible) is not bool:
raise ValueError(
f"{field} '{variable.name}' result_visible must be a boolean."
)
def _validate_port(port: PortDefinition, *, model_type: str) -> None:
field = f"Component '{model_type}' port"
if not isinstance(port, PortDefinition):
raise ValueError(f"{field} declarations must use PortDefinition.")
_validate_machine_id(port.name, field=f"{field} name")
_validate_machine_id(port.domain, field=f"{field} '{port.name}' domain")
if port.kind not in {"physical", "signal"}:
raise ValueError(f"{field} '{port.name}' has invalid kind.")
if port.nominal_role not in {
"inlet",
"outlet",
"bidirectional",
"input",
"output",
}:
raise ValueError(f"{field} '{port.name}' has invalid nominal role.")
if port.kind == "physical" and port.positive_flow_direction != "intoComponent":
raise ValueError(
f"{field} '{port.name}' must use positive flow into the component."
)
if port.kind == "physical" and port.nominal_role in {"input", "output"}:
raise ValueError(
f"{field} '{port.name}' uses a signal-only nominal role."
)
if port.kind == "signal" and port.nominal_role not in {"input", "output"}:
raise ValueError(
f"{field} '{port.name}' must use input or output as its nominal role."
)
variable_names = [variable.name for variable in port.variables]
_validate_unique_names(
variable_names,
field=f"{field} '{port.name}' variables",
)
for variable in port.variables:
_validate_port_variable(
variable,
model_type=model_type,
port_name=port.name,
)
def _validate_parameter(
parameter: ParameterDefinition,
*,
model_type: str,
) -> None:
field = f"Component '{model_type}' parameter"
if not isinstance(parameter, ParameterDefinition):
raise ValueError(f"{field} declarations must use ParameterDefinition.")
_validate_member_id(parameter.name, field=f"{field} name")
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
_validate_quantity_unit(
parameter.quantity,
parameter.unit,
field=f"{field} '{parameter.name}'",
)
if not isinstance(parameter.default, (int, float)) or not isfinite(
parameter.default
):
raise ValueError(f"{field} '{parameter.name}' default must be finite.")
for boundary_name, boundary in (
("minimum", parameter.minimum),
("maximum", parameter.maximum),
):
if boundary is not None and (
not isinstance(boundary, (int, float)) or not isfinite(boundary)
):
raise ValueError(
f"{field} '{parameter.name}' {boundary_name} must be finite."
)
if type(parameter.minimum_exclusive) is not bool:
raise ValueError(
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
)
if (
parameter.minimum is not None
and parameter.maximum is not None
and parameter.minimum > parameter.maximum
):
raise ValueError(
f"{field} '{parameter.name}' minimum exceeds its maximum."
)
message = parameter.validation_message(parameter.default)
if message is not None:
raise ValueError(
f"{field} '{parameter.name}' default value {message}."
)
def _validate_result_variable(
variable: ResultVariableDefinition,
*,
model_type: str,
) -> None:
field = f"Component '{model_type}' result variable"
if not isinstance(variable, ResultVariableDefinition):
raise ValueError(
f"{field} declarations must use ResultVariableDefinition."
)
_validate_member_id(variable.name, field=f"{field} name")
_validate_label(variable.label, field=f"{field} '{variable.name}' label")
_validate_quantity_unit(
variable.quantity,
variable.unit,
field=f"{field} '{variable.name}'",
)
_validate_machine_id(
variable.category,
field=f"{field} '{variable.name}' category",
)
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
if type(variable.visible) is not bool:
raise ValueError(
f"{field} '{variable.name}' visible must be a boolean."
)
def validate_component_model_class(
component_class: type[Component],
*,
library: ComponentLibrarySpec,
) -> None:
if not isinstance(component_class, type) or not issubclass(
component_class,
Component,
):
raise ValueError(
f"Library '{library.id}' model entries must be Component subclasses."
)
required_declarations = (
"MODEL_TYPE",
"MODEL_VERSION",
"PORTS",
"PARAMETERS",
"RESULT_VARIABLES",
"DISPLAY",
"create",
)
missing = [
name for name in required_declarations if name not in component_class.__dict__
]
if missing:
raise ValueError(
f"Component class '{component_class.__name__}' must declare: "
+ ", ".join(missing)
+ "."
)
if not isinstance(component_class.__dict__["create"], classmethod):
raise ValueError(
f"Component class '{component_class.__name__}' create must be a classmethod."
)
model_type = _validate_machine_id(
component_class.MODEL_TYPE,
field=f"Component class '{component_class.__name__}' model type",
)
_validate_version(
component_class.MODEL_VERSION,
field=f"Component '{model_type}' model version",
)
display = component_class.DISPLAY
if not isinstance(display, ComponentDisplaySpec):
raise ValueError(
f"Component '{model_type}' DISPLAY must use ComponentDisplaySpec."
)
_validate_label(display.label, field=f"Component '{model_type}' display label")
_validate_machine_id(
display.symbol,
field=f"Component '{model_type}' display symbol",
)
_validate_order(display.order, field=f"Component '{model_type}' display order")
if not isinstance(display.ports, tuple):
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
if display.library_id != library.id:
raise ValueError(
f"Component '{model_type}' references library '{display.library_id}', "
f"expected '{library.id}'."
)
if display.category_id not in library.category_by_id:
raise ValueError(
f"Component '{model_type}' references unknown category "
f"'{display.category_id}' in library '{library.id}'."
)
ports = component_class.PORTS
parameters = component_class.PARAMETERS
result_variables = component_class.RESULT_VARIABLES
if not isinstance(ports, tuple):
raise ValueError(f"Component '{model_type}' PORTS must be a tuple.")
if not isinstance(parameters, tuple):
raise ValueError(f"Component '{model_type}' PARAMETERS must be a tuple.")
if not isinstance(result_variables, tuple):
raise ValueError(
f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
)
for port in ports:
_validate_port(port, model_type=model_type)
for parameter in parameters:
_validate_parameter(parameter, model_type=model_type)
for variable in result_variables:
_validate_result_variable(variable, model_type=model_type)
for port in display.ports:
if not isinstance(port, PortDisplaySpec):
raise ValueError(
f"Component '{model_type}' display ports must use PortDisplaySpec."
)
_validate_machine_id(
port.name,
field=f"Component '{model_type}' display port name",
)
if port.side not in {"left", "right"}:
raise ValueError(
f"Component '{model_type}' display port '{port.name}' "
"must use side 'left' or 'right'."
)
_validate_order(
port.order,
field=f"Component '{model_type}' display port '{port.name}' order",
)
port_names = [port.name for port in ports]
parameter_names = [parameter.name for parameter in parameters]
result_names = [variable.name for variable in result_variables]
display_port_names = [port.name for port in display.ports]
_validate_unique_names(port_names, field=f"Component '{model_type}' ports")
_validate_unique_names(
parameter_names,
field=f"Component '{model_type}' parameters",
)
_validate_unique_names(
result_names,
field=f"Component '{model_type}' result variables",
)
_validate_unique_names(
display_port_names,
field=f"Component '{model_type}' display ports",
)
if set(port_names) != set(display_port_names):
raise ValueError(
f"Component '{model_type}' display ports must exactly match "
"its physical port declarations."
)
def discover_component_registries(
library_references: Sequence[str] = ENABLED_COMPONENT_LIBRARIES,
) -> tuple[
dict[str, ComponentLibrarySpec],
dict[str, ComponentModelSpec],
]:
libraries: dict[str, ComponentLibrarySpec] = {}
models: dict[str, ComponentModelSpec] = {}
for library_reference in library_references:
library = _load_reference(library_reference)
if not isinstance(library, ComponentLibrarySpec):
raise ValueError(
f"Enabled library '{library_reference}' must reference "
"ComponentLibrarySpec."
)
validate_component_library(library)
if library.id in libraries:
raise ValueError(f"Duplicate component library id: {library.id}.")
libraries[library.id] = library
for model_reference in library.models:
component_class = _load_reference(model_reference)
if not isinstance(component_class, type) or not issubclass(
component_class,
Component,
):
raise ValueError(
f"Library '{library.id}' model '{model_reference}' is not "
"a Component subclass."
)
validate_component_model_class(component_class, library=library)
model_type = cast(str, component_class.MODEL_TYPE)
if model_type in models:
existing = models[model_type].component_class
raise ValueError(
f"Duplicate component model type '{model_type}' from "
f"{existing.__module__}.{existing.__name__} and "
f"{component_class.__module__}.{component_class.__name__}."
)
spec = ComponentModelSpec(
component_class=component_class,
library=library,
)
try:
spec.create(
f"__catalog_validation_{model_type}",
IdealGasMedium(),
{},
)
except Exception as exc:
raise ValueError(
f"Component model '{model_type}' cannot be created with "
"its declared defaults."
) from exc
models[model_type] = spec
if not libraries:
raise ValueError("At least one component library must be enabled.")
return libraries, models
COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY = (
discover_component_registries()
)
def validate_component_registries() -> None:
"""Re-run public registry invariants for tests and startup diagnostics."""
discovered_libraries, discovered_models = discover_component_registries()
if tuple(discovered_libraries) != tuple(COMPONENT_LIBRARY_REGISTRY):
raise ValueError("Component library registry differs from discovery output.")
if tuple(discovered_models) != tuple(COMPONENT_MODEL_REGISTRY):
raise ValueError("Component model registry differs from discovery output.")
def build_component_catalog() -> dict[str, object]:
components_by_library: dict[str, list[ComponentModelSpec]] = {
library_id: [] for library_id in COMPONENT_LIBRARY_REGISTRY
}
for component in COMPONENT_MODEL_REGISTRY.values():
components_by_library[component.library.id].append(component)
libraries: list[dict[str, object]] = []
for library in sorted(
COMPONENT_LIBRARY_REGISTRY.values(),
key=lambda item: (item.order, item.id),
):
payload = library.as_catalog_dict()
payload["components"] = [
component.as_catalog_dict()
for component in sorted(
components_by_library[library.id],
key=lambda item: (item.display.order, item.model_type),
)
]
libraries.append(payload)
return {
"schemaVersion": 1,
"libraries": libraries,
}
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
@@ -1,4 +1,4 @@
from PythonModels.reporting.testmodel_outputs import (
from app.simulation.reporting.testmodel_outputs import (
COMPARISON_KEYS,
MODELICA_COMPARISON_COLUMNS,
PRIMARY_KEYS,
+1
View File
@@ -0,0 +1 @@
"""Numerical solvers used by simulation systems."""
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
from dataclasses import dataclass
from math import sqrt
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork
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
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Literal
CancellationCheck = Callable[[], bool]
AcceptedStepCallback = Callable[[float], None]
IntegrationStatus = Literal["completed", "cancelled", "failed"]
class _IntegrationCancelled(Exception):
pass
@dataclass(frozen=True)
class SolveIVPConfig:
t_start: float = 0.0
t_stop: float = 20.0
method: str = "BDF"
rtol: float = 1e-6
atol: float = 1e-8
max_step: float = 1e-3
@dataclass(frozen=True)
class ODESolution:
t: list[float]
y: list[list[float]]
success: bool
message: str
status: IntegrationStatus = "completed"
error: Exception | None = None
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
return [x + scale * y for x, y in zip(a, b)]
def _append_solution_sample(
times: list[float],
states: list[list[float]],
time: float,
state: list[float],
) -> None:
if times and time <= times[-1] + 1e-12:
return
times.append(float(time))
for index, value in enumerate(state):
states[index].append(float(value))
def _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
) -> ODESolution:
if t_eval is None:
point_count = max(
2,
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
)
step = (config.t_stop - config.t_start) / (point_count - 1)
t_eval = [config.t_start + index * step for index in range(point_count)]
state = list(initial_state)
states = [[value] for value in state]
times = [float(t_eval[0])]
current_time = float(t_eval[0])
status: IntegrationStatus = "completed"
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
error: Exception | None = None
try:
for target_time in t_eval[1:]:
while current_time < target_time - 1e-15:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
if accepted_step_callback is not None:
accepted_step_callback(current_time)
_append_solution_sample(times, states, target_time, state)
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
_append_solution_sample(times, states, current_time, state)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
def _integrate_scipy_stepwise(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
cancel_check: CancellationCheck,
accepted_step_callback: AcceptedStepCallback | None,
) -> ODESolution:
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
solver_types = {
"BDF": BDF,
"DOP853": DOP853,
"LSODA": LSODA,
"RK23": RK23,
"RK45": RK45,
"Radau": Radau,
}
solver_type = solver_types.get(config.method)
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
last_accepted_time = float(config.t_start)
last_accepted_state = [float(value) for value in initial_state]
sample_times = list(t_eval or [])
sample_index = 0
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= config.t_start + 1e-12
):
sample_index += 1
def cancellable_rhs(time, state):
if cancel_check():
raise _IntegrationCancelled
return rhs(float(time), [float(value) for value in state])
if cancel_check():
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
)
except _IntegrationCancelled:
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
except Exception as exc:
return ODESolution(
t=times,
y=states,
success=False,
message=str(exc),
status="failed",
error=exc,
)
status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval."
error: Exception | None = None
while solver.status == "running":
if cancel_check():
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
try:
step_message = solver.step()
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
if solver.status == "failed":
status = "failed"
message = str(step_message or "Integration step failed.")
break
last_accepted_time = float(solver.t)
last_accepted_state = [float(value) for value in solver.y]
if sample_times:
dense_output = solver.dense_output()
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time + 1e-12
):
sample_time = float(sample_times[sample_index])
sample_state = [float(value) for value in dense_output(sample_time)]
_append_solution_sample(times, states, sample_time, sample_state)
sample_index += 1
else:
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
if accepted_step_callback is not None:
accepted_step_callback(last_accepted_time)
if status != "completed":
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
def integrate_ode(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None = None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
):
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
if abs(config.t_stop - config.t_start) <= 1e-15:
return ODESolution(
t=[float(config.t_start)],
y=[[value] for value in initial_state],
success=True,
message="Skipped integration because t_start equals t_stop.",
)
try:
from scipy.integrate import solve_ivp
except ImportError:
return _runge_kutta_4(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
if cancel_check is not None:
return _integrate_scipy_stepwise(
rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
)
return solve_ivp(
fun=rhs,
t_span=(config.t_start, config.t_stop),
y0=initial_state,
method=config.method,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
t_eval=t_eval,
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import DynamicComponent
from app.simulation.systems.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,
)
File renamed without changes.
+463
View File
@@ -0,0 +1,463 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from math import floor, isfinite
from typing import Literal
from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import Endpoint, SimulationNetwork
SimulationProgressCallback = Callable[[float, str], None]
SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@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
status: SimulationRunStatus
message: str
simulated_until: float
requested_stop_time: float
variables: tuple[ResultVariableMetadata, ...]
series: dict[str, list[float]]
final: dict[str, float]
diagnostics: dict[str, object]
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"status": self.status,
"partial": self.status != "completed",
"message": self.message,
"simulatedUntil": self.simulated_until,
"requestedStopTime": self.requested_stop_time,
"variables": [variable.as_dict() for variable in self.variables],
"series": self.series,
"final": self.final,
"diagnostics": self.diagnostics,
}
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.network.components.values():
for relative_key, value in component.result_values().items():
series.setdefault(
f"{component.name}.{relative_key}", []
).append(value)
def simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
) -> GenericSimulationResult:
last_reported_progress = -1.0
last_reported_phase = ""
def report_progress(
progress: float,
phase: str,
*,
force: bool = False,
) -> None:
nonlocal last_reported_phase, last_reported_progress
if progress_callback is None:
return
bounded_progress = min(1.0, max(0.0, progress))
if (
force
or phase != last_reported_phase
or bounded_progress - last_reported_progress >= 0.0025
):
last_reported_phase = phase
last_reported_progress = max(
last_reported_progress,
bounded_progress,
)
progress_callback(last_reported_progress, phase)
report_progress(0.0, "initializing", force=True)
t_eval = simulation_sample_times(config, sample_step)
initial_state = self.consistent_initial_state_vector()
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start
def report_solver_time(time: float) -> None:
nonlocal furthest_solver_time
furthest_solver_time = max(furthest_solver_time, float(time))
time_fraction = (
(furthest_solver_time - config.t_start) / duration
if duration > 0.0
else 1.0
)
report_progress(time_fraction, "integrating")
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
if cancel_check is None:
report_solver_time(time)
return self.rhs(time, state_vector)
solution = integrate_ode(
rhs=monitored_rhs,
initial_state=initial_state,
config=config,
t_eval=t_eval,
cancel_check=cancel_check,
accepted_step_callback=(
report_solver_time if cancel_check is not None else None
),
)
if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status
integration_error = solution.error
else:
run_status = "completed" if bool(solution.success) else "failed"
integration_error = None
result_message = str(solution.message)
postprocess_progress = (
1.0
if run_status == "completed"
else max(0.0, last_reported_progress)
)
report_progress(postprocess_progress, "postprocessing", force=True)
times = [float(value) for value in solution.t]
series: dict[str, list[float]] = {"time": []}
postprocessing_error: Exception | None = None
for time_index in range(len(times)):
if (
run_status == "completed"
and cancel_check is not None
and cancel_check()
):
run_status = "cancelled"
result_message = "Simulation was stopped while preparing partial results."
break
state = [
float(solution.y[state_index][time_index])
for state_index in range(len(solution.y))
]
try:
self.apply_state_vector(state)
self._close_current_state()
self._append_current_state(series)
series["time"].append(times[time_index])
except Exception as exc:
run_status = "failed"
result_message = str(exc)
postprocessing_error = exc
break
if len(series["time"]) < 2:
if postprocessing_error is not None:
raise postprocessing_error
if integration_error is not None:
raise integration_error
final = {
key: values[-1]
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(series["time"]),
}
variables = tuple(
variable
for variable in self.network.result_variable_metadata()
if variable.key in series
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
force=True,
)
return GenericSimulationResult(
success=run_status == "completed" and bool(solution.success),
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
),
requested_stop_time=float(config.t_stop),
variables=variables,
series=series,
final=final,
diagnostics=diagnostics,
)
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.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:
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:
"""Container for components, topology, and state-vector bookkeeping."""
def __init__(self, name: str) -> None:
self.name = name
self.components: dict[str, Component] = {}
self.connections: list[Connection] = []
def add_component(self, component: Component) -> None:
if component.name in self.components:
raise ValueError(f"Duplicate component name: {component.name}")
self.components[component.name] = component
def connect(
self,
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 [
component
for component in self.components.values()
if isinstance(component, DynamicComponent)
]
def initial_state_vector(self) -> list[float]:
values: list[float] = []
for component in self.dynamic_components():
values.extend(component.get_state_vector())
return values
def apply_state_vector(self, values: list[float]) -> None:
cursor = 0
for component in self.dynamic_components():
next_cursor = cursor + component.state_size
component.set_state_vector(values[cursor:next_cursor])
cursor = next_cursor
if cursor != len(values):
raise ValueError("State vector length does not match dynamic components.")
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
return tuple(
variable
for component in self.components.values()
for variable in component.result_variable_metadata()
)
def summary(self) -> str:
lines = [f"Network: {self.name}", "Components:"]
for name, component in self.components.items():
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.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,
"parameters": component.parameter_interface_dicts(),
"ports": [
definition.as_interface_dict()
for definition in component.port_definitions
],
"resultVariables": [
variable.as_dict()
for variable in component.result_variable_metadata()
],
}
for component in self.components.values()
],
"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
],
}
+844
View File
@@ -0,0 +1,844 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from functools import lru_cache
from math import isfinite
from pathlib import Path
from typing import Literal
from lxml import etree
from app.simulation.core.ports import PortDefinition
from app.simulation.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,
)
+46
View File
@@ -0,0 +1,46 @@
# 开发文档索引
本目录是 SystemSimulationApp 协议和开发规范的统一入口。
## 模型开发
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。
2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。
3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json)
`GET /api/components/catalog` 的机器可读结构。
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
模型。不要从前端兜底数据反推后端物理契约。
## System XML
- [System XML v2 协议](system-xml-v2.md)
- [System XML v2 XSD](../schemas/system-simulation-v2.xsd)
- [System XML v1 协议(旧版)](system-xml-v1.md)
- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd)
新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和
连接。XML 解析器不能自行创造模型端口或参数。
## 当前代码入口
| 目的 | 文件 |
| --- | --- |
| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) |
| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) |
| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) |
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
## AI 使用原则
- 先读规范和相邻模型,再改代码。
- 只从 `library.py` 受控登记公开模型。
- 不在前端复制后端端口、参数或默认值作为正式来源。
- 不覆盖用户已有改动。
- 不自行猜测缺失的物理方程。
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
+730
View File
@@ -0,0 +1,730 @@
# 组件库分类、发现与读取规范 v1
状态:已在 `experimental` 临时组件库实施
适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库
当前试验库:`experimental`(界面名称:临时测试组件库)
## 0. 文档定位
本文档只负责“模型如何被系统发现和读取”。模型方程、状态、参数和结果应如何编写,
统一参见[组件模型建模规范 v1](component-model-authoring-spec-v1.md)。
人工或 AI 排查组件读取问题时,按以下顺序读取:
1. `app/simulation/registry.py` 中的 `ENABLED_COMPONENT_LIBRARIES`。
2. 被启用组件库的 `library.py`。
3. `library.py/models` 明确列出的模型类。
4. 模型类的 `DISPLAY / PORTS / PARAMETERS`。
5. `build_component_catalog()` 的输出。
6. 前端 `normalizeComponentCatalog()`。
当前事实来源优先级:
| 信息 | 唯一事实来源 | 不能作为事实来源 |
| --- | --- | --- |
| 启用哪些库 | `ENABLED_COMPONENT_LIBRARIES` | 目录中碰巧存在的文件夹 |
| 库分类和模型清单 | 各库 `library.py` | 前端组件列表 |
| 模型类型和版本 | 模型类 | 文件名或中文名称 |
| 物理端口 | 模型类 `PORTS` | `DISPLAY.ports` 或画布方向 |
| 参数默认值和边界 | 模型类 `PARAMETERS` | 前端兜底定义 |
| 图标和端口位置 | 模型类 `DISPLAY` | 物理方程 |
| 前端运行目录 | `/api/components/catalog` | 手工扫描 Python 包 |
如果文档、代码和目录响应不一致,应在同一次修改中修复并增加测试,不能通过复制
另一份映射临时绕过。
## 1. 目标
本规范用于统一以下内容:
1. 后端组件库如何声明自身信息和分类。
2. 单个模型如何声明端口、参数、结果变量和界面显示信息。
3. 后端如何发现、校验、注册并实例化模型。
4. 前端如何通过统一目录接口自动生成左侧组件库和参数面板。
5. System XML 中的模型类型如何稳定映射到 Python 实现。
目标工作流如下:
```mermaid
flowchart LR
A["ENABLED_COMPONENT_LIBRARIES"] --> B["library.py"]
B --> C["受控导入模型类"]
C --> D["启动契约校验"]
D --> E["组件注册表"]
E --> F["GET /api/components/catalog"]
F --> G["React Flow 组件库和参数面板"]
E --> H["System XML 模型实例化"]
```
新增一个符合本规范的模型后,前端不应再修改 `App.tsx` 中的组件列表、参数列表
或分类列表。只有新增一种前端尚不支持的图形渲染方式时,才需要补充前端图标组件。
## 2. 术语和层级
组件目录采用四个相互独立的概念:
| 层级 | 示例 | 含义 |
| --- | --- | --- |
| 组件库 Library | `experimental` | 一组具有共同发布、维护和版本边界的模型 |
| 界面分类 Category | `storage` | 只用于组件面板分组和排序 |
| 物理域 Domain | `pneumatic` | 决定端口能否连接以及采用哪组连接方程 |
| 模型 Model | `cylinder` | 可实例化并写入 System XML 的稳定模型类型 |
分类和物理域禁止混用。例如,`storage` 是界面分类,`pneumatic` 是物理域;
一个“储能元件”也可以属于液压域,不能根据分类推断端口连接规则。
标准层级为:
```text
Library
Category
Model
Port
Port variable
Parameter
Result variable
```
## 3. 推荐目录结构
每个组件库使用独立 Python 包:
```text
app/simulation/components/
experimental/
__init__.py
library.py
storage/
__init__.py
cylinder.py
tank.py
flow/
__init__.py
orifice.py
resistive_pipe.py
junctions/
__init__.py
tee.py
```
规则如下:
- `library.py` 是组件库唯一清单入口。
- 分类目录名必须与分类 ID 一致。
- 一个公开模型原则上放在一个独立 `.py` 文件中。
- 求解器、介质、通用方程和状态对象不得放入组件库目录。
- 未准备对外注册的实验类可以保留在库内,但不得加入库清单。
- 禁止通过扫描任意 `.py` 文件并执行其中代码来发现模型;必须使用库清单进行受控导入。
## 4. 标识符和版本
### 4.1 标识符
以下字段使用稳定的英文机器标识:
- `library.id`
- `category.id`
- `MODEL_TYPE`
- 端口名
- 参数名
- 结果变量名
标识符应使用 `snake_case`,只允许小写英文字母、数字和下划线,并以字母开头。
已有工程约定中的 `T`、`U` 等热力学变量可以保留。
界面中文名称单独保存在 `label` 中。修改 `label` 不影响工程兼容性;修改机器标识
会影响工程文件、System XML、结果文件和后端注册,因此发布后不得直接改名。
### 4.2 版本
每个组件库和模型都应具有版本:
```python
LIBRARY_VERSION = "0.1.0"
MODEL_VERSION = "1.0.0"
```
版本遵循 `主版本.次版本.修订版本`:
- 修订版本:只修复实现,不改变输入输出契约。
- 次版本:向后兼容地新增参数、结果或能力。
- 主版本:端口、参数语义或方程发生不兼容变化。
当前 System XML v2 尚未保存模型版本。正式发布组件库前,应在 XML 中增加
`library` 和 `modelVersion`,并提供旧工程迁移规则。
## 5. 组件库清单
每个库必须提供 `library.py`,并使用有类型的不可变声明:
```python
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="experimental",
label="临时测试组件库",
version="0.1.0",
source_package="app.simulation.components.experimental",
temporary=True,
order=100,
categories=(
ComponentCategorySpec(
id="storage",
label="储能元件",
order=10,
),
ComponentCategorySpec(
id="flow",
label="流动元件",
order=20,
),
ComponentCategorySpec(
id="junctions",
label="连接元件",
order=30,
),
),
models=(
"app.simulation.components.experimental.storage.cylinder:Cylinder",
"app.simulation.components.experimental.storage.tank:Tank",
"app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe",
"app.simulation.components.experimental.flow.orifice:Orifice",
"app.simulation.components.experimental.junctions.tee:Tee",
),
)
```
`models` 是唯一允许注册到系统的模型清单。它同时解决以下问题:
- 避免导入测试脚本或内部辅助类。
- 控制模型加载顺序。
- 避免同一个 `MODEL_TYPE` 被多个实现重复注册。
- 可以只加载部署环境允许使用的组件库。
- 启动错误能明确定位到具体库和模型。
当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型;
`experimental/__init__.py` 只保留旧常量的兼容别名。
## 6. 模型类契约
一个可注册模型必须显式声明:
```python
class ExampleComponent(Component):
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
字段含义:
| 字段 | 是否必须 | 用途 |
| --- | --- | --- |
| `MODEL_TYPE` | 必须 | System XML、注册表和结果元数据中的稳定类型 |
| `MODEL_VERSION` | 必须 | 模型契约和迁移版本 |
| `PORTS` | 必须 | 物理或信号连接契约 |
| `PARAMETERS` | 必须,可为空 | 用户输入参数及校验边界 |
| `RESULT_VARIABLES` | 必须,可为空 | 组件级可展示结果 |
| `DISPLAY` | 必须 | 前端名称、分类、图标、排序和端口布局 |
模型实现还必须满足:
1. 构造函数调用 `super().__init__(name)`。
2. 使用 `set_parameter_values()` 保存所有规范化后的参数。
3. 使用 `register_declared_port()` 创建 `PORTS` 中声明的端口。
4. 组件级结果键必须与 `RESULT_VARIABLES` 完全一致。
5. 所有内部计算均使用 SI 基准值。
6. 模型不能直接依赖 FastAPI、React Flow 或 XML DOM。
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
完整方程示例参见
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。
## 7. 界面显示声明
`DISPLAY` 只描述模型在前端的呈现,不参与物理求解:
```python
from app.simulation.core.catalog import (
ComponentDisplaySpec,
PortDisplaySpec,
)
DISPLAY = ComponentDisplaySpec(
label="示例容腔",
library_id="experimental",
category_id="storage",
symbol="cylinder",
order=90,
ports=(
PortDisplaySpec(
name="port_a",
side="left",
order=10,
),
),
)
```
字段规则:
- `label`:组件库中的显示名称。
- `library_id`:必须引用已加载的库。
- `category_id`:必须引用该库已声明的分类。
- `symbol`:前端图形渲染器的稳定标识。
- `order`:同一分类中的排序值。
- `ports`:只声明端口在图形中的位置和顺序。
`DISPLAY.ports` 中的端口名必须与模型的 `PORTS` 完全一致,不能缺少、增加或改名。
旋转和镜像只改变前端计算后的视觉方位,不改变端口机器名和物理语义。
前端遇到未知 `symbol` 时必须显示通用占位图标,同时保留模型拖拽、参数编辑、
连线和 XML 生成功能,不能因为缺少专用图形而丢弃整个模型。
## 8. 端口规范
端口由 `PortDefinition` 声明:
```python
PORTS = (
PortDefinition.pneumatic(
"port_a",
nominal_role="bidirectional",
),
)
```
每个端口必须包含:
- 稳定端口名。
- `kind`:`physical` 或 `signal`。
- `domain`:例如 `pneumatic`。
- `nominal_role`:用于界面提示,不决定实际流向。
- `positive_flow_direction`:物理流量变量的符号约定。
- 端口变量及各自连接规则。
当前气动功率端口包含:
| 变量 | 角色 | 连接规则 | 单位 |
| --- | --- | --- | --- |
| `p` | effort | `equal` | `Pa` |
| `m_flow` | flow | `sumToZero` | `kg/s` |
| `h_outflow` | stream | `streamMix` | `J/kg` |
气动端口统一约定 `m_flow > 0` 表示质量流入当前组件。`inlet`、`outlet` 是标称角色,
不应阻止反向流动;实际方向由求解结果中的流量符号决定。
连接校验至少包括:
1. 两个端口均存在。
2. 端口不能连接自身。
3. `kind` 相同。
4. `domain` 相同。
5. 端口变量集合及连接规则兼容。
6. 同一物理端口的连接数量符合当前网络编译器能力。
## 9. 参数规范
参数使用 `ParameterDefinition` 声明:
```python
PARAMETERS = (
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
),
)
```
规则如下:
- `name` 是模型构造、XML 和工程文件共同使用的稳定名称。
- `label` 是界面文案。
- `quantity` 是受控物理量标识,用于前端匹配可换算单位。
- `unit` 是后端 SI 单位。
- `default` 必须能够直接创建合法模型。
- 边界必须与方程有效范围一致。
- 无量纲量使用 `quantity="dimensionless"` 和 `unit=""`。
- 用户输入可以使用其他公制单位,但提交后端前必须换算为 SI。
- 文本框编辑中的临时字符串不立即判错,失焦、回车或运行仿真时再执行数值校验。
后端不得静默忽略未知参数。缺少参数时可使用声明的默认值;出现未知参数时必须
返回包含组件 ID 和参数名的明确错误。
## 10. 结果变量规范
组件结果和端口结果分开管理:
- 组件结果来自 `RESULT_VARIABLES`。
- 端口结果根据 `PORTS` 中 `result_visible=True` 的端口变量自动生成。
- 求解器缓存、残差和调试量默认不进入用户结果。
每个结果变量必须提供:
- `name`
- `label`
- `quantity`
- `unit`
- `category`
- `order`
仿真结果必须输出结构化元数据,前端禁止拆解结果键或按字符串关键词猜测:
```json
{
"key": "cylinder_1.port_b.p",
"componentId": "cylinder_1",
"componentType": "cylinder",
"scope": "port",
"portName": "port_b",
"name": "p",
"label": "压力",
"quantity": "pressure",
"unit": "Pa",
"category": "effort",
"order": 10
}
```
结果页应按 `componentId`、`scope`、`portName`、`quantity` 等结构化字段筛选,
而不是从 `key` 中推断组件、端口和变量。
## 11. 标准模型创建入口
注册中心通过统一入口创建模型,避免长期维护集中式 `_cylinder_factory`、
`_tank_factory` 等适配函数。当前接口为:
```python
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
...
```
创建流程:
1. 注册中心按 `PARAMETERS` 填充默认值。
2. 校验数值有限性和上下限。
3. 拒绝未知参数。
4. 调用模型类的 `create()`。
5. 验证实例的模型类型、端口和参数快照。
6. 将实例交给网络编译器。
这种方式允许 Python 构造参数保留内部命名,同时对外始终使用规范中的参数名。
## 12. 自动发现与注册
后端启动时按以下顺序建立注册表:
```text
读取启用的 library.py
-> 校验库 ID、版本和分类
-> 按 models 清单导入模型类
-> 读取模型静态契约
-> 执行跨字段校验
-> 建立 library registry
-> 建立 model registry
-> 构建前端 catalog
```
当前使用显式启用列表:
```python
ENABLED_COMPONENT_LIBRARIES = (
"app.simulation.components.experimental.library:LIBRARY",
)
```
禁止以下发现方式:
- 在整个仓库递归导入所有 Python 文件。
- 依赖文件名自动推断 `MODEL_TYPE`。
- 由前端硬编码后端类路径。
- 导入失败后悄悄跳过模型。
- 多个实现重复注册同一个模型类型并由加载顺序决定最终结果。
发现或校验失败时,FastAPI 应拒绝启动并给出库 ID、模型类型、字段和原因。
## 13. 启动校验规则
注册表完成前必须执行以下校验:
### 13.1 库与分类
- 库 ID 全局唯一。
- 库版本格式有效。
- 分类 ID 在库内唯一。
- 所有排序值为整数。
- 所有模型引用已存在的库和分类。
### 13.2 模型
- `MODEL_TYPE` 全局唯一,且与注册键一致。
- 模型版本格式有效。
- 模型继承框架要求的基类。
- 模型提供统一创建入口。
- 默认参数能够成功创建实例。
### 13.3 端口
- 端口名在模型内唯一。
- 显示端口集合与物理端口集合完全一致。
- 端口物理域、变量角色和连接规则有效。
- 实例实际注册的端口与静态声明一致。
### 13.4 参数
- 参数名在模型内唯一。
- 默认值有限且满足边界。
- `minimum <= maximum`。
- `quantity` 和 `unit` 的组合已登记。
- 实例保留所有规范化参数,不得静默修改或丢失。
### 13.5 结果
- 结果变量名在对应作用域内唯一。
- `quantity` 和单位有效。
- `component_result_values()` 的键与声明一致。
- 端口结果只来自声明为可见的端口变量。
## 14. 前端组件目录协议
前端只通过以下接口读取组件库:
```http
GET /api/components/catalog
```
目录顶层必须具有版本:
```json
{
"schemaVersion": 1,
"libraries": []
}
```
单个模型至少包含:
```json
{
"type": "cylinder",
"modelType": "cylinder",
"modelVersion": "1.0.0",
"label": "气瓶",
"symbol": "cylinder",
"order": 10,
"category": {
"id": "storage",
"label": "储能元件",
"order": 10
},
"ports": [],
"parameters": []
}
```
前端读取规则:
1. 按库 `order`、分类 `order`、模型 `order` 排序。
2. 使用 `type` 作为拖拽数据和工程文件中的稳定类型。
3. 使用 `label` 显示中文名称。
4. 使用 `ports` 生成 React Flow Handle。
5. 使用 `parameters` 生成参数输入和单位选择控件。
6. 使用 `symbol` 选择图标渲染器。
7. 不在前端重新定义参数默认值、边界或端口语义。
当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期
容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。
### 14.1 前端实际读取步骤
React Flow 启动时:
1. 使用 `no-store` 请求 `/api/components/catalog`。
2. 检查 `schemaVersion == 1`。
3. 检查库、模型、端口和参数结构。
4. 检查模型 `type` 是否全局重复。
5. 将参数数组转换为参数面板定义。
6. 按库、分类和模型的 `order` 排序。
7. 成功时显示“后端目录”。
8. 请求或格式校验失败时显示“内置兜底”并使用开发期兜底目录。
前端兜底目录不保证包含新模型。新增公开模型后,只要 FastAPI 正常提供目录,前端
就能读取;若要求后端离线时也显示新模型,才需要有意识地同步兜底定义。兜底定义
仍不能成为端口、参数或默认值的权威来源。
### 14.2 修改后如何生效
修改 Python 模型、库清单或注册器后必须重启 FastAPI:
```powershell
cd F:\Master\SystemSimulationApp
.\.venv-win\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
```
只刷新浏览器无法让已运行的 Python 进程重新导入模型。前端源代码由 Vite 开发服务
热更新;普通目录内容变化不需要重启 Vite。
## 15. System XML 映射
System XML 中:
```xml
<Component
id="cylinder_1"
name="cylinder_1"
type="cylinder"
componentType="cylinder">
```
映射规则:
- `id`:工程内唯一的组件实例 ID。
- `name`:用户可修改的组件实例名称。
- `type`:必须匹配唯一的 `MODEL_TYPE`。
- `componentType`:当前为兼容字段,应与 `type` 相同。
- `<Port name>`:必须存在于模型的 `PORTS`。
- `<Parameter name>`:必须存在于模型的 `PARAMETERS`。
XML 解析器只负责结构、引用和契约校验;模型注册中心负责选择 Python 类并创建实例;
模型自身负责方程和状态。三层职责不得混合。
## 16. 测试要求
每个新模型至少需要:
1. 元数据测试:模型类型、端口、参数和结果声明合法。
2. 目录测试:模型出现在正确库和分类中。
3. 默认创建测试:默认参数能构造模型。
4. 参数边界测试:非法值被拒绝,错误信息包含组件和参数。
5. 端口测试:实例端口与声明完全一致。
6. XML 测试:最小系统能解析并映射到正确模型。
7. 方程测试:至少验证一个稳态、残差或守恒关系。
8. 最小仿真测试:一个短时算例能产生有限结果和结构化结果元数据。
库级测试还应检查:
- 库清单中的所有类均可导入。
- 所有模型类型全局唯一。
- 无遗漏或重复分类。
- `GET /api/components/catalog` 满足目录 schema。
## 17. 新增模型操作清单
开发者新增模型时只执行以下步骤:
1. 在目标库的正确分类目录中新建模型文件。
2. 实现 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、
`RESULT_VARIABLES` 和 `DISPLAY`。
3. 实现统一 `create()` 和模型方程。
4. 将模型类路径加入该库 `library.py` 的 `models`。
5. 添加模型单元测试和最小 XML/仿真测试。
6. 运行注册校验和完整测试。
7. 重启 FastAPI,刷新前端确认目录来源为“后端目录”。
正常情况下不需要修改:
- React Flow 左侧组件列表。
- 参数面板字段。
- System XML 模型类型分派代码。
- 结果变量关键词映射。
- 集中式模型工厂表。
### 17.1 AI 修改约束
AI 在处理组件库读取任务时必须:
1. 先确认目标是新增模型、修改模型还是新增库。
2. 读取当前启用列表和目标库清单。
3. 只把公开模型加入 `library.py/models`。
4. 不通过递归扫描替代显式清单。
5. 不在前端重新声明后端契约作为正式实现。
6. 不静默跳过加载失败的模型。
7. 保留未知 `symbol` 的通用图标回退能力。
8. 修改后检查目录响应,并运行注册表和前端构建测试。
9. 告知用户需要重启 FastAPI。
AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开模型。公开性的唯一判断
依据是该类是否出现在已启用库的 `models` 清单中。
## 18. 当前实现与目标规范的差异
| 能力 | 当前状态 | 目标 |
| --- | --- | --- |
| 库 ID、名称、版本、分类和模型清单 | 已实现 | 由各库 `library.py` 维护 |
| 库和模型注册表 | 已实现 | 由已启用库清单自动构建 |
| 前端目录接口 | 已实现 | 已包含库版本和模型版本 |
| 前端动态分类和参数读取 | 已实现 | 新增已支持图标的模型无需改组件列表 |
| 端口与参数契约 | 已实现 | 保持为唯一事实来源 |
| 结构化结果元数据 | 已实现 | 保持为唯一事实来源 |
| `DISPLAY` | 已实现 | 由公开模型类自行声明 |
| 模型工厂 | 已实现 | 模型类统一 `create()` |
| 模型发现 | 已实现 | 按库清单受控发现 |
| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 |
| XML/工程中的模型版本与迁移 | 未实现 | 正式库发布前补齐 |
| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` |
## 19. 推荐实施顺序
1. 已完成:声明类型已放入独立的 `core/catalog.py`。
2. 已完成:`experimental/library.py` 已成为临时库唯一清单入口。
3. 已完成:五个公开模型自行声明 `DISPLAY` 和 `MODEL_VERSION`。
4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。
5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。
6. 已完成:已增加组件目录 JSON Schema。
7. 待完成:在 System XML 和工程文件中保存模型版本,并设计迁移机制。
8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。
该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、
解析器和界面。
## 20. 改动影响表
| 想做的改动 | 必须修改 | 通常不需要修改 |
| --- | --- | --- |
| 新增同库同分类模型 | 模型文件、`library.py/models`、测试 | 注册表、前端参数列表 |
| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 |
| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 |
| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 |
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、XML/网络测试、版本迁移 | 库分类 |
| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 |
| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 |
| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 |
## 21. 读取故障排查
| 现象 | 优先检查 |
| --- | --- |
| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` |
| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 |
| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 |
| 前端显示“内置兜底” | 8000 端口、`/api/components/catalog`、后端是否重启 |
| 分类错误 | `DISPLAY.category_id` 与库 `categories` |
| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 |
| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 |
| XML 报不支持类型 | XML `type` 是否精确匹配 `MODEL_TYPE` |
| 图标是通用图形 | `symbol` 尚无专用前端渲染器,但模型仍应可用 |
最小诊断命令:
```powershell
.\.venv-win\Scripts\python.exe -c "from app.simulation.registry import build_component_catalog; print(build_component_catalog())"
.\.venv-win\Scripts\python.exe -m unittest tests.test_component_registry tests.test_component_catalog
```
+680
View File
@@ -0,0 +1,680 @@
# 组件模型建模规范 v1
状态:已在 `experimental` 临时组件库实施
适用对象:人工开发者、代码生成工具和 AI 编程助手
配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
## 1. 文档目标
本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须
同时满足四个使用方:
1. 求解器能够实例化模型并调用方程。
2. System XML 能够根据稳定类型找到模型。
3. React Flow 能够自动显示图标、端口和参数。
4. 结果页面能够根据结构化元数据展示变量。
本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷:
先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。
## 2. 开始前先判断任务类型
### 2.1 新增公开模型
公开模型会出现在前端组件库中,也能被 System XML 创建。必须:
- 放入某个组件库的分类目录。
- 实现完整模型契约。
- 加入该库 `library.py` 的 `models` 清单。
- 添加目录、契约、方程和最小仿真测试。
### 2.2 修改已有公开模型
必须先判断改动是否破坏已有工程:
| 改动 | 版本建议 | 兼容性要求 |
| --- | --- | --- |
| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 |
| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 |
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 |
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
### 2.3 新增内部模型
仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
当前示例是
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。
### 2.4 新增物理域
仅新增模型类不足以支持新物理域。除了模型,还必须设计:
- `PortDefinition` 和端口变量。
- 变量角色与连接规则。
- 网络兼容性检查。
- 代数方程和 stream/signal 传播。
- XML 端口协议。
- 前端连线兼容规则。
- 最小闭合系统与求解测试。
没有完成这些基础能力时,不得仅通过修改 `domain` 字符串宣称支持新物理域。
## 3. 开发前必须读取的文件
人工或 AI 在修改模型前,应按顺序读取:
1. 本文档。
2. 目标库的 `library.py`。
3. 同分类中物理行为最接近的现有模型。
4. [`core/base.py`](../app/simulation/core/base.py)。
5. [`core/ports.py`](../app/simulation/core/ports.py)。
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
9. 与目标模型最接近的测试。
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
## 4. 文件位置和命名
公开模型放在:
```text
app/simulation/components/<library_id>/<category_id>/<model_module>.py
```
例如:
```text
app/simulation/components/experimental/storage/cylinder.py
app/simulation/components/experimental/flow/orifice.py
app/simulation/components/experimental/junctions/tee.py
```
规则:
- 一个公开模型原则上对应一个文件和一个主要模型类。
- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。
- `MODEL_TYPE` 使用小写 `snake_case`。
- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。
- 中文名称只写入 `label`,不能代替机器标识。
- 求解器、介质和网络通用逻辑不得复制到模型文件。
## 5. 公开模型完整契约
每个公开模型类必须在自身类体中显式声明:
```python
MODEL_TYPE = "example_component"
MODEL_VERSION = "1.0.0"
PORTS = (...)
PARAMETERS = (...)
RESULT_VARIABLES = (...)
DISPLAY = ...
```
同时必须实现:
```python
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> Component:
...
```
注册器要求这些字段直接存在于公开模型类中。不要依赖父类隐式提供
`MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、
`DISPLAY` 或 `create()`。
## 6. 基类选择
### 6.1 `AlgebraicComponent`
适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如:
- 孔板
- 阀门
- 阻性管段
- 理想三通
至少实现:
- 构造函数和端口注册。
- `create()`。
- `pressure_flow_equation_residuals()`。
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
### 6.2 `ThermodynamicVolumeComponent`
适用于包含质量和能量状态的气体容腔,例如:
- 气瓶
- 贮箱
- 有容积的管段
至少实现:
- `get_state_vector()`。
- `set_state_vector()`。
- `refresh_thermodynamic_ports()`。
- `state_derivative_from_ports()`。
- `pressure_flow_equation_residuals()`。
该基类已经提供标准热力学组件结果:
```text
m, U, p, T, rho, u, h
```
除非物理含义不同,不要重新复制这组结果声明。
### 6.3 其他基类
如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型
直接把专用判断塞入 `SimulationNetwork` 或求解器。
## 7. 端口建模规范
当前气动模型使用:
```python
PortDefinition.pneumatic(
"port_a",
nominal_role="bidirectional",
)
```
气动端口包含:
| 变量 | 角色 | 连接规则 | SI 单位 |
| --- | --- | --- | --- |
| `p` | `effort` | `equal` | `Pa` |
| `m_flow` | `flow` | `sumToZero` | `kg/s` |
| `h_outflow` | `stream` | `streamMix` | `J/kg` |
必须遵守:
- `m_flow > 0` 表示质量流入当前组件。
- `nominal_role` 只用于界面和默认布局,不限制实际流向。
- 物理连接是非因果的,连接线端点顺序不代表流向。
- 所有声明端口必须使用 `register_declared_port()` 创建。
- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。
- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。
禁止:
- 在模型内部根据画布左右方向判断流向。
- 为了前端显示另造一套端口名。
- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。
- 直接绕过端口状态读写其他组件对象。
## 8. 参数建模规范
所有用户可配置输入必须使用 `ParameterDefinition`:
```python
ParameterDefinition(
name="volume",
label="容积",
quantity="volume",
unit="m3",
default=0.1,
minimum=0.0,
minimum_exclusive=True,
)
```
字段含义:
| 字段 | 规则 |
| --- | --- |
| `name` | 稳定机器名,同时用于 XML、工程文件和 `create()` |
| `label` | 前端显示名称,不能为空 |
| `quantity` | 受控物理量标识 |
| `unit` | 后端 SI 基准单位 |
| `default` | 必须能够创建有效模型 |
| `minimum` / `maximum` | 必须反映方程有效范围 |
| `minimum_exclusive` | 用于直径、容积等严格大于零的量 |
当前受控单位定义在 `SI_UNIT_BY_QUANTITY`:
| quantity | SI 单位 |
| --- | --- |
| `dimensionless` | 空字符串 |
| `density` | `kg/m³` |
| `flow_coefficient` | `kg/(s*Pa^0.5)` |
| `internal_energy` | `J` |
| `length` | `m` |
| `mass` | `kg` |
| `mass_flow` | `kg/s` |
| `pressure` | `Pa` |
| `specific_enthalpy` | `J/kg` |
| `specific_internal_energy` | `J/kg` |
| `temperature` | `K` |
| `volume` | `m3` |
新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在
单个模型中私自拼写新的同义 `quantity`。
构造函数必须调用:
```python
self.set_parameter_values(
{
"volume": volume,
"p0": p0,
"T0": T0,
}
)
```
保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。
## 9. 结果变量规范
### 9.1 组件级结果
组件自身状态或派生量使用 `ResultVariableDefinition`:
```python
ResultVariableDefinition(
name="pressure_drop",
label="压降",
quantity="pressure",
unit="Pa",
category="derived",
order=10,
)
```
声明后必须在 `component_result_values()` 返回同名值:
```python
def component_result_values(self) -> Mapping[str, float]:
return {
"pressure_drop": self.port_a.p - self.port_b.p,
}
```
声明集合和返回键必须一致。
### 9.2 端口结果
端口结果由 `PORTS` 的端口变量自动产生,不要在 `RESULT_VARIABLES` 中重复声明
`port_a.p`、`port_a.m_flow` 等字段。
### 9.3 禁止暴露的内容
以下内容默认不能作为用户结果:
- 非线性求解器内部未知量索引。
- 缩放残差和迭代缓存。
- 仅用于调试的临时中间值。
- 可以由已有结果稳定推导、但没有明确工程用途的重复字段。
## 10. 显示声明规范
公开模型必须声明 `DISPLAY`:
```python
DISPLAY = ComponentDisplaySpec(
label="示例阻力元件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=90,
)
```
规则:
- `library_id` 必须等于所属库 ID。
- `category_id` 必须存在于所属库的 `categories`。
- `symbol` 是前端图形键,不是模型类型。
- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。
- 只有确实需要专用工程图标时才修改前端图标渲染器。
- `side` 只允许 `left` 或 `right`。
- 旋转和镜像不能改变端口名或物理语义。
## 11. 标准创建入口
`create()` 是注册器创建模型的唯一入口:
```python
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleComponent:
return cls(
name=name,
medium=medium,
coefficient=parameters["coefficient"],
)
```
注册器会在调用前:
1. 补齐默认参数。
2. 拒绝未知参数。
3. 检查有限值和边界。
调用后还会检查:
1. 返回对象类型正确。
2. 实例 `model_type` 与 `MODEL_TYPE` 一致。
3. 实际端口与 `PORTS` 完全一致。
4. 实例保存的参数与规范化参数完全一致。
`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。
## 12. 方程实现要求
模型方程必须满足:
- 残差形式统一为“期望等式左侧减右侧”。
- 每条 `EquationResidual` 使用稳定、可定位的 `id`。
- `variables` 列出该残差实际涉及的端口量或状态。
- `role` 与方程主要约束的物理角色一致。
- 对零压差、零流量和反向流动给出有限结果。
- 必要正则化必须有物理解释,并通过边界测试保护。
- 不得用画布坐标、连接线方向或组件名称决定方程。
动态模型还必须:
- 状态向量长度稳定。
- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。
- 状态导数满足质量和能量守恒约定。
- 初始化默认值能够产生有限介质状态。
## 13. 可复制的代数模型模板
下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改
类型、参数、方程、名称和测试,不能只改类名就注册。
```python
from __future__ import annotations
from collections.abc import Mapping
from math import sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class ExampleRestriction(AlgebraicComponent):
MODEL_TYPE = "example_restriction"
MODEL_VERSION = "1.0.0"
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
name="K",
label="流量系数",
quantity="flow_coefficient",
unit="kg/(s*Pa^0.5)",
default=1e-5,
minimum=0.0,
),
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="示例阻力元件",
library_id="experimental",
category_id="flow",
symbol="generic",
ports=(
PortDisplaySpec("port_a", "left", order=10),
PortDisplaySpec("port_b", "right", order=20),
),
order=90,
)
def __init__(self, name: str, K: float = 1e-5) -> None:
super().__init__(name)
self.set_parameter_values({"K": K})
self.K = K
self.port_a = self.register_declared_port("port_a")
self.port_b = self.register_declared_port("port_b")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> ExampleRestriction:
return cls(name=name, K=parameters["K"])
def pressure_flow_equation_residuals(
self,
) -> tuple[EquationResidual, ...]:
pressure_difference = self.port_a.p - self.port_b.p
expected_flow = (
self.K
* sqrt(abs(pressure_difference))
* (1.0 if pressure_difference > 0.0 else -1.0)
if pressure_difference != 0.0
else 0.0
)
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 - expected_flow,
),
)
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"]
```
真实现有模型可参考:
- 储能元件:
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
- 阻性元件:
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
- 多端口连接元件:
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
## 14. 注册模型
模型文件完成后,只修改所属库的 `library.py`:
```python
models=(
# 已有模型
"app.simulation.components.experimental.flow.example_restriction:ExampleRestriction",
)
```
禁止:
- 直接修改 `COMPONENT_MODEL_REGISTRY`。
- 在前端复制参数和端口定义作为正式来源。
- 递归扫描组件目录自动导入所有 `.py`。
- 同时注册两个相同 `MODEL_TYPE`。
- 把测试类、抽象基类或内部算例模型加入公开清单。
## 15. 测试要求
每个公开模型至少添加:
1. 静态契约测试。
2. 默认参数创建测试。
3. 参数边界测试。
4. 端口与显示布局一致性测试。
5. 关键方程残差测试。
6. 零流量或反向流动测试。
7. 目录输出测试。
8. 最小 XML 编译测试。
9. 能进入通用求解器的模型,再添加短时仿真测试。
推荐先运行:
```powershell
.\.venv-win\Scripts\python.exe -m unittest `
tests.test_component_registry `
tests.test_component_catalog `
tests.test_component_metadata
```
然后运行完整回归:
```powershell
.\.venv-win\Scripts\python.exe -m unittest discover -s tests
```
目录契约影响前端时还要运行:
```powershell
cd frontend
$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path
npm.cmd run build
```
## 16. 修改已有模型的安全步骤
1. 找到 `MODEL_TYPE` 的所有 XML、工程和测试引用。
2. 记录修改前的端口、参数、结果和默认行为。
3. 判断版本级别和是否需要迁移。
4. 先增加或修改测试,明确预期物理行为。
5. 修改模型类,不在注册器和前端复制规则。
6. 检查默认实例和旧参数是否仍能创建。
7. 检查最小系统是否仍然闭合。
8. 运行针对性测试和完整回归。
9. 同步本文档或模型专属说明中的物理假设。
## 17. 人工或 AI 的任务输入卡
为了减少猜测,新增模型前建议先填写:
```text
模型中文名称:
MODEL_TYPE:
所属 library_id:
所属 category_id:
物理域:
模型用途和边界:
端口列表及含义:
参数列表、SI 单位、默认值和范围:
状态变量:
代数方程或微分方程:
正流量约定:
需要显示的组件结果:
已知参考模型或工程公式:
最小测试系统:
允许的近似:
明确不实现的能力:
```
如果关键物理信息缺失,AI 应先通过现有模型、测试或用户提供的参考补齐;不能仅凭
组件名称自行创造方程。
## 18. AI 修改协议
AI 创建或修改模型时必须遵守:
### 修改前
1. 读取第 3 节列出的文件。
2. 检查工作区已有改动,不能覆盖无关修改。
3. 明确模型是公开模型还是内部模型。
4. 明确端口物理域、状态、参数、方程和结果。
5. 找到最接近的现有模型并沿用代码风格。
### 修改中
1. 将物理契约保存在模型类中。
2. 只在库清单中登记公开模型。
3. 不修改集中注册表来加入单个模型。
4. 不为了让测试通过而放宽全局校验。
5. 不改变现有模型标识,除非任务明确要求迁移。
6. 不把前端拖拽方向当作物理流向。
7. 不把求解器失败简单隐藏为默认结果。
### 修改后
1. 展示涉及的模型、清单和测试文件。
2. 报告版本变化和兼容性影响。
3. 运行针对性测试、完整后端测试和必要的前端构建。
4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。
5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。
6. 未执行的校验必须明确说明原因。
## 19. 常见失败与处理
| 现象 | 常见原因 | 处理 |
| --- | --- | --- |
| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 |
| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI |
| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 |
| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 |
| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 |
| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` |
| XML 报不支持模型 | XML `type` 与 `MODEL_TYPE` 不一致 | 修正类型或提供迁移 |
| 模型可显示但无法仿真 | 只完成目录元数据,方程或物理域求解未实现 | 补齐方程、网络和求解测试 |
## 20. 完成定义
一个模型只有同时满足以下条件才算完成:
- 模型契约完整且启动校验通过。
- 默认参数和边界有效。
- 端口、参数和结果具有稳定物理含义。
- 方程覆盖零流量、正常流动和必要的反向流动。
- 模型已加入正确库清单。
- 目录接口能自动输出模型。
- 前端无需复制参数和端口定义即可使用。
- XML 能映射到正确模型。
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
- 针对性测试、完整回归和必要的前端构建通过。
- 文档记录了模型假设、适用范围和已知限制。
+72
View File
@@ -0,0 +1,72 @@
# System XML v1 协议
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。
## 基本约定
- 根元素固定为 `System`,`schemaVersion` 固定为 `1`。
- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
- v1 文档不携带结果数据;仿真结果通过运行接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="1" unitSystem="SI">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180">
<Port name="port_b"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180">
<Port name="port_a"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" source="cylinder_1" sourcePort="port_b" target="tank_1" targetPort="port_a"/>
</Connections>
</System>
```
## Simulation
| 属性 | 类型 | 含义 | 约束 |
|---|---|---|---|
| `tStart` | double | 仿真起始时间,单位 s | 有限数 |
| `tStop` | double | 仿真结束时间,单位 s | 必须大于 `tStart` |
| `step` | double | 结果采样间隔,单位 s | 必须大于 0 |
| `maxStep` | double | 求解器最大积分步长,单位 s | 必须大于 0 |
| `method` | string | 求解器名称,例如 `BDF` | 非空 |
## Components
每个 `Component` 必须包含唯一 `id`、显示名称、模型类型、前端类型和画布坐标。组件下可以包含多个 `Port` 和 `Parameter`。
参数的 `value` 必须是可转换为有限浮点数的 SI 值。具体组件支持的端口、参数和取值范围由后续组件注册表协议约束。
## Connections
每个 `Connection` 必须包含唯一 `id` 和两个完整端点:
- `source` + `sourcePort`
- `target` + `targetPort`
端点中的组件 ID 必须存在,端口名称必须属于对应组件。v1 前端仍使用 source/target 组织连线,但仿真层必须将它们视为连接的两个端点,不得直接解释为固定物理流向。
## 版本兼容
- 解析器遇到缺少 `schemaVersion` 的旧 XML 时,应明确报告“旧协议”,不能静默当作 v1。
- v1 新增可选字段时必须保持旧解析器可忽略;删除字段或改变字段语义时必须升级主版本。
- XSD 只验证文档结构和基础数据类型。跨字段约束、组件端口规则和拓扑可求解性由仿真校验层负责。
+216
View File
@@ -0,0 +1,216 @@
# System XML v2 协议
System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
## 基本约定
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。
- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。
- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<System name="transfer-system" schemaVersion="2" unitSystem="SI">
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
<Components>
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
<Port name="port_b" kind="physical" domain="pneumatic" nominalRole="outlet" positiveFlowDirection="intoComponent" side="right"/>
<Parameter name="volume" value="0.01"/>
<Parameter name="p0" value="35000000"/>
<Parameter name="T0" value="300"/>
</Component>
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180" rotation="0" mirrored="false">
<Port name="port_a" kind="physical" domain="pneumatic" nominalRole="inlet" positiveFlowDirection="intoComponent" side="left"/>
<Parameter name="volume" value="0.1"/>
<Parameter name="p0" value="100000"/>
<Parameter name="T0" value="300"/>
</Component>
</Components>
<Connections>
<Connection id="edge-1" kind="physical" domain="pneumatic">
<Endpoint component="cylinder_1" port="port_b"/>
<Endpoint component="tank_1" port="port_a"/>
</Connection>
</Connections>
</System>
```
## Port
组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。
| 属性 | 含义 |
|---|---|
| `name` | 组件模型中的稳定端口名 |
| `kind` | `physical` 或 `signal` |
| `domain` | 端口物理域,当前流体组件使用 `pneumatic` |
| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` |
| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 |
| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 |
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
三通的三个端口当前保留仿真模型已有名称 `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 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
## 仿真模型编译接口
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 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 回到仿真网络的完整入口。解析过程固定分为三层:
| 层级 | `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`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 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
```
组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
## 第三阶段:XML 驱动仿真 MVP
第三阶段当前已经打通:
1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
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
```
+124
View File
@@ -9,10 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
@@ -83,6 +86,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -853,12 +872,30 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1014,6 +1051,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1287,6 +1337,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1326,6 +1385,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -1426,6 +1532,15 @@
"node": ">=0.10.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1495,6 +1610,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+6 -1
View File
@@ -6,14 +6,19 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview --host 127.0.0.1"
"preview": "vite preview --host 127.0.0.1",
"test": "playwright test",
"test:e2e": "playwright test"
},
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: {
timeout: 6_000,
},
use: {
baseURL: "http://127.0.0.1:4173",
channel: "msedge",
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: "npm run dev -- --port 4173 --strictPort",
url: "http://127.0.0.1:4173",
reuseExistingServer: false,
timeout: 60_000,
},
});
+4224 -259
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useReactFlow, useStore } from "@xyflow/react";
type AutoFitViewProps = {
enabled?: boolean;
expectedNodeCount: number;
minZoom?: number;
padding?: number;
requestKey: number | string;
};
export function AutoFitView({
enabled = true,
expectedNodeCount,
minZoom = 0.1,
padding = 0.18,
requestKey,
}: AutoFitViewProps) {
const expectedNodesAreMeasured = useStore((state) => {
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) {
return false;
}
for (const node of state.nodeLookup.values()) {
if (
!node.measured.width ||
!node.measured.height ||
node.measured.width <= 0 ||
node.measured.height <= 0
) {
return false;
}
}
return true;
});
const { fitView } = useReactFlow();
useLayoutEffect(() => {
if (!enabled || !expectedNodesAreMeasured) {
return;
}
void fitView({ duration: 0, minZoom, padding });
}, [
enabled,
expectedNodesAreMeasured,
fitView,
minZoom,
padding,
requestKey,
]);
return null;
}
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
export type WorkspaceView = "modeling" | "results";
type WorkspaceViewTabsProps = {
activeView: WorkspaceView;
hasResults: boolean;
hasUnreadResults?: boolean;
onViewChange: (view: WorkspaceView) => void;
};
export function WorkspaceViewTabs({
activeView,
hasResults,
hasUnreadResults = false,
onViewChange,
}: WorkspaceViewTabsProps) {
return (
<div aria-label="工作区视图" className="workspace-view-tabs" role="tablist">
<button
aria-selected={activeView === "modeling"}
className={activeView === "modeling" ? "active" : ""}
onClick={() => onViewChange("modeling")}
role="tab"
type="button"
>
建模
</button>
<button
aria-label={hasUnreadResults ? "结果,有新的仿真结果" : "结果"}
aria-selected={activeView === "results"}
className={activeView === "results" ? "active" : ""}
onClick={() => onViewChange("results")}
role="tab"
title={hasUnreadResults ? "有新的仿真结果" : undefined}
type="button"
>
结果
{hasResults && hasUnreadResults ? (
<span aria-hidden="true" className="result-available-dot" />
) : null}
</button>
</div>
);
}
+1982 -76
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,6 +1,7 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $PSScriptRoot
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
Sort-Object Name -Descending |
Select-Object -First 1
@@ -10,4 +11,4 @@ if (-not $nodeDir) {
}
$env:Path = "$($nodeDir.FullName);$env:Path"
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import {
expectAllNodesInsideCanvas,
prepareApp,
resultSnapshot,
wideProject,
} from "./fixtures";
test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("工程已加载");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("工程已加载");
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "恢复" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "建模" }).click();
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
wideProject.edges.length,
);
});
+213
View File
@@ -0,0 +1,213 @@
import { expect, type Page } from "@playwright/test";
const physicalPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
order: side === "left" ? 0 : 1,
});
export const componentCatalog = {
schemaVersion: 1,
libraries: [
{
id: "e2e-library",
label: "自动化测试组件库",
version: "1.0.0",
sourcePackage: "tests.e2e",
temporary: true,
order: 1,
components: [
{
type: "generic_sensor",
label: "通用测试元件",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 1,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [
physicalPort("port_a", "left", "bidirectional"),
physicalPort("port_b", "right", "bidirectional"),
],
parameters: [
{
name: "gain",
label: "增益",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
},
],
},
],
},
],
};
const projectPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
});
export const wideProject = {
name: "demo-system",
nodes: Array.from({ length: 4 }, (_, index) => ({
id: `generic_sensor_${index + 1}`,
type: "simulationComponent",
position: {
x: index * 620,
y: index % 2 === 0 ? 0 : 260,
},
data: {
label: `generic_sensor_${index + 1}`,
componentType: "generic_sensor",
modelType: "generic_sensor",
symbol: "symbol-not-registered",
ports: [
projectPort("port_a", "left", "bidirectional"),
projectPort("port_b", "right", "bidirectional"),
],
parameters: { gain: 1 },
parameterUnits: { gain: "" },
rotation: 0,
mirrored: false,
},
})),
edges: Array.from({ length: 3 }, (_, index) => ({
id: `edge-${index + 1}`,
source: `generic_sensor_${index + 1}`,
target: `generic_sensor_${index + 2}`,
sourceHandle: "port_b",
targetHandle: "port_a",
})),
simulation: {
t_start: 0,
t_stop: 10,
step: 0.1,
max_step: 0.05,
method: "RK45",
},
};
export const resultSnapshot = {
id: "e2e-result",
createdAt: "2026-07-28T10:00:00.000Z",
project: wideProject,
result: {
success: true,
status: "completed",
partial: false,
message: "Simulation completed",
simulatedUntil: 10,
requestedStopTime: 10,
variables: [
{
key: "generic_sensor_1.value",
componentId: "generic_sensor_1",
componentType: "generic_sensor",
scope: "component",
portName: null,
name: "value",
label: "数值",
quantity: "dimensionless",
unit: "",
category: "state",
order: 1,
},
],
final: { "generic_sensor_1.value": 1 },
series: {
time: [0, 5, 10],
"generic_sensor_1.value": [0, 0.5, 1],
},
diagnostics: {
pressureFlow: {
solveCount: 3,
maxScaledResidual: 0,
maxEvaluationsPerSolve: 1,
},
stream: {
maxIterationsPerSolve: 1,
},
stateCount: 1,
sampleCount: 3,
},
},
};
export async function prepareApp(page: Page) {
await page.addInitScript(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.hash = "#/modeling";
});
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(componentCatalog),
});
});
}
export async function expectAllNodesInsideCanvas(
page: Page,
canvasSelector: string,
expectedCount = wideProject.nodes.length,
) {
const canvas = page.locator(canvasSelector);
await expect(canvas).toBeVisible();
const nodes = canvas.locator(".react-flow__node");
await expect(nodes).toHaveCount(expectedCount);
await expect
.poll(
async () =>
page.evaluate(
({ selector, count }) => {
const canvasElement = document.querySelector<HTMLElement>(selector);
const nodeElements = Array.from(
canvasElement?.querySelectorAll<HTMLElement>(".react-flow__node") ?? [],
);
if (!canvasElement || nodeElements.length !== count) {
return false;
}
const canvasBounds = canvasElement.getBoundingClientRect();
const tolerance = 3;
return nodeElements.every((node) => {
const bounds = node.getBoundingClientRect();
return (
bounds.left >= canvasBounds.left - tolerance &&
bounds.top >= canvasBounds.top - tolerance &&
bounds.right <= canvasBounds.right + tolerance &&
bounds.bottom <= canvasBounds.bottom + tolerance
);
});
},
{ selector: canvasSelector, count: expectedCount },
),
{ message: `所有节点应完整显示在 ${canvasSelector} 内` },
)
.toBe(true);
}
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({
page,
}) => {
await page.goto("/");
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
await expect(paletteItem).toBeVisible();
await paletteItem.dragTo(page.locator(".flow-canvas .react-flow__pane"));
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
await expect(node).toBeVisible();
await expect(node.locator(".sim-node")).toHaveClass(/generic-component/);
await expect(node.locator(".node-symbol-anchor")).toHaveCount(0);
await expect(node.locator(".node-label")).toHaveText("generic_sensor_1");
await expect(node.locator(".port-handle")).toHaveCount(2);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toHaveClass(/has-unread/);
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
await expect(consolePanel).not.toContainText("已添加组件");
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expect(consolePanel).toContainText("XML 已生成");
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toBeVisible();
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"');
await expect(page.locator(".properties .xml-output")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("系统 XML");
await page.getByRole("button", { name: "检查模型" }).click();
await expect(consolePanel).toContainText(/模型检查/);
await expect(page.locator(".status-strip")).toHaveCount(0);
await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果");
});
+2
View File
@@ -1,2 +1,4 @@
fastapi
lxml>=5,<7
scipy>=1.13,<2
uvicorn[standard]
+304
View File
@@ -0,0 +1,304 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://systemsimulation.local/schemas/component-catalog-v1.schema.json",
"title": "SystemSimulation Component Catalog v1",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"libraries"
],
"properties": {
"schemaVersion": {
"const": 1
},
"libraries": {
"type": "array",
"items": {
"$ref": "#/$defs/library"
}
}
},
"$defs": {
"machineId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"category": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"order"
],
"properties": {
"id": {
"$ref": "#/$defs/machineId"
},
"label": {
"type": "string",
"minLength": 1
},
"order": {
"type": "integer"
}
}
},
"portVariable": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"role",
"connectionRule",
"label",
"quantity",
"unit",
"resultVisible",
"order"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
},
"role": {
"enum": [
"effort",
"flow",
"stream",
"signal"
]
},
"connectionRule": {
"enum": [
"equal",
"sumToZero",
"streamMix",
"directed"
]
},
"label": {
"type": "string",
"minLength": 1
},
"quantity": {
"type": "string",
"minLength": 1
},
"unit": {
"type": "string"
},
"resultVisible": {
"type": "boolean"
},
"order": {
"type": "integer"
}
}
},
"port": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"kind",
"domain",
"nominalRole",
"positiveFlowDirection",
"variables",
"side",
"order"
],
"properties": {
"name": {
"$ref": "#/$defs/machineId"
},
"kind": {
"enum": [
"physical",
"signal"
]
},
"domain": {
"$ref": "#/$defs/machineId"
},
"nominalRole": {
"enum": [
"inlet",
"outlet",
"bidirectional",
"input",
"output"
]
},
"positiveFlowDirection": {
"enum": [
"intoComponent",
null
]
},
"variables": {
"type": "array",
"items": {
"$ref": "#/$defs/portVariable"
}
},
"side": {
"enum": [
"left",
"right"
]
},
"order": {
"type": "integer"
}
}
},
"parameter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"label",
"quantity",
"unit",
"default",
"minimumExclusive"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
},
"label": {
"type": "string",
"minLength": 1
},
"quantity": {
"type": "string",
"minLength": 1
},
"unit": {
"type": "string"
},
"default": {
"type": "number"
},
"minimum": {
"type": "number"
},
"maximum": {
"type": "number"
},
"minimumExclusive": {
"type": "boolean"
}
}
},
"component": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"modelType",
"modelVersion",
"label",
"symbol",
"order",
"category",
"ports",
"parameters"
],
"properties": {
"type": {
"$ref": "#/$defs/machineId"
},
"modelType": {
"$ref": "#/$defs/machineId"
},
"modelVersion": {
"$ref": "#/$defs/version"
},
"label": {
"type": "string",
"minLength": 1
},
"symbol": {
"$ref": "#/$defs/machineId"
},
"order": {
"type": "integer"
},
"category": {
"$ref": "#/$defs/category"
},
"ports": {
"type": "array",
"items": {
"$ref": "#/$defs/port"
}
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/$defs/parameter"
}
}
}
},
"library": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"version",
"sourcePackage",
"temporary",
"order",
"categories",
"components"
],
"properties": {
"id": {
"$ref": "#/$defs/machineId"
},
"label": {
"type": "string",
"minLength": 1
},
"version": {
"$ref": "#/$defs/version"
},
"sourcePackage": {
"type": "string",
"minLength": 1
},
"temporary": {
"type": "boolean"
},
"order": {
"type": "integer"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/$defs/category"
}
},
"components": {
"type": "array",
"items": {
"$ref": "#/$defs/component"
}
}
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:simpleType name="NonEmptyString">
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PositiveDouble">
<xs:restriction base="xs:double">
<xs:minExclusive value="0"/>
</xs:restriction>
</xs:simpleType>
<xs:element name="System">
<xs:complexType>
<xs:sequence>
<xs:element name="Simulation">
<xs:complexType>
<xs:attribute name="tStart" type="xs:double" use="required"/>
<xs:attribute name="tStop" type="xs:double" use="required"/>
<xs:attribute name="step" type="PositiveDouble" use="required"/>
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
<xs:attribute name="method" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Components">
<xs:complexType>
<xs:sequence>
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="value" type="xs:double" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="type" type="NonEmptyString" use="required"/>
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
<xs:attribute name="x" type="xs:double" use="required"/>
<xs:attribute name="y" type="xs:double" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="Connections">
<xs:complexType>
<xs:sequence>
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="source" type="NonEmptyString" use="required"/>
<xs:attribute name="sourcePort" type="NonEmptyString" use="required"/>
<xs:attribute name="target" type="NonEmptyString" use="required"/>
<xs:attribute name="targetPort" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="schemaVersion" use="required" fixed="1"/>
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
</xs:complexType>
</xs:element>
</xs:schema>
+135
View File
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:simpleType name="NonEmptyString">
<xs:restriction base="xs:string">
<xs:whiteSpace value="collapse"/>
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PositiveDouble">
<xs:restriction base="xs:double">
<xs:minExclusive value="0"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortKind">
<xs:restriction base="xs:string">
<xs:enumeration value="physical"/>
<xs:enumeration value="signal"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortNominalRole">
<xs:restriction base="xs:string">
<xs:enumeration value="inlet"/>
<xs:enumeration value="outlet"/>
<xs:enumeration value="bidirectional"/>
<xs:enumeration value="input"/>
<xs:enumeration value="output"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="PortSide">
<xs:restriction base="xs:string">
<xs:enumeration value="left"/>
<xs:enumeration value="right"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="EndpointRole">
<xs:restriction base="xs:string">
<xs:enumeration value="source"/>
<xs:enumeration value="target"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="NodeRotation">
<xs:restriction base="xs:integer">
<xs:enumeration value="0"/>
<xs:enumeration value="90"/>
<xs:enumeration value="180"/>
<xs:enumeration value="270"/>
</xs:restriction>
</xs:simpleType>
<xs:element name="System">
<xs:complexType>
<xs:sequence>
<xs:element name="Simulation">
<xs:complexType>
<xs:attribute name="tStart" type="xs:double" use="required"/>
<xs:attribute name="tStop" type="xs:double" use="required"/>
<xs:attribute name="step" type="PositiveDouble" use="required"/>
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
<xs:attribute name="method" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Components">
<xs:complexType>
<xs:sequence>
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="kind" type="PortKind" use="required"/>
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
<xs:attribute name="nominalRole" type="PortNominalRole" use="required"/>
<xs:attribute name="positiveFlowDirection" use="optional" fixed="intoComponent"/>
<xs:attribute name="side" type="PortSide" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="value" type="xs:double" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="type" type="NonEmptyString" use="required"/>
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
<xs:attribute name="x" type="xs:double" use="required"/>
<xs:attribute name="y" type="xs:double" use="required"/>
<xs:attribute name="rotation" type="NodeRotation" use="optional" default="0"/>
<xs:attribute name="mirrored" type="xs:boolean" use="optional" default="false"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="Connections">
<xs:complexType>
<xs:sequence>
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="Endpoint" minOccurs="2" maxOccurs="2">
<xs:complexType>
<xs:attribute name="component" type="NonEmptyString" use="required"/>
<xs:attribute name="port" type="NonEmptyString" use="required"/>
<xs:attribute name="role" type="EndpointRole" use="optional"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="id" type="NonEmptyString" use="required"/>
<xs:attribute name="kind" type="PortKind" use="required"/>
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="name" type="NonEmptyString" use="required"/>
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
</xs:complexType>
</xs:element>
</xs:schema>
+24
View File
@@ -0,0 +1,24 @@
@echo off
setlocal
cd /d "%~dp0"
title SystemSimulationApp Launcher
if not exist "%~dp0start-backend.bat" (
echo [ERROR] start-backend.bat was not found.
pause
exit /b 1
)
if not exist "%~dp0start-reactflow.bat" (
echo [ERROR] start-reactflow.bat was not found.
pause
exit /b 1
)
echo Starting FastAPI and ReactFlow in separate windows...
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%~dp0start-backend.bat"
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%~dp0start-reactflow.bat"
exit /b 0
+30
View File
@@ -0,0 +1,30 @@
@echo off
setlocal
cd /d "%~dp0"
title SystemSimulationApp FastAPI - 127.0.0.1:8000
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
if not exist "%PYTHON_EXE%" (
echo [ERROR] Python virtual environment was not found:
echo %PYTHON_EXE%
echo.
pause
exit /b 1
)
echo Starting FastAPI at http://127.0.0.1:8000
echo Press Ctrl+C to stop the service.
echo.
"%PYTHON_EXE%" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] FastAPI exited with code %EXIT_CODE%.
pause
)
exit /b %EXIT_CODE%
+31
View File
@@ -0,0 +1,31 @@
@echo off
setlocal
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
set "FRONTEND_DIR=%~dp0frontend"
set "START_SCRIPT=%FRONTEND_DIR%\start-dev.bat"
if not exist "%START_SCRIPT%" (
echo [ERROR] ReactFlow start script was not found:
echo %START_SCRIPT%
echo.
pause
exit /b 1
)
echo Starting ReactFlow at http://127.0.0.1:5173
echo Press Ctrl+C to stop the service.
echo.
cd /d "%FRONTEND_DIR%"
call "%START_SCRIPT%"
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
pause
)
exit /b %EXIT_CODE%
File renamed without changes.
Internal Server Error - 奔熠云-Git
500 Internal Server Error

An error occurred:

An error occurred

Gitea Version: 28.0.0