diff --git a/PythonModels/README.md b/PythonModels/README.md index b05b5e4..328256e 100644 --- a/PythonModels/README.md +++ b/PythonModels/README.md @@ -12,6 +12,7 @@ 包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。 - `components/`: 元件级 Python 实现 目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。 + 新增或修改元件时先阅读 `components/example.md` 中的建模规范与完整示例。 - `systems/`: 系统级装配与闭合 当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。 - `reporting/`: 结果导出与对比 diff --git a/PythonModels/components/cylinder.py b/PythonModels/components/cylinder.py index d6508dc..215c06f 100644 --- a/PythonModels/components/cylinder.py +++ b/PythonModels/components/cylinder.py @@ -2,16 +2,53 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import DynamicComponent +from PythonModels.core.base import ThermodynamicVolumeComponent from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortState +from PythonModels.core.ports import PortDefinition from PythonModels.core.state import VolumeState -class Cylinder(DynamicComponent): +class Cylinder(ThermodynamicVolumeComponent): """Python port of ModelicaModels.Mycylinder.""" + MODEL_TYPE = "cylinder" + PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),) + PARAMETERS = ( + ParameterDefinition( + "volume", + 0.01, + label="容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p0", + 35e6, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + def __init__( self, name: str, @@ -21,14 +58,13 @@ class Cylinder(DynamicComponent): T0: float = 300.0, ) -> None: super().__init__(name=name) + self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) self.medium = medium self.V = V m0 = p0 * V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) self.state = VolumeState(m=m0, U=U0) - self.port_b = self.register_port( - PortState.pneumatic("port_b", nominal_role="outlet") - ) + self.port_b = self.register_declared_port("port_b") def get_state_vector(self) -> list[float]: return self.state.as_vector() diff --git a/PythonModels/components/example.md b/PythonModels/components/example.md new file mode 100644 index 0000000..4ca00d5 --- /dev/null +++ b/PythonModels/components/example.md @@ -0,0 +1,240 @@ +# 元件建模规范与示例 + +本文档是 `PythonModels/components` 下新增元件的最小开发规范。目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML 校验、求解器和前端分别维护同一份含义。 + +## 一、元件类必须声明的内容 + +每个元件类至少需要声明以下四个类属性: + +```python +MODEL_TYPE = "example_component" +PORTS = (...) +PARAMETERS = (...) +RESULT_VARIABLES = (...) +``` + +- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。 +- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。 +- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。 +- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。 + +元件构造函数还必须: + +1. 调用 `super().__init__(name)`。 +2. 使用 `set_parameter_values()` 保存规范化后的输入参数。 +3. 使用 `register_declared_port()` 创建已声明端口。 +4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。 + +## 二、输入参数与结果变量 + +输入参数和仿真结果必须分开声明: + +- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。 +- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。 +- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。 +- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。 + +参数定义示例: + +```python +ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, +) +``` + +结果变量定义示例: + +```python +ResultVariableDefinition( + name="p", + label="压力", + quantity="pressure", + unit="Pa", + category="thermodynamic", + order=30, +) +``` + +## 三、命名和单位约定 + +- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。 +- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。 +- 无量纲参数的 `unit` 使用空字符串。 +- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。 +- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。 +- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。 + +## 四、完整示例:单端口储气容腔 + +下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。 + +```python +from __future__ import annotations + +from collections.abc import Mapping + +from PythonModels.core.base import ThermodynamicVolumeComponent +from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) +from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties +from PythonModels.core.ports import PortDefinition +from PythonModels.core.state import VolumeState + + +class ExampleVolume(ThermodynamicVolumeComponent): + MODEL_TYPE = "example_volume" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), + ) + PARAMETERS = ( + ParameterDefinition( + name="volume", + label="容积", + quantity="volume", + unit="m3", + default=0.1, + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + name="p0", + label="初始压力", + quantity="pressure", + unit="Pa", + default=100000.0, + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + name="T0", + label="初始温度", + quantity="temperature", + unit="K", + default=300.0, + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + + def __init__( + self, + name: str, + medium: IdealGasMedium, + volume: float = 0.1, + p0: float = 100000.0, + T0: float = 300.0, + ) -> None: + super().__init__(name) + self.set_parameter_values( + {"volume": volume, "p0": p0, "T0": T0} + ) + self.medium = medium + self.V = volume + initial_mass = p0 * volume / (medium.R_gas * T0) + initial_energy = initial_mass * medium.specific_internal_energy(T0) + self.state = VolumeState(m=initial_mass, U=initial_energy) + self.port_a = self.register_declared_port("port_a") + + def get_state_vector(self) -> list[float]: + return self.state.as_vector() + + def set_state_vector(self, values: list[float]) -> None: + self.state = VolumeState.from_vector(values) + + def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: + properties = self.medium.properties_from_mU( + self.state.m, self.state.U, self.V + ) + self.port_a.p = properties.p + self.port_a.h_outflow = properties.h + return properties + + def state_derivative_from_ports( + self, + connected_h: Mapping[str, float], + ) -> list[float]: + properties = self.refresh_thermodynamic_ports() + inlet_h = self.connection_inlet_enthalpy( + port_m_flow=self.port_a.m_flow, + connected_h=connected_h["port_a"], + internal_h=properties.h, + ) + return [self.port_a.m_flow, self.port_a.m_flow * inlet_h] + + def pressure_flow_equation_residuals( + self, + ) -> tuple[EquationResidual, ...]: + pressure = self.medium.properties_from_mU( + self.state.m, self.state.U, self.V + ).p + return ( + EquationResidual( + id=f"{self.name}:port_a_pressure_state", + owner="component", + owner_id=self.name, + relation="state", + variables=(f"{self.name}.port_a.p", f"{self.name}.state"), + role="effort", + value=self.port_a.p - pressure, + ), + ) +``` + +注册时只引用元件类已经声明的契约,不要再复制参数和端口定义: + +```python +def _example_volume_factory(name, medium, values): + return ExampleVolume( + name=name, + medium=medium, + volume=values["volume"], + p0=values["p0"], + T0=values["T0"], + ) + + +COMPONENT_MODEL_REGISTRY[ExampleVolume.MODEL_TYPE] = ComponentModelSpec( + model_type=ExampleVolume.MODEL_TYPE, + ports=ExampleVolume.PORTS, + parameters=ExampleVolume.PARAMETERS, + factory=_example_volume_factory, +) +``` + +完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义: + +```json +{ + "key": "example_volume_1.port_a.m_flow", + "componentId": "example_volume_1", + "componentType": "example_volume", + "scope": "port", + "portName": "port_a", + "name": "m_flow", + "label": "质量流量", + "quantity": "mass_flow", + "unit": "kg/s", + "category": "flow", + "order": 20 +} +``` + +## 五、新增元件检查清单 + +1. `MODEL_TYPE` 是否唯一,并与 XML、前端组件类型一致。 +2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。 +3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。 +4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。 +5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。 +6. 是否只暴露有工程意义的结果,而非内部计算变量。 +7. 是否补充参数边界、端口契约、结果元数据和最小仿真的自动测试。 diff --git a/PythonModels/components/orifice.py b/PythonModels/components/orifice.py index bff20cd..28238a5 100644 --- a/PythonModels/components/orifice.py +++ b/PythonModels/components/orifice.py @@ -5,22 +5,44 @@ from math import sqrt from PythonModels.core.base import AlgebraicComponent from PythonModels.core.equations import EquationResidual -from PythonModels.core.ports import PortState +from PythonModels.core.metadata import ParameterDefinition +from PythonModels.core.ports import PortDefinition class Orifice(AlgebraicComponent): """Python port of ModelicaModels.Myorifice.""" - def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None: + MODEL_TYPE = "orifice" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "K", + 1e-5, + label="流量系数", + quantity="flow_coefficient", + unit="kg/(s*Pa^0.5)", + minimum=0.0, + ), + ParameterDefinition( + "opening", + 1.0, + label="开度", + minimum=0.0, + maximum=1.0, + ), + ) + RESULT_VARIABLES = () + + def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None: super().__init__(name=name) + self.set_parameter_values({"K": K, "opening": opening}) self.opening = opening self.K = K - self.port_a = self.register_port( - PortState.pneumatic("port_a", nominal_role="inlet") - ) - self.port_b = self.register_port( - PortState.pneumatic("port_b", nominal_role="outlet") - ) + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") @property def K_eff(self) -> float: diff --git a/PythonModels/components/pipe.py b/PythonModels/components/pipe.py index 36cca46..43d0173 100644 --- a/PythonModels/components/pipe.py +++ b/PythonModels/components/pipe.py @@ -2,16 +2,71 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import DynamicComponent +from PythonModels.core.base import ThermodynamicVolumeComponent from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortState +from PythonModels.core.ports import PortDefinition from PythonModels.core.state import VolumeState -class Pipe(DynamicComponent): +class Pipe(ThermodynamicVolumeComponent): """Python port of ModelicaModels.Mypipe.""" + MODEL_TYPE = "pipe" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "length", + 5.0, + label="长度", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "diameter", + 0.02, + label="直径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "lambda_darcy", + 0.02, + label="摩阻系数", + minimum=0.0, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + def __init__( self, name: str, @@ -23,6 +78,15 @@ class Pipe(DynamicComponent): T0: float = 300.0, ) -> None: super().__init__(name=name) + self.set_parameter_values( + { + "length": L, + "diameter": D, + "lambda_darcy": lambda_darcy, + "p0": p0, + "T0": T0, + } + ) self.medium = medium self.L = L self.D = D @@ -32,12 +96,8 @@ class Pipe(DynamicComponent): m0 = p0 * self.V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) self.state = VolumeState(m=m0, U=U0) - self.port_a = self.register_port( - PortState.pneumatic("port_a", nominal_role="inlet") - ) - self.port_b = self.register_port( - PortState.pneumatic("port_b", nominal_role="outlet") - ) + self.port_a = self.register_declared_port("port_a") + self.port_b = self.register_declared_port("port_b") def get_state_vector(self) -> list[float]: return self.state.as_vector() diff --git a/PythonModels/components/resistive_pipe.py b/PythonModels/components/resistive_pipe.py index cce4fe3..2a5723d 100644 --- a/PythonModels/components/resistive_pipe.py +++ b/PythonModels/components/resistive_pipe.py @@ -5,13 +5,65 @@ from math import pi from PythonModels.core.base import AlgebraicComponent from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ParameterDefinition from PythonModels.core.medium import IdealGasMedium -from PythonModels.core.ports import PortState +from PythonModels.core.ports import PortDefinition class ResistivePipe(AlgebraicComponent): """Quasi-steady Darcy resistance used by topology-driven simulation.""" + MODEL_TYPE = "pipe" + PORTS = ( + PortDefinition.pneumatic("port_a", nominal_role="inlet"), + PortDefinition.pneumatic("port_b", nominal_role="outlet"), + ) + PARAMETERS = ( + ParameterDefinition( + "length", + 5.0, + label="长度", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "diameter", + 0.02, + label="直径", + quantity="length", + unit="m", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "lambda_darcy", + 0.02, + label="摩阻系数", + minimum=0.0, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = () + def __init__( self, name: str, @@ -23,6 +75,15 @@ class ResistivePipe(AlgebraicComponent): T0: float = 300.0, ) -> None: super().__init__(name=name) + self.set_parameter_values( + { + "length": L, + "diameter": D, + "lambda_darcy": lambda_darcy, + "p0": p0, + "T0": T0, + } + ) self.medium = medium self.L = L self.D = D @@ -32,15 +93,13 @@ class ResistivePipe(AlgebraicComponent): self.area = pi * D * D / 4.0 initial_h = medium.specific_enthalpy(T0) - self.port_a = PortState.pneumatic("port_a", nominal_role="inlet") + self.port_a = self.register_declared_port("port_a") self.port_a.p = p0 self.port_a.h_outflow = initial_h - self.register_port(self.port_a) - self.port_b = PortState.pneumatic("port_b", nominal_role="outlet") + self.port_b = self.register_declared_port("port_b") self.port_b.p = p0 self.port_b.h_outflow = initial_h - self.register_port(self.port_b) def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float: average_pressure = max(0.5 * (p_a + p_b), 1.0) diff --git a/PythonModels/components/tank.py b/PythonModels/components/tank.py index 6fd4712..02b3a9e 100644 --- a/PythonModels/components/tank.py +++ b/PythonModels/components/tank.py @@ -2,16 +2,53 @@ from __future__ import annotations from collections.abc import Mapping -from PythonModels.core.base import DynamicComponent +from PythonModels.core.base import ThermodynamicVolumeComponent from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ( + ParameterDefinition, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties -from PythonModels.core.ports import PortState +from PythonModels.core.ports import PortDefinition from PythonModels.core.state import VolumeState -class Tank(DynamicComponent): +class Tank(ThermodynamicVolumeComponent): """Python port of ModelicaModels.Mytank.""" + MODEL_TYPE = "tank" + PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),) + PARAMETERS = ( + ParameterDefinition( + "volume", + 0.1, + label="容积", + quantity="volume", + unit="m3", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "p0", + 1e5, + label="初始压力", + quantity="pressure", + unit="Pa", + minimum=0.0, + minimum_exclusive=True, + ), + ParameterDefinition( + "T0", + 300.0, + label="初始温度", + quantity="temperature", + unit="K", + minimum=0.0, + minimum_exclusive=True, + ), + ) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + def __init__( self, name: str, @@ -21,14 +58,13 @@ class Tank(DynamicComponent): T0: float = 300.0, ) -> None: super().__init__(name=name) + self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) self.medium = medium self.V = V m0 = p0 * V / (medium.R_gas * T0) U0 = m0 * medium.specific_internal_energy(T0) self.state = VolumeState(m=m0, U=U0) - self.port_a = self.register_port( - PortState.pneumatic("port_a", nominal_role="inlet") - ) + self.port_a = self.register_declared_port("port_a") def get_state_vector(self) -> list[float]: return self.state.as_vector() diff --git a/PythonModels/components/tee.py b/PythonModels/components/tee.py index 5c84522..0c8c9a9 100644 --- a/PythonModels/components/tee.py +++ b/PythonModels/components/tee.py @@ -4,23 +4,27 @@ from collections.abc import Mapping from PythonModels.core.base import AlgebraicComponent from PythonModels.core.equations import EquationResidual -from PythonModels.core.ports import PortState +from PythonModels.core.ports import PortDefinition class Tee(AlgebraicComponent): """Python port of ModelicaModels.Mytee.""" + MODEL_TYPE = "tee" + PORTS = ( + PortDefinition.pneumatic("port_in", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"), + PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"), + ) + PARAMETERS = () + RESULT_VARIABLES = () + def __init__(self, name: str) -> None: super().__init__(name=name) - self.port_in = self.register_port( - PortState.pneumatic("port_in", nominal_role="bidirectional") - ) - self.port_out1 = self.register_port( - PortState.pneumatic("port_out1", nominal_role="bidirectional") - ) - self.port_out2 = self.register_port( - PortState.pneumatic("port_out2", nominal_role="bidirectional") - ) + self.set_parameter_values({}) + self.port_in = self.register_declared_port("port_in") + self.port_out1 = self.register_declared_port("port_out1") + self.port_out2 = self.register_declared_port("port_out2") def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: return ( diff --git a/PythonModels/core/base.py b/PythonModels/core/base.py index ca6710c..4209ed2 100644 --- a/PythonModels/core/base.py +++ b/PythonModels/core/base.py @@ -2,17 +2,29 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import Any +from typing import Any, ClassVar from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ( + ParameterDefinition, + ResultVariableDefinition, + ResultVariableMetadata, + THERMODYNAMIC_VOLUME_RESULT_VARIABLES, +) from PythonModels.core.ports import PortDefinition, PortState class Component(ABC): + MODEL_TYPE: ClassVar[str | None] = None + PORTS: ClassVar[tuple[PortDefinition, ...]] = () + PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = () + RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = () + def __init__(self, name: str) -> None: self.name = name - self.model_type = self.__class__.__name__.lower() + self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower() self._ports: dict[str, PortState] = {} + self._parameter_values: dict[str, float] = {} @property def ports(self) -> dict[str, PortState]: @@ -35,12 +47,133 @@ class Component(ABC): self._ports[definition.name] = port return port + def register_declared_port(self, name: str) -> PortState: + try: + definition = next(item for item in self.PORTS if item.name == name) + except StopIteration as exc: + raise ValueError( + f"Component model {self.model_type} does not declare port {name}." + ) from exc + return self.register_port(PortState(definition=definition)) + + def set_parameter_values(self, values: Mapping[str, float]) -> None: + definitions = {definition.name: definition for definition in self.PARAMETERS} + unknown = sorted(set(values) - set(definitions)) + if unknown: + raise ValueError( + f"Component {self.name} contains unsupported parameters: " + + ", ".join(unknown) + + "." + ) + missing = sorted(set(definitions) - set(values)) + if missing: + raise ValueError( + f"Component {self.name} is missing parameters: " + + ", ".join(missing) + + "." + ) + + resolved: dict[str, float] = {} + for name, definition in definitions.items(): + value = float(values[name]) + message = definition.validation_message(value) + if message is not None: + raise ValueError( + f"Parameter '{name}' on component '{self.name}' {message}." + ) + resolved[name] = value + self._parameter_values = resolved + + @property + def parameter_values(self) -> dict[str, float]: + return dict(self._parameter_values) + def get_port(self, name: str) -> PortState: try: return self._ports[name] except KeyError as exc: raise ValueError(f"Component {self.name} has no port named {name}.") from exc + def component_result_values(self) -> Mapping[str, float]: + return {} + + def result_values(self) -> dict[str, float]: + component_values = dict(self.component_result_values()) + declared = {definition.name: definition for definition in self.RESULT_VARIABLES} + unknown = sorted(set(component_values) - set(declared)) + if unknown: + raise ValueError( + f"Component {self.name} returned undeclared result variables: " + + ", ".join(unknown) + + "." + ) + + values: dict[str, float] = {} + for name, definition in declared.items(): + if not definition.visible: + continue + if name not in component_values: + raise ValueError( + f"Component {self.name} did not provide declared result variable {name}." + ) + values[name] = float(component_values[name]) + + for port_definition in self.port_definitions: + port = self.get_port(port_definition.name) + for variable in port_definition.variables: + if not variable.result_visible: + continue + values[f"{port_definition.name}.{variable.name}"] = float( + getattr(port, variable.name) + ) + return values + + def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: + metadata = [ + ResultVariableMetadata( + key=f"{self.name}.{definition.name}", + component_id=self.name, + component_type=self.model_type, + scope="component", + name=definition.name, + label=definition.label, + quantity=definition.quantity, + unit=definition.unit, + category=definition.category, + order=definition.order, + ) + for definition in self.RESULT_VARIABLES + if definition.visible + ] + for port_definition in self.port_definitions: + for variable in port_definition.variables: + if not variable.result_visible: + continue + metadata.append( + ResultVariableMetadata( + key=f"{self.name}.{port_definition.name}.{variable.name}", + component_id=self.name, + component_type=self.model_type, + scope="port", + port_name=port_definition.name, + name=variable.name, + label=variable.label or variable.name, + quantity=variable.quantity or variable.name, + unit=variable.unit, + category=variable.role, + order=variable.order, + ) + ) + return tuple(metadata) + + def parameter_interface_dicts(self) -> list[dict[str, object]]: + return [ + definition.as_interface_dict( + value=self._parameter_values.get(definition.name) + ) + for definition in self.PARAMETERS + ] + def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: """Return algebraic residuals after the network assigns port states.""" @@ -97,5 +230,28 @@ class DynamicComponent(Component): raise NotImplementedError +class ThermodynamicVolumeComponent(DynamicComponent): + """Two-state gas volume exposing the shared thermodynamic result contract.""" + + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + + def component_result_values(self) -> Mapping[str, float]: + state = self.get_state_vector() + if len(state) < 2: + raise ValueError( + f"Thermodynamic component {self.name} must expose mass and energy states." + ) + properties = self.refresh_thermodynamic_ports() + return { + "m": float(state[0]), + "U": float(state[1]), + "p": float(properties.p), + "T": float(properties.T), + "rho": float(properties.rho), + "u": float(properties.u), + "h": float(properties.h), + } + + class AlgebraicComponent(Component): """Stateless element described by algebraic constraints only.""" diff --git a/PythonModels/core/metadata.py b/PythonModels/core/metadata.py new file mode 100644 index 0000000..7c5add2 --- /dev/null +++ b/PythonModels/core/metadata.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Literal + + +ResultVariableScope = Literal["component", "port"] + + +@dataclass(frozen=True) +class ParameterDefinition: + """User-configurable model input expressed in the backend SI contract.""" + + name: str + default: float + label: str = "" + quantity: str = "dimensionless" + unit: str = "" + minimum: float | None = None + maximum: float | None = None + minimum_exclusive: bool = False + + def validation_message(self, value: float) -> str | None: + if not isfinite(value): + return "must be finite" + if self.minimum is not None: + if self.minimum_exclusive and value <= self.minimum: + return f"must be greater than {self.minimum:g}" + if not self.minimum_exclusive and value < self.minimum: + return f"must be at least {self.minimum:g}" + if self.maximum is not None and value > self.maximum: + return f"must be at most {self.maximum:g}" + return None + + def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]: + payload: dict[str, object] = { + "name": self.name, + "label": self.label or self.name, + "quantity": self.quantity, + "unit": self.unit, + "default": self.default, + "minimumExclusive": self.minimum_exclusive, + } + if self.minimum is not None: + payload["minimum"] = self.minimum + if self.maximum is not None: + payload["maximum"] = self.maximum + if value is not None: + payload["value"] = value + return payload + + +@dataclass(frozen=True) +class ResultVariableDefinition: + """Component-relative declaration of a user-visible simulation result.""" + + name: str + label: str + quantity: str + unit: str = "" + category: str = "derived" + order: int = 0 + visible: bool = True + + +@dataclass(frozen=True) +class ResultVariableMetadata: + """A result declaration bound to one concrete component instance.""" + + key: str + component_id: str + component_type: str + scope: ResultVariableScope + name: str + label: str + quantity: str + unit: str + category: str + order: int + port_name: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "key": self.key, + "componentId": self.component_id, + "componentType": self.component_type, + "scope": self.scope, + "portName": self.port_name, + "name": self.name, + "label": self.label, + "quantity": self.quantity, + "unit": self.unit, + "category": self.category, + "order": self.order, + } + + +THERMODYNAMIC_VOLUME_RESULT_VARIABLES = ( + ResultVariableDefinition( + name="m", + label="质量", + quantity="mass", + unit="kg", + category="state", + order=10, + ), + ResultVariableDefinition( + name="U", + label="内能", + quantity="internal_energy", + unit="J", + category="state", + order=20, + ), + ResultVariableDefinition( + name="p", + label="压力", + quantity="pressure", + unit="Pa", + category="thermodynamic", + order=30, + ), + ResultVariableDefinition( + name="T", + label="温度", + quantity="temperature", + unit="K", + category="thermodynamic", + order=40, + ), + ResultVariableDefinition( + name="rho", + label="密度", + quantity="density", + unit="kg/m³", + category="thermodynamic", + order=50, + ), + ResultVariableDefinition( + name="u", + label="比内能", + quantity="specific_internal_energy", + unit="J/kg", + category="thermodynamic", + order=60, + ), + ResultVariableDefinition( + name="h", + label="比焓", + quantity="specific_enthalpy", + unit="J/kg", + category="thermodynamic", + order=70, + ), +) diff --git a/PythonModels/core/network.py b/PythonModels/core/network.py index c1a275a..8e66525 100644 --- a/PythonModels/core/network.py +++ b/PythonModels/core/network.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from PythonModels.core.base import Component, DynamicComponent from PythonModels.core.equations import EquationResidual +from PythonModels.core.metadata import ResultVariableMetadata from PythonModels.core.ports import PortState @@ -257,6 +258,13 @@ class SimulationNetwork: if cursor != len(values): raise ValueError("State vector length does not match dynamic components.") + def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: + return tuple( + variable + for component in self.components.values() + for variable in component.result_variable_metadata() + ) + def summary(self) -> str: lines = [f"Network: {self.name}", "Components:"] for name, component in self.components.items(): @@ -281,10 +289,15 @@ class SimulationNetwork: { "id": component.name, "type": component.model_type, + "parameters": component.parameter_interface_dicts(), "ports": [ definition.as_interface_dict() for definition in component.port_definitions ], + "resultVariables": [ + variable.as_dict() + for variable in component.result_variable_metadata() + ], } for component in self.components.values() ], diff --git a/PythonModels/core/ports.py b/PythonModels/core/ports.py index 6ee87ff..401fe52 100644 --- a/PythonModels/core/ports.py +++ b/PythonModels/core/ports.py @@ -16,12 +16,22 @@ class PortVariableDefinition: name: str role: VariableRole connection_rule: ConnectionRule + label: str = field(default="", compare=False) + quantity: str = field(default="", compare=False) + unit: str = field(default="", compare=False) + result_visible: bool = field(default=True, compare=False) + order: int = field(default=0, compare=False) - def as_interface_dict(self) -> dict[str, str]: + def as_interface_dict(self) -> dict[str, object]: return { "name": self.name, "role": self.role, "connectionRule": self.connection_rule, + "label": self.label or self.name, + "quantity": self.quantity or self.name, + "unit": self.unit, + "resultVisible": self.result_visible, + "order": self.order, } @@ -50,9 +60,33 @@ class PortDefinition: nominal_role=nominal_role, positive_flow_direction="intoComponent", variables=( - PortVariableDefinition("p", "effort", "equal"), - PortVariableDefinition("m_flow", "flow", "sumToZero"), - PortVariableDefinition("h_outflow", "stream", "streamMix"), + PortVariableDefinition( + "p", + "effort", + "equal", + label="压力", + quantity="pressure", + unit="Pa", + order=10, + ), + PortVariableDefinition( + "m_flow", + "flow", + "sumToZero", + label="质量流量", + quantity="mass_flow", + unit="kg/s", + order=20, + ), + PortVariableDefinition( + "h_outflow", + "stream", + "streamMix", + label="流出比焓", + quantity="specific_enthalpy", + unit="J/kg", + order=30, + ), ), ) diff --git a/PythonModels/core/solver.py b/PythonModels/core/solver.py index 8533fcb..086a5df 100644 --- a/PythonModels/core/solver.py +++ b/PythonModels/core/solver.py @@ -1,7 +1,16 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable +from typing import Callable, Literal + + +CancellationCheck = Callable[[], bool] +AcceptedStepCallback = Callable[[float], None] +IntegrationStatus = Literal["completed", "cancelled", "failed"] + + +class _IntegrationCancelled(Exception): + pass @dataclass(frozen=True) @@ -20,17 +29,34 @@ class ODESolution: y: list[list[float]] success: bool message: str + status: IntegrationStatus = "completed" + error: Exception | None = None def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]: return [x + scale * y for x, y in zip(a, b)] +def _append_solution_sample( + times: list[float], + states: list[list[float]], + time: float, + state: list[float], +) -> None: + if times and time <= times[-1] + 1e-12: + return + times.append(float(time)) + for index, value in enumerate(state): + states[index].append(float(value)) + + def _runge_kutta_4( rhs: Callable[[float, list[float]], list[float]], initial_state: list[float], config: SolveIVPConfig, t_eval: list[float] | None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, ) -> ODESolution: if t_eval is None: point_count = max( @@ -44,29 +70,189 @@ def _runge_kutta_4( states = [[value] for value in state] times = [float(t_eval[0])] current_time = float(t_eval[0]) + status: IntegrationStatus = "completed" + message = "Integrated with built-in RK4 fallback because SciPy is unavailable." + error: Exception | None = None - 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 + 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) - times.append(float(target_time)) - for index, value in enumerate(state): - states[index].append(value) + _append_solution_sample(times, states, target_time, state) + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + _append_solution_sample(times, states, current_time, state) + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + _append_solution_sample(times, states, current_time, state) return ODESolution( t=times, y=states, - success=True, - message="Integrated with built-in RK4 fallback because SciPy is unavailable.", + success=status == "completed", + message=message, + status=status, + error=error, + ) + + +def _integrate_scipy_stepwise( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + cancel_check: CancellationCheck, + accepted_step_callback: AcceptedStepCallback | None, +) -> ODESolution: + import numpy as np + from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau + + solver_types = { + "BDF": BDF, + "DOP853": DOP853, + "LSODA": LSODA, + "RK23": RK23, + "RK45": RK45, + "Radau": Radau, + } + solver_type = solver_types.get(config.method) + if solver_type is None: + raise ValueError(f"Unsupported integration method: {config.method}") + + times = [float(config.t_start)] + states = [[float(value)] for value in initial_state] + last_accepted_time = float(config.t_start) + last_accepted_state = [float(value) for value in initial_state] + sample_times = list(t_eval or []) + sample_index = 0 + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= config.t_start + 1e-12 + ): + sample_index += 1 + + def cancellable_rhs(time, state): + if cancel_check(): + raise _IntegrationCancelled + return rhs(float(time), [float(value) for value in state]) + + if cancel_check(): + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + + try: + solver = solver_type( + cancellable_rhs, + config.t_start, + np.asarray(initial_state, dtype=float), + config.t_stop, + rtol=config.rtol, + atol=config.atol, + max_step=config.max_step, + ) + except _IntegrationCancelled: + return ODESolution( + t=times, + y=states, + success=False, + message="Simulation was stopped before integration started.", + status="cancelled", + ) + except Exception as exc: + return ODESolution( + t=times, + y=states, + success=False, + message=str(exc), + status="failed", + error=exc, + ) + + status: IntegrationStatus = "completed" + message = "The solver successfully reached the end of the integration interval." + error: Exception | None = None + + while solver.status == "running": + if cancel_check(): + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + try: + step_message = solver.step() + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + break + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + break + + if solver.status == "failed": + status = "failed" + message = str(step_message or "Integration step failed.") + break + + last_accepted_time = float(solver.t) + last_accepted_state = [float(value) for value in solver.y] + if sample_times: + dense_output = solver.dense_output() + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= last_accepted_time + 1e-12 + ): + sample_time = float(sample_times[sample_index]) + sample_state = [float(value) for value in dense_output(sample_time)] + _append_solution_sample(times, states, sample_time, sample_state) + sample_index += 1 + else: + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + if accepted_step_callback is not None: + accepted_step_callback(last_accepted_time) + + if status != "completed": + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, ) @@ -75,6 +261,8 @@ def integrate_ode( initial_state: list[float], config: SolveIVPConfig, t_eval: list[float] | None = None, + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, ): """Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback.""" @@ -89,7 +277,24 @@ def integrate_ode( try: from scipy.integrate import solve_ivp except ImportError: - return _runge_kutta_4(rhs, initial_state, config, t_eval) + return _runge_kutta_4( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) + + if cancel_check is not None: + return _integrate_scipy_stepwise( + rhs, + initial_state, + config, + t_eval, + cancel_check, + accepted_step_callback, + ) return solve_ivp( fun=rhs, diff --git a/PythonModels/registry.py b/PythonModels/registry.py index 3950af0..b43ef06 100644 --- a/PythonModels/registry.py +++ b/PythonModels/registry.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from math import isfinite from PythonModels.components.cylinder import Cylinder from PythonModels.components.orifice import Orifice @@ -10,29 +9,12 @@ from PythonModels.components.resistive_pipe import ResistivePipe from PythonModels.components.tank import Tank from PythonModels.components.tee import Tee from PythonModels.core.base import Component +from PythonModels.core.metadata import ParameterDefinition from PythonModels.core.medium import IdealGasMedium from PythonModels.core.ports import PortDefinition -@dataclass(frozen=True) -class ParameterSpec: - name: str - default: float - minimum: float | None = None - maximum: float | None = None - minimum_exclusive: bool = False - - def validation_message(self, value: float) -> str | None: - if not isfinite(value): - return "must be finite" - if self.minimum is not None: - if self.minimum_exclusive and value <= self.minimum: - return f"must be greater than {self.minimum:g}" - if not self.minimum_exclusive and value < self.minimum: - return f"must be at least {self.minimum:g}" - if self.maximum is not None and value > self.maximum: - return f"must be at most {self.maximum:g}" - return None +ParameterSpec = ParameterDefinition ComponentFactory = Callable[ @@ -45,11 +27,11 @@ ComponentFactory = Callable[ class ComponentModelSpec: model_type: str ports: tuple[PortDefinition, ...] - parameters: tuple[ParameterSpec, ...] + parameters: tuple[ParameterDefinition, ...] factory: ComponentFactory @property - def parameter_by_name(self) -> dict[str, ParameterSpec]: + def parameter_by_name(self) -> dict[str, ParameterDefinition]: return {parameter.name: parameter for parameter in self.parameters} def create( @@ -77,6 +59,15 @@ class ComponentModelSpec: ) component = self.factory(name, medium, resolved) component.model_type = self.model_type + if component.port_definitions != self.ports: + raise ValueError( + f"Component implementation {self.model_type} does not match " + "its declared ports." + ) + if component.parameter_values != resolved: + raise ValueError( + f"Component implementation {self.model_type} did not preserve its parameters." + ) return component @@ -142,60 +133,33 @@ def _tee_factory( COMPONENT_MODEL_REGISTRY: dict[str, ComponentModelSpec] = { "cylinder": ComponentModelSpec( - model_type="cylinder", - ports=(PortDefinition.pneumatic("port_b", nominal_role="outlet"),), - parameters=( - ParameterSpec("volume", 0.01, minimum=0.0, minimum_exclusive=True), - ParameterSpec("p0", 35e6, minimum=0.0, minimum_exclusive=True), - ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True), - ), + model_type=Cylinder.MODEL_TYPE, + ports=Cylinder.PORTS, + parameters=Cylinder.PARAMETERS, factory=_cylinder_factory, ), "tank": ComponentModelSpec( - model_type="tank", - ports=(PortDefinition.pneumatic("port_a", nominal_role="inlet"),), - parameters=( - ParameterSpec("volume", 0.1, minimum=0.0, minimum_exclusive=True), - ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True), - ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True), - ), + model_type=Tank.MODEL_TYPE, + ports=Tank.PORTS, + parameters=Tank.PARAMETERS, factory=_tank_factory, ), "pipe": ComponentModelSpec( - model_type="pipe", - ports=( - PortDefinition.pneumatic("port_a", nominal_role="inlet"), - PortDefinition.pneumatic("port_b", nominal_role="outlet"), - ), - parameters=( - ParameterSpec("length", 5.0, minimum=0.0, minimum_exclusive=True), - ParameterSpec("diameter", 0.02, minimum=0.0, minimum_exclusive=True), - ParameterSpec("lambda_darcy", 0.02, minimum=0.0), - ParameterSpec("p0", 1e5, minimum=0.0, minimum_exclusive=True), - ParameterSpec("T0", 300.0, minimum=0.0, minimum_exclusive=True), - ), + model_type=ResistivePipe.MODEL_TYPE, + ports=ResistivePipe.PORTS, + parameters=ResistivePipe.PARAMETERS, factory=_pipe_factory, ), "orifice": ComponentModelSpec( - model_type="orifice", - ports=( - PortDefinition.pneumatic("port_a", nominal_role="inlet"), - PortDefinition.pneumatic("port_b", nominal_role="outlet"), - ), - parameters=( - ParameterSpec("K", 1e-5, minimum=0.0), - ParameterSpec("opening", 1.0, minimum=0.0, maximum=1.0), - ), + model_type=Orifice.MODEL_TYPE, + ports=Orifice.PORTS, + parameters=Orifice.PARAMETERS, factory=_orifice_factory, ), "tee": ComponentModelSpec( - model_type="tee", - ports=( - PortDefinition.pneumatic("port_in", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"), - ), - parameters=(), + model_type=Tee.MODEL_TYPE, + ports=Tee.PORTS, + parameters=Tee.PARAMETERS, factory=_tee_factory, ), } diff --git a/PythonModels/systems/generic.py b/PythonModels/systems/generic.py index 44fac46..160baf4 100644 --- a/PythonModels/systems/generic.py +++ b/PythonModels/systems/generic.py @@ -1,15 +1,23 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from math import floor, isfinite +from typing import Literal from PythonModels.core.algebraic import PressureFlowSolver from PythonModels.core.base import DynamicComponent +from PythonModels.core.metadata import ResultVariableMetadata from PythonModels.core.network import Endpoint, SimulationNetwork -from PythonModels.core.solver import SolveIVPConfig, integrate_ode +from PythonModels.core.solver import ODESolution, SolveIVPConfig, integrate_ode from PythonModels.core.stream import StreamResolver +SimulationProgressCallback = Callable[[float, str], None] +SimulationCancellationCheck = Callable[[], bool] +SimulationRunStatus = Literal["completed", "cancelled", "failed"] + + @dataclass(frozen=True) class SimulationPreparationIssue: code: str @@ -28,7 +36,11 @@ class SimulationPreparationError(ValueError): @dataclass(frozen=True) class GenericSimulationResult: success: bool + status: SimulationRunStatus message: str + simulated_until: float + requested_stop_time: float + variables: tuple[ResultVariableMetadata, ...] series: dict[str, list[float]] final: dict[str, float] diagnostics: dict[str, object] @@ -36,7 +48,12 @@ class GenericSimulationResult: def as_dict(self) -> dict[str, object]: return { "success": self.success, + "status": self.status, + "partial": self.status != "completed", "message": self.message, + "simulatedUntil": self.simulated_until, + "requestedStopTime": self.requested_stop_time, + "variables": [variable.as_dict() for variable in self.variables], "series": self.series, "final": self.final, "diagnostics": self.diagnostics, @@ -276,51 +293,121 @@ class GenericFluidSystem: return derivatives def _append_current_state(self, series: dict[str, list[float]]) -> None: - for component in self.dynamic_components: - properties = component.refresh_thermodynamic_ports() - state = component.get_state_vector() - if len(state) >= 2: - series.setdefault(f"{component.name}.m", []).append(float(state[0])) - series.setdefault(f"{component.name}.U", []).append(float(state[1])) - for name in ("p", "T", "rho", "u", "h"): - if hasattr(properties, name): - series.setdefault(f"{component.name}.{name}", []).append( - float(getattr(properties, name)) - ) - for component in self.network.components.values(): - for port_name, port in component.ports.items(): - prefix = f"{component.name}.{port_name}" - series.setdefault(f"{prefix}.p", []).append(float(port.p)) - series.setdefault(f"{prefix}.m_flow", []).append(float(port.m_flow)) - series.setdefault(f"{prefix}.h_outflow", []).append( - float(port.h_outflow) - ) + for relative_key, value in component.result_values().items(): + series.setdefault( + f"{component.name}.{relative_key}", [] + ).append(value) def simulate( self, config: SolveIVPConfig, *, sample_step: float, + progress_callback: SimulationProgressCallback | None = None, + cancel_check: SimulationCancellationCheck | None = None, ) -> GenericSimulationResult: + last_reported_progress = -1.0 + last_reported_phase = "" + + def report_progress( + progress: float, + phase: str, + *, + force: bool = False, + ) -> None: + nonlocal last_reported_phase, last_reported_progress + if progress_callback is None: + return + bounded_progress = min(1.0, max(0.0, progress)) + if ( + force + or phase != last_reported_phase + or bounded_progress - last_reported_progress >= 0.0025 + ): + last_reported_phase = phase + last_reported_progress = max( + last_reported_progress, + bounded_progress, + ) + progress_callback(last_reported_progress, phase) + + report_progress(0.0, "initializing", force=True) t_eval = simulation_sample_times(config, sample_step) initial_state = self.consistent_initial_state_vector() + report_progress(0.0, "integrating", force=True) + duration = config.t_stop - config.t_start + furthest_solver_time = config.t_start + + def report_solver_time(time: float) -> None: + nonlocal furthest_solver_time + furthest_solver_time = max(furthest_solver_time, float(time)) + time_fraction = ( + (furthest_solver_time - config.t_start) / duration + if duration > 0.0 + else 1.0 + ) + report_progress(time_fraction, "integrating") + + def monitored_rhs(time: float, state_vector: list[float]) -> list[float]: + if cancel_check is None: + report_solver_time(time) + return self.rhs(time, state_vector) + solution = integrate_ode( - rhs=self.rhs, + rhs=monitored_rhs, initial_state=initial_state, config=config, t_eval=t_eval, + cancel_check=cancel_check, + accepted_step_callback=( + report_solver_time if cancel_check is not None else None + ), ) + if isinstance(solution, ODESolution): + run_status: SimulationRunStatus = solution.status + integration_error = solution.error + else: + run_status = "completed" if bool(solution.success) else "failed" + integration_error = None + result_message = str(solution.message) + postprocess_progress = ( + 1.0 + if run_status == "completed" + else max(0.0, last_reported_progress) + ) + report_progress(postprocess_progress, "postprocessing", force=True) times = [float(value) for value in solution.t] - series: dict[str, list[float]] = {"time": times} + series: dict[str, list[float]] = {"time": []} + postprocessing_error: Exception | None = None for time_index in range(len(times)): + if ( + run_status == "completed" + and cancel_check is not None + and cancel_check() + ): + run_status = "cancelled" + result_message = "Simulation was stopped while preparing partial results." + break state = [ float(solution.y[state_index][time_index]) for state_index in range(len(solution.y)) ] - self.apply_state_vector(state) - self._close_current_state() - self._append_current_state(series) + try: + self.apply_state_vector(state) + self._close_current_state() + self._append_current_state(series) + series["time"].append(times[time_index]) + except Exception as exc: + run_status = "failed" + result_message = str(exc) + postprocessing_error = exc + break + if len(series["time"]) < 2: + if postprocessing_error is not None: + raise postprocessing_error + if integration_error is not None: + raise integration_error final = { key: values[-1] @@ -347,11 +434,29 @@ class GenericFluidSystem: ), }, "stateCount": len(initial_state), - "sampleCount": len(times), + "sampleCount": len(series["time"]), } + variables = tuple( + variable + for variable in self.network.result_variable_metadata() + if variable.key in series + ) + report_progress( + 1.0 if run_status == "completed" else max(0.0, last_reported_progress), + "complete" if run_status == "completed" else run_status, + force=True, + ) return GenericSimulationResult( - success=bool(solution.success), - message=str(solution.message), + success=run_status == "completed" and bool(solution.success), + status=run_status, + message=result_message, + simulated_until=( + float(series["time"][-1]) + if series["time"] + else float(config.t_start) + ), + requested_stop_time=float(config.t_stop), + variables=variables, series=series, final=final, diagnostics=diagnostics, diff --git a/README.md b/README.md index 63b983b..20a476f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ReactFlow 系统建模与 PythonModels 仿真应用。 - `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。 - `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 PythonModels 组件网络。 - `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。 +- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。 气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。 diff --git a/app/main.py b/app/main.py index 3ef9374..a63cc55 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,24 @@ from __future__ import annotations +from collections.abc import Callable, Iterator +import csv +from dataclasses import dataclass from datetime import datetime, timezone +import io import json +from math import isfinite from pathlib import Path +import queue +import re +import threading +from time import monotonic from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import quote +from uuid import uuid4 from xml.etree import ElementTree as ET from fastapi import FastAPI, HTTPException, Request, Response -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from pydantic import BaseModel, Field from app.system_xml import ( @@ -27,6 +38,47 @@ PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" SYSTEM_XML_SCHEMA_VERSION = "2" SYSTEM_XML_UNIT_SYSTEM = "SI" +SimulationProgressEmitter = Callable[ + [int, str, str, float | None, float | None], + None, +] +SIMULATION_PHASE_MESSAGES = { + "initializing": "正在初始化状态与方程", + "integrating": "正在进行时间积分与压力流量求解", + "postprocessing": "正在整理采样结果", + "cancelled": "正在整理已终止仿真的部分结果", + "failed": "正在整理异常终止前的部分结果", + "complete": "正在汇总仿真结果", +} +SIMULATION_STREAM_HEARTBEAT_SECONDS = 5.0 +SIMULATION_TASK_RETENTION_SECONDS = 600.0 + +SimulationCancelReason = Literal["user", "stalled"] +SimulationTaskStatus = Literal[ + "queued", + "running", + "cancelling", + "completed", + "stopped", + "stalled", + "failed", +] + + +@dataclass +class SimulationTaskRecord: + simulation_id: str + cancel_event: threading.Event + status: SimulationTaskStatus = "queued" + cancel_reason: SimulationCancelReason | None = None + result: dict[str, object] | None = None + error: dict[str, object] | None = None + updated_at: float = 0.0 + + +SIMULATION_TASKS: dict[str, SimulationTaskRecord] = {} +SIMULATION_TASKS_LOCK = threading.Lock() + class ReactFlowPosition(BaseModel): x: float = 0.0 @@ -88,6 +140,30 @@ class ReactFlowProjectPayload(BaseModel): simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig) +class SimulationResultVariablePayload(BaseModel): + key: str + componentId: str + componentType: str + scope: Literal["component", "port"] + portName: str | None = None + name: str + label: str + quantity: str + unit: str = "" + category: str = "derived" + order: int = 0 + + +class SimulationResultCsvPayload(BaseModel): + projectName: str = "simulation" + variables: list[SimulationResultVariablePayload] = Field(default_factory=list) + series: dict[str, list[float]] = Field(default_factory=dict) + + +class SimulationCancellationPayload(BaseModel): + reason: SimulationCancelReason = "user" + + def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]: if hasattr(model, "model_dump"): return model.model_dump(mode="json") @@ -164,6 +240,92 @@ def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response: return Response(content=xml, media_type="application/xml") +@app.post("/api/simulation-results/csv") +def export_simulation_results_csv( + payload: SimulationResultCsvPayload, +) -> Response: + csv_text = build_simulation_results_csv(payload) + filename = simulation_results_csv_filename(payload.projectName) + ascii_filename = re.sub(r"[^A-Za-z0-9._-]+", "_", filename).strip("._") + if not ascii_filename: + ascii_filename = "simulation-results.csv" + disposition = ( + f'attachment; filename="{ascii_filename}"; ' + f"filename*=UTF-8''{quote(filename)}" + ) + return Response( + content="\ufeff" + csv_text, + media_type="text/csv", + headers={"Content-Disposition": disposition}, + ) + + +def build_simulation_results_csv(payload: SimulationResultCsvPayload) -> str: + times = payload.series.get("time") + if not times: + raise HTTPException( + status_code=422, + detail="Simulation results must contain a non-empty time series.", + ) + + variable_keys = [variable.key for variable in payload.variables] + if not variable_keys: + raise HTTPException( + status_code=422, + detail="Simulation results do not contain exportable variables.", + ) + if len(variable_keys) != len(set(variable_keys)): + raise HTTPException( + status_code=422, + detail="Simulation result metadata contains duplicate variable keys.", + ) + + expected_keys = {"time", *variable_keys} + missing_keys = sorted(expected_keys - set(payload.series)) + unknown_keys = sorted(set(payload.series) - expected_keys) + if missing_keys or unknown_keys: + details = [] + if missing_keys: + details.append("missing " + ", ".join(missing_keys)) + if unknown_keys: + details.append("unmapped " + ", ".join(unknown_keys)) + raise HTTPException( + status_code=422, + detail="Simulation result columns do not match metadata: " + + "; ".join(details) + + ".", + ) + + row_count = len(times) + for key in ("time", *variable_keys): + values = payload.series[key] + if len(values) != row_count: + raise HTTPException( + status_code=422, + detail=f"Simulation result column '{key}' has an inconsistent length.", + ) + if not all(isfinite(value) for value in values): + raise HTTPException( + status_code=422, + detail=f"Simulation result column '{key}' contains non-finite values.", + ) + + output = io.StringIO(newline="") + writer = csv.writer(output, lineterminator="\r\n") + writer.writerow(["time", *variable_keys]) + for index in range(row_count): + writer.writerow( + [payload.series["time"][index]] + + [payload.series[key][index] for key in variable_keys] + ) + return output.getvalue() + + +def simulation_results_csv_filename(project_name: str) -> str: + stem = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", project_name).strip(" .") + return f"{stem[:80] or 'simulation'}-results.csv" + + @app.get("/api/reactflow/projects") def list_reactflow_projects() -> dict[str, object]: PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True) @@ -258,8 +420,158 @@ async def compile_system_xml_model(request: Request) -> dict[str, object]: } +TERMINAL_SIMULATION_TASK_STATUSES = {"completed", "stopped", "stalled", "failed"} + + +def _register_simulation_task(simulation_id: str) -> SimulationTaskRecord: + normalized_id = simulation_id.strip() + if ( + not normalized_id + or len(normalized_id) > 128 + or re.fullmatch(r"[A-Za-z0-9._-]+", normalized_id) is None + ): + raise HTTPException(status_code=422, detail="Invalid simulation task id.") + + now = monotonic() + with SIMULATION_TASKS_LOCK: + expired_ids = [ + task_id + for task_id, task in SIMULATION_TASKS.items() + if task.status in TERMINAL_SIMULATION_TASK_STATUSES + and now - task.updated_at > SIMULATION_TASK_RETENTION_SECONDS + ] + for task_id in expired_ids: + del SIMULATION_TASKS[task_id] + if normalized_id in SIMULATION_TASKS: + raise HTTPException( + status_code=409, + detail="A simulation task with this id already exists.", + ) + task = SimulationTaskRecord( + simulation_id=normalized_id, + cancel_event=threading.Event(), + updated_at=now, + ) + SIMULATION_TASKS[normalized_id] = task + return task + + +def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + return { + "simulationId": task.simulation_id, + "status": task.status, + "cancelReason": task.cancel_reason, + "result": task.result, + "error": task.error, + } + + +def _request_simulation_task_cancel( + task: SimulationTaskRecord, + reason: SimulationCancelReason, +) -> bool: + with SIMULATION_TASKS_LOCK: + if task.status in TERMINAL_SIMULATION_TASK_STATUSES: + return False + if task.cancel_reason is None: + task.cancel_reason = reason + task.status = "cancelling" + task.updated_at = monotonic() + task.cancel_event.set() + return True + + +def _mark_simulation_task_running(task: SimulationTaskRecord) -> None: + with SIMULATION_TASKS_LOCK: + if task.status == "queued": + task.status = "running" + task.updated_at = monotonic() + + +def _mark_simulation_task_result( + task: SimulationTaskRecord, + result: dict[str, object], +) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + result_status = result.get("status") + if result_status == "cancelled": + public_status: SimulationTaskStatus = ( + "stalled" if task.cancel_reason == "stalled" else "stopped" + ) + elif result_status == "completed" and bool(result.get("success")): + public_status = "completed" + else: + public_status = "failed" + result["status"] = public_status + result["partial"] = public_status != "completed" + task.status = public_status + task.result = result + task.updated_at = monotonic() + return result + + +def _mark_simulation_task_error( + task: SimulationTaskRecord, + error: dict[str, object], +) -> None: + with SIMULATION_TASKS_LOCK: + task.status = "failed" + task.error = error + task.updated_at = monotonic() + + @app.post("/api/system-xml/simulate") async def simulate_system_xml(request: Request) -> dict[str, object]: + return run_system_xml_simulation(await request.body()) + + +@app.post("/api/system-xml/simulate-stream") +async def simulate_system_xml_stream(request: Request) -> StreamingResponse: + simulation_id = request.headers.get("x-simulation-id") or uuid4().hex + task = _register_simulation_task(simulation_id) + return StreamingResponse( + simulation_event_stream(await request.body(), task=task), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Simulation-Id": task.simulation_id, + }, + ) + + +@app.post("/api/system-xml/simulations/{simulation_id}/cancel") +def cancel_system_xml_simulation( + simulation_id: str, + payload: SimulationCancellationPayload, +) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + task = SIMULATION_TASKS.get(simulation_id) + if task is None: + raise HTTPException(status_code=404, detail="Simulation task was not found.") + accepted = _request_simulation_task_cancel(task, payload.reason) + return { + "simulationId": simulation_id, + "accepted": accepted, + "status": task.status, + } + + +@app.get("/api/system-xml/simulations/{simulation_id}") +def get_system_xml_simulation(simulation_id: str) -> dict[str, object]: + with SIMULATION_TASKS_LOCK: + task = SIMULATION_TASKS.get(simulation_id) + if task is None: + raise HTTPException(status_code=404, detail="Simulation task was not found.") + return _simulation_task_snapshot(task) + + +def run_system_xml_simulation( + xml_bytes: bytes, + progress_callback: SimulationProgressEmitter | None = None, + cancel_check: Callable[[], bool] | None = None, +) -> dict[str, object]: from PythonModels.core.algebraic import AlgebraicSolveError from PythonModels.core.solver import SolveIVPConfig from PythonModels.core.stream import StreamSolveError @@ -268,9 +580,42 @@ async def simulate_system_xml(request: Request) -> dict[str, object]: SimulationPreparationError, ) - report = validate_system_xml_document(await request.body()) + def emit( + progress: int, + phase: str, + message: str, + simulated_time: float | None = None, + total_time: float | None = None, + ) -> None: + if progress_callback is not None: + progress_callback( + progress, + phase, + message, + simulated_time, + total_time, + ) + + emit(0, "validation", "正在校验 System XML") + report = validate_system_xml_document(xml_bytes) document = _validated_xml_document_or_422(report) + emit(0, "compilation", "正在编译组件与连接关系") project, network = _compile_xml_document_or_422(document) + emit(0, "initialization", "模型编译完成,正在准备求解器") + + def report_system_progress(progress: float, phase: str) -> None: + bounded_progress = min(1.0, max(0.0, progress)) + simulated_time = project.simulation.t_start + bounded_progress * ( + project.simulation.t_stop - project.simulation.t_start + ) + emit( + round(100 * bounded_progress), + phase, + SIMULATION_PHASE_MESSAGES.get(phase, "正在运行仿真"), + simulated_time, + project.simulation.t_stop, + ) + try: system = GenericFluidSystem(network) result = system.simulate( @@ -281,6 +626,8 @@ async def simulate_system_xml(request: Request) -> dict[str, object]: max_step=project.simulation.max_step, ), sample_step=project.simulation.step, + progress_callback=report_system_progress, + cancel_check=cancel_check, ) except SimulationPreparationError as exc: raise HTTPException( @@ -353,6 +700,142 @@ async def simulate_system_xml(request: Request) -> dict[str, object]: } +def simulation_event_stream( + xml_bytes: bytes, + *, + task: SimulationTaskRecord | None = None, +) -> Iterator[str]: + events: queue.Queue[dict[str, object] | object] = queue.Queue() + finished = object() + latest_progress = 0 + latest_phase = "queued" + latest_message = "正在等待仿真任务启动" + latest_simulated_time: float | None = None + latest_total_time: float | None = None + + def emit_progress( + progress: int, + phase: str, + message: str, + simulated_time: float | None = None, + total_time: float | None = None, + ) -> None: + nonlocal latest_message, latest_phase, latest_progress + nonlocal latest_simulated_time, latest_total_time + latest_progress = max(latest_progress, min(100, max(0, progress))) + latest_phase = phase + latest_message = message + if simulated_time is not None and isfinite(simulated_time): + latest_simulated_time = simulated_time + if total_time is not None and isfinite(total_time): + latest_total_time = total_time + event: dict[str, object] = { + "event": "progress", + "progress": latest_progress, + "phase": phase, + "message": message, + } + if latest_simulated_time is not None: + event["simulatedTime"] = latest_simulated_time + if latest_total_time is not None: + event["totalTime"] = latest_total_time + events.put(event) + + def worker() -> None: + if task is not None: + _mark_simulation_task_running(task) + try: + result = run_system_xml_simulation( + xml_bytes, + emit_progress, + task.cancel_event.is_set if task is not None else None, + ) + if task is not None: + result = _mark_simulation_task_result(task, result) + result_status = str(result.get("status", "completed")) + result_messages = { + "completed": "仿真完成", + "stopped": "仿真已由用户终止,已保留部分结果", + "stalled": "仿真因进度连接异常而终止,已保留部分结果", + "failed": "仿真异常终止,已保留可用的部分结果", + } + events.put( + { + "event": "result", + "progress": 100 if result_status == "completed" else latest_progress, + "phase": result_status, + "message": result_messages.get(result_status, "仿真任务结束"), + "simulatedTime": result.get("simulatedUntil"), + "totalTime": result.get("requestedStopTime"), + "result": result, + } + ) + except HTTPException as exc: + detail = exc.detail + message = ( + str(detail.get("message", "仿真失败")) + if isinstance(detail, dict) + else str(detail) + ) + error_event = { + "event": "error", + "progress": latest_progress, + "phase": "failed", + "status": exc.status_code, + "message": message, + "detail": detail, + } + if task is not None: + _mark_simulation_task_error(task, error_event) + events.put(error_event) + except Exception as exc: # pragma: no cover - last-resort stream guard + error_event = { + "event": "error", + "progress": latest_progress, + "phase": "failed", + "status": 500, + "message": "仿真服务发生未预期错误。", + "detail": str(exc), + } + if task is not None: + _mark_simulation_task_error(task, error_event) + events.put(error_event) + finally: + events.put(finished) + + threading.Thread( + target=worker, + name="system-simulation", + daemon=True, + ).start() + + try: + while True: + try: + event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS) + except queue.Empty: + yield json.dumps( + { + "event": "progress", + "progress": latest_progress, + "phase": latest_phase, + "message": latest_message, + "heartbeat": True, + "simulatedTime": latest_simulated_time, + "totalTime": latest_total_time, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + "\n" + continue + if event is finished: + break + yield json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" + finally: + if task is not None: + _request_simulation_task_cancel(task, "stalled") + + def _validated_xml_document_or_422( report: SystemXmlValidationReport, ) -> SystemXmlDocument: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 32c31f2..5328c80 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@xyflow/react": "^12.11.2", + "html2canvas": "^1.4.1", "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" @@ -854,12 +855,30 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1015,6 +1034,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1436,6 +1468,15 @@ "node": ">=0.10.0" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1505,6 +1546,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3ac9f93..08bb269 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@xyflow/react": "^12.11.2", + "html2canvas": "^1.4.1", "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f04c1c..a1496af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,24 +1,54 @@ -import { ChangeEvent, DragEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ChangeEvent, + DragEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import { + BellRing, + ChevronDown, + ChevronUp, ClipboardPaste, Copy, + Download, + FileCode2, + FileDown, + FileUp, FlipHorizontal2, + FolderOpen, + Grid2X2, + Grip, ListChecks, Magnet, Maximize2, + Minimize2, + Move, + Play, Redo2, RotateCw, + Save, + Square, + SquareTerminal, Trash2, Undo2, type LucideIcon, } from "lucide-react"; import { Background, + BackgroundVariant, ConnectionLineType, ConnectionMode, Controls, Handle, - MiniMap, Panel, Position, ReactFlow, @@ -40,6 +70,7 @@ import { import { EmptySimulationResultsView, SimulationResultsView, + type ResultVariableMetadata, type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { @@ -139,7 +170,12 @@ type ReactFlowProjectPayload = { type SimulationResult = { success: boolean; + status: "completed" | "stopped" | "stalled" | "failed"; + partial: boolean; message: string; + simulatedUntil: number; + requestedStopTime: number; + variables: ResultVariableMetadata[]; final: Record; series: Record; diagnostics: { @@ -176,14 +212,135 @@ type EditorClipboard = { edges: SimulationEdge[]; }; +type ModelingPaneLayout = { + paletteWidth: number; + propertiesWidth: number; +}; + +type ModelingPaneResizeKind = "palette-width" | "properties-width"; + +type ModelingPaneResizeState = { + kind: ModelingPaneResizeKind; + pointerId: number; + startX: number; + startWidth: number; +}; + +type CanvasGridVisibility = { + lines: boolean; + dots: boolean; +}; + const AUTOSAVE_KEY = "system-simulation-flow:autosave"; const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; +const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions"; +const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout"; +const CANVAS_GRID_VISIBILITY_KEY = "system-simulation-flow:canvas-grid-visibility"; +const CONSOLE_VIEWPORT_MARGIN = 8; +const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000; +const SIMULATION_SOLVER_STALL_TIMEOUT_MS = 60_000; const GRID_SIZE = 18; +const MODELING_PANE_SPLITTER_SIZE = 7; +const MIN_PALETTE_WIDTH = 180; +const MAX_PALETTE_WIDTH = 420; +const MIN_PROPERTIES_WIDTH = 280; +const MAX_PROPERTIES_WIDTH = 560; +const MIN_MODELING_CANVAS_WIDTH = 420; +const MODELING_PANE_KEYBOARD_STEP = 20; const HISTORY_LIMIT = 50; const NODE_FRAME_WIDTH = 132; const NODE_FRAME_HEIGHT = 84; +function defaultModelingPaneLayout(): ModelingPaneLayout { + return { + paletteWidth: 230, + propertiesWidth: 330, + }; +} + +function loadModelingPaneLayout(): ModelingPaneLayout { + const fallback = defaultModelingPaneLayout(); + try { + const parsed = JSON.parse( + localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null", + ) as Partial | null; + if ( + !parsed || + !Number.isFinite(parsed.paletteWidth) || + !Number.isFinite(parsed.propertiesWidth) + ) { + return fallback; + } + return { + paletteWidth: Math.min( + MAX_PALETTE_WIDTH, + Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), + ), + propertiesWidth: Math.min( + MAX_PROPERTIES_WIDTH, + Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), + ), + }; + } catch { + return fallback; + } +} + +function clampModelingPaneLayout( + layout: ModelingPaneLayout, + workbenchWidth: number, +): ModelingPaneLayout { + let paletteWidth = Math.min( + MAX_PALETTE_WIDTH, + Math.max(MIN_PALETTE_WIDTH, layout.paletteWidth), + ); + let propertiesWidth = Math.min( + MAX_PROPERTIES_WIDTH, + Math.max(MIN_PROPERTIES_WIDTH, layout.propertiesWidth), + ); + const availableSideWidth = Math.max( + MIN_PALETTE_WIDTH + MIN_PROPERTIES_WIDTH, + workbenchWidth - + MIN_MODELING_CANVAS_WIDTH - + MODELING_PANE_SPLITTER_SIZE * 2, + ); + let overflow = paletteWidth + propertiesWidth - availableSideWidth; + if (overflow > 0) { + const propertiesReduction = Math.min( + overflow, + propertiesWidth - MIN_PROPERTIES_WIDTH, + ); + propertiesWidth -= propertiesReduction; + overflow -= propertiesReduction; + } + if (overflow > 0) { + paletteWidth -= Math.min( + overflow, + paletteWidth - MIN_PALETTE_WIDTH, + ); + } + return { paletteWidth, propertiesWidth }; +} + +function loadCanvasGridVisibility(): CanvasGridVisibility { + try { + const parsed = JSON.parse( + localStorage.getItem(CANVAS_GRID_VISIBILITY_KEY) ?? "null", + ) as Partial | null; + if ( + parsed && + typeof parsed.lines === "boolean" && + typeof parsed.dots === "boolean" + ) { + return { lines: parsed.lines, dots: parsed.dots }; + } + } catch { + // Use the default view when browser storage is unavailable or invalid. + } + return { lines: false, dots: true }; +} + const identityUnit = (value: number) => value; const scaleUnit = (factor: number) => ({ toBase: (value: number) => value * factor, @@ -225,6 +382,88 @@ const defaultSimulationConfig: SimulationConfig = { max_step: 0.005, method: "BDF", }; + +type SimulationConsoleLevel = "info" | "success" | "warning" | "error"; +type SimulationConsoleMode = "minimized" | "normal" | "maximized"; + +type ConsolePosition = { + x: number; + y: number; +}; + +type ConsolePlacement = ConsolePosition & { + horizontalAnchor: "left" | "right"; + verticalAnchor: "top" | "bottom"; +}; + +type ConsoleDragState = { + pointerId: number; + offsetX: number; + offsetY: number; + width: number; + height: number; +}; + +type SimulationConsoleEntry = { + id: number; + timestamp: string; + level: SimulationConsoleLevel; + message: string; +}; + +type SimulationProgressState = { + state: "idle" | "running" | "success" | "warning" | "error"; + percent: number; + message: string; + startTime: number; + simulatedTime: number; + totalTime: number; +}; + +type SimulationStreamIssue = { + code?: string; + message?: string; +}; + +type SimulationCancelReason = "user" | "stalled"; + +type SimulationTaskResponse = { + simulationId: string; + status: "queued" | "running" | "cancelling" | "completed" | "stopped" | "stalled" | "failed"; + cancelReason: SimulationCancelReason | null; + result: SimulationResult | null; + error: SimulationStreamEvent | null; +}; + +type SimulationStreamEvent = + | { + event: "progress"; + progress: number; + phase: string; + message: string; + heartbeat?: boolean; + simulatedTime?: number; + totalTime?: number; + } + | { + event: "result"; + progress: number; + phase: "complete"; + message: string; + simulatedTime?: number; + totalTime?: number; + result: SimulationResult; + } + | { + event: "error"; + progress: number; + phase: "failed"; + status: number; + message: string; + detail?: unknown; + simulatedTime?: number; + totalTime?: number; + }; const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; function physicalPort( @@ -562,6 +801,7 @@ type ToolbarButtonProps = { disabled?: boolean; pressed?: boolean; accent?: boolean; + primary?: boolean; }; function ToolbarButton({ @@ -571,13 +811,14 @@ function ToolbarButton({ disabled = false, pressed, accent = false, + primary = false, }: ToolbarButtonProps) { return ( + + + + + ); + } + + return ( + + ); +} + +type DeferredNumberInputProps = { + ariaLabel?: string; + invalid?: boolean; + value: number | string; + onCommit: (value: string) => void; + onEditEnd?: () => void; + onEditStart?: () => void; +}; + +function DeferredNumberInput({ + ariaLabel, + invalid = false, + value, + onCommit, + onEditEnd, + onEditStart, +}: DeferredNumberInputProps) { + const committedText = String(value); + const [draftText, setDraftText] = useState(committedText); + const committedTextRef = useRef(committedText); + const draftTextRef = useRef(committedText); + const editingRef = useRef(false); + const cancelCommitRef = useRef(false); + + useEffect(() => { + committedTextRef.current = committedText; + if (!editingRef.current) { + draftTextRef.current = committedText; + setDraftText(committedText); + } + }, [committedText]); + + return ( + { + editingRef.current = false; + if (cancelCommitRef.current) { + cancelCommitRef.current = false; + } else { + onCommit(draftTextRef.current); + } + window.setTimeout(() => onEditEnd?.(), 0); + }} + onChange={(event) => { + draftTextRef.current = event.target.value; + setDraftText(event.target.value); + }} + onFocus={() => { + editingRef.current = true; + cancelCommitRef.current = false; + onEditStart?.(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelCommitRef.current = true; + draftTextRef.current = committedTextRef.current; + setDraftText(committedTextRef.current); + event.currentTarget.blur(); + } + }} + spellCheck={false} + type="text" + value={draftText} + /> + ); +} + function FlowWorkbench() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -603,11 +1585,31 @@ function FlowWorkbench() { const [simulationResultSignature, setSimulationResultSignature] = useState(""); const [resultSnapshot, setResultSnapshot] = useState(loadStoredResultSnapshot); + const [hasUnreadResults, setHasUnreadResults] = useState(false); const [activeView, setActiveView] = useState( workspaceViewFromLocation, ); const [busy, setBusy] = useState(false); + const [consoleEntries, setConsoleEntries] = useState([]); + const [consoleMode, setConsoleMode] = useState("minimized"); + const [consoleUnreadCount, setConsoleUnreadCount] = useState(0); + const [simulationStopPending, setSimulationStopPending] = useState(false); + const [simulationProgress, setSimulationProgress] = useState({ + state: "idle", + percent: 0, + message: "等待仿真任务", + startTime: defaultSimulationConfig.t_start, + simulatedTime: defaultSimulationConfig.t_start, + totalTime: defaultSimulationConfig.t_stop, + }); const [snapEnabled, setSnapEnabled] = useState(true); + const [gridVisibility, setGridVisibility] = useState( + loadCanvasGridVisibility, + ); + const [modelingPaneLayout, setModelingPaneLayout] = + useState(loadModelingPaneLayout); + const [activeModelingPaneResize, setActiveModelingPaneResize] = + useState(null); const [validationIssues, setValidationIssues] = useState([]); const [validatedSignature, setValidatedSignature] = useState(""); const [saveState, setSaveState] = useState("正在检查本地草稿..."); @@ -619,6 +1621,8 @@ function FlowWorkbench() { }); const reactFlow = useReactFlow(); const fileInputRef = useRef(null); + const workbenchRef = useRef(null); + const modelingPaneResizeRef = useRef(null); const nodesRef = useRef(nodes); const edgesRef = useRef(edges); const projectNameRef = useRef(projectName); @@ -627,20 +1631,241 @@ function FlowWorkbench() { const futureRef = useRef([]); const clipboardRef = useRef(null); const pasteCountRef = useRef(0); + const consoleModeRef = useRef("minimized"); + const consoleEntryIdRef = useRef(0); + const activeSimulationIdRef = useRef(null); nodesRef.current = nodes; edgesRef.current = edges; projectNameRef.current = projectName; simulationConfigRef.current = simulationConfig; + consoleModeRef.current = consoleMode; + + useEffect(() => { + try { + localStorage.setItem( + MODELING_PANE_LAYOUT_KEY, + JSON.stringify(modelingPaneLayout), + ); + } catch { + // Resizing remains available when browser storage is unavailable. + } + }, [modelingPaneLayout]); + + useEffect(() => { + try { + localStorage.setItem( + CANVAS_GRID_VISIBILITY_KEY, + JSON.stringify(gridVisibility), + ); + } catch { + // Grid controls remain available when browser storage is unavailable. + } + }, [gridVisibility]); + + useLayoutEffect(() => { + if (activeView !== "modeling") { + return; + } + const workbench = workbenchRef.current; + if (!workbench || typeof ResizeObserver === "undefined") { + return; + } + const clampToWorkbench = (width: number) => { + setModelingPaneLayout((current) => { + const next = clampModelingPaneLayout(current, width); + return next.paletteWidth === current.paletteWidth && + next.propertiesWidth === current.propertiesWidth + ? current + : next; + }); + }; + clampToWorkbench(workbench.clientWidth); + const observer = new ResizeObserver(([entry]) => { + if (entry) { + clampToWorkbench(entry.contentRect.width); + } + }); + observer.observe(workbench); + return () => observer.disconnect(); + }, [activeView]); + + const setModelingPaneSize = ( + kind: ModelingPaneResizeKind, + requestedWidth: number, + ) => { + const workbenchWidth = workbenchRef.current?.getBoundingClientRect().width; + if (!workbenchWidth) { + return; + } + setModelingPaneLayout((current) => { + if (kind === "palette-width") { + const maximum = Math.max( + MIN_PALETTE_WIDTH, + Math.min( + MAX_PALETTE_WIDTH, + workbenchWidth - + current.propertiesWidth - + MIN_MODELING_CANVAS_WIDTH - + MODELING_PANE_SPLITTER_SIZE * 2, + ), + ); + return { + ...current, + paletteWidth: Math.min( + maximum, + Math.max(MIN_PALETTE_WIDTH, requestedWidth), + ), + }; + } + const maximum = Math.max( + MIN_PROPERTIES_WIDTH, + Math.min( + MAX_PROPERTIES_WIDTH, + workbenchWidth - + current.paletteWidth - + MIN_MODELING_CANVAS_WIDTH - + MODELING_PANE_SPLITTER_SIZE * 2, + ), + ); + return { + ...current, + propertiesWidth: Math.min( + maximum, + Math.max(MIN_PROPERTIES_WIDTH, requestedWidth), + ), + }; + }); + }; + + const startModelingPaneResize = ( + event: ReactPointerEvent, + kind: ModelingPaneResizeKind, + ) => { + event.preventDefault(); + modelingPaneResizeRef.current = { + kind, + pointerId: event.pointerId, + startX: event.clientX, + startWidth: + kind === "palette-width" + ? modelingPaneLayout.paletteWidth + : modelingPaneLayout.propertiesWidth, + }; + event.currentTarget.setPointerCapture(event.pointerId); + setActiveModelingPaneResize(kind); + }; + + const moveModelingPaneResize = ( + event: ReactPointerEvent, + ) => { + const resize = modelingPaneResizeRef.current; + if (!resize || resize.pointerId !== event.pointerId) { + return; + } + event.preventDefault(); + const delta = event.clientX - resize.startX; + setModelingPaneSize( + resize.kind, + resize.startWidth + + (resize.kind === "palette-width" ? delta : -delta), + ); + }; + + const stopModelingPaneResize = ( + event: ReactPointerEvent, + ) => { + if (modelingPaneResizeRef.current?.pointerId !== event.pointerId) { + return; + } + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + modelingPaneResizeRef.current = null; + setActiveModelingPaneResize(null); + }; + + const resizeModelingPaneWithKeyboard = ( + event: ReactKeyboardEvent, + kind: ModelingPaneResizeKind, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + event.preventDefault(); + const direction = event.key === "ArrowRight" ? 1 : -1; + const currentWidth = + kind === "palette-width" + ? modelingPaneLayout.paletteWidth + : modelingPaneLayout.propertiesWidth; + setModelingPaneSize( + kind, + currentWidth + + MODELING_PANE_KEYBOARD_STEP * + (kind === "palette-width" ? direction : -direction), + ); + }; + + const resetModelingPaneSize = (kind: ModelingPaneResizeKind) => { + const defaults = defaultModelingPaneLayout(); + setModelingPaneSize( + kind, + kind === "palette-width" + ? defaults.paletteWidth + : defaults.propertiesWidth, + ); + }; + + const changeConsoleMode = useCallback((mode: SimulationConsoleMode) => { + consoleModeRef.current = mode; + setConsoleMode(mode); + if (mode !== "minimized") { + setConsoleUnreadCount(0); + } + }, []); + + const appendConsoleEntry = useCallback( + (level: SimulationConsoleLevel, message: string) => { + const entry: SimulationConsoleEntry = { + id: ++consoleEntryIdRef.current, + timestamp: new Date().toLocaleTimeString("zh-CN", { hour12: false }), + level, + message, + }; + setConsoleEntries((currentEntries) => [ + ...currentEntries.slice(-399), + entry, + ]); + if (consoleModeRef.current === "minimized") { + setConsoleUnreadCount((count) => count + 1); + } + }, + [], + ); const changeWorkspaceView = useCallback((view: WorkspaceView) => { setActiveView(view); + if (view === "results") { + setHasUnreadResults(false); + } const nextHash = view === "results" ? "#/results" : "#/modeling"; if (window.location.hash !== nextHash) { window.history.pushState({ workspaceView: view }, "", nextHash); } }, []); + const loadSimulationResultsSnapshot = useCallback( + (snapshot: SimulationResultsSnapshot) => { + setResultSnapshot(snapshot); + storeResultSnapshot(snapshot); + setSimulationResult(null); + setSimulationResultSignature(""); + setStatusText(`已加载结果文件:${snapshot.project.name}`); + changeWorkspaceView("results"); + }, + [changeWorkspaceView], + ); + const nodeTypes = useMemo( () => ({ simulationComponent: SimulationComponentNode }), [], @@ -699,8 +1924,19 @@ function FlowWorkbench() { [nodes, validationSeverityByNode], ); const renderedEdges = useMemo( - () => edges.map((edge) => ({ ...edge, type: "step" })), - [edges], + () => + edges.map((edge) => { + const selected = edge.selected || edge.id === selectedEdgeId; + const className = [edge.className, selected ? "editor-edge-selected" : ""] + .filter(Boolean) + .join(" "); + return { + ...edge, + className: className || undefined, + type: "step", + }; + }), + [edges, selectedEdgeId], ); const updateHistoryAvailability = useCallback(() => { @@ -1199,36 +2435,249 @@ function FlowWorkbench() { }); }; - const runSimulation = async () => { - const issues = checkModel(); - if (issues.some((issue) => issue.severity === "error")) { + const publishSimulationResult = ( + result: SimulationResult, + project: ReactFlowProjectPayload, + ) => { + const resultStatus = result.status ?? (result.success ? "completed" : "failed"); + const sampleCount = Math.min( + result.diagnostics.sampleCount, + result.series.time?.length ?? 0, + ); + const hasUsableSamples = sampleCount >= 2; + const startTime = project.simulation.t_start; + const finalSimulatedTime = Number.isFinite(result.simulatedUntil) + ? result.simulatedUntil + : startTime; + const finalTotalTime = Number.isFinite(result.requestedStopTime) + ? result.requestedStopTime + : project.simulation.t_stop; + const finalPercent = + resultStatus === "completed" + ? 100 + : simulationTimePercent( + startTime, + finalSimulatedTime, + finalTotalTime, + ); + const simulatedUntil = Number.isFinite(result.simulatedUntil) + ? formatNumber(result.simulatedUntil) + : "未知时间"; + const statusMessages = { + completed: `仿真完成,已生成新的结果,共 ${sampleCount} 个采样点`, + stopped: `仿真已手动终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, + stalled: `仿真因进度异常而终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, + failed: `仿真异常终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, + } as const; + const statusMessage = statusMessages[resultStatus]; + + setStatusText(statusMessage); + if (resultStatus === "completed") { + setSimulationProgress({ + state: "success", + percent: 100, + message: "仿真完成,新结果可查看", + startTime, + simulatedTime: finalTotalTime, + totalTime: finalTotalTime, + }); + appendConsoleEntry( + "success", + `${statusMessage},可在“结果”页签查看;最大方程残差 ${formatNumber(result.diagnostics.pressureFlow.maxScaledResidual)}`, + ); + } else { + setSimulationProgress((current) => ({ + ...current, + state: resultStatus === "stopped" ? "warning" : "error", + percent: finalPercent, + message: + resultStatus === "stopped" + ? "仿真已停止" + : resultStatus === "stalled" + ? "仿真因进度异常终止" + : "仿真异常终止", + startTime, + simulatedTime: finalSimulatedTime, + totalTime: finalTotalTime, + })); + appendConsoleEntry(resultStatus === "stopped" ? "warning" : "error", statusMessage); + } + + if (!hasUsableSamples) { + appendConsoleEntry("warning", "有效计算步不足,未生成部分结果页面"); return; } + + setSimulationResult(result); + setSimulationResultSignature(JSON.stringify(project)); + const nextSnapshot: SimulationResultsSnapshot = { + id: `simulation-${Date.now()}`, + createdAt: new Date().toISOString(), + project: cloneValue(project), + result: cloneValue(result), + }; + setResultSnapshot(nextSnapshot); + storeResultSnapshot(nextSnapshot); + setHasUnreadResults(true); + }; + + const stopSimulation = async () => { + const simulationId = activeSimulationIdRef.current; + if (!simulationId || simulationStopPending || simulationProgress.state !== "running") { + return; + } + + setSimulationStopPending(true); + setSimulationProgress((current) => ({ ...current, message: "正在停止仿真" })); + appendConsoleEntry("warning", "已请求停止仿真,正在等待当前积分步结束并整理部分结果"); + try { + const response = await requestSimulationCancellation(simulationId, "user"); + if (!response.accepted) { + appendConsoleEntry("info", "仿真任务已经结束,正在读取最终状态"); + } + } catch (error) { + setSimulationStopPending(false); + setSimulationProgress((current) => ({ + ...current, + message: "停止请求失败,仿真可能仍在运行", + })); + appendConsoleEntry("error", `停止仿真失败:${formatError(error)}`); + } + }; + + const runSimulation = async () => { + const issues = checkModel(); + const modelErrors = issues.filter((issue) => issue.severity === "error"); + if (modelErrors.length > 0) { + changeConsoleMode("normal"); + setSimulationProgress({ + state: "error", + percent: 0, + message: "模型检查未通过", + startTime: simulationConfigRef.current.t_start, + simulatedTime: simulationConfigRef.current.t_start, + totalTime: simulationConfigRef.current.t_stop, + }); + appendConsoleEntry("error", `模型检查未通过,共 ${modelErrors.length} 个错误`); + modelErrors.forEach((issue) => appendConsoleEntry("error", issue.message)); + return; + } + changeConsoleMode("normal"); + setSimulationProgress({ + state: "running", + percent: 0, + message: "正在提交仿真任务", + startTime: simulationConfigRef.current.t_start, + simulatedTime: simulationConfigRef.current.t_start, + totalTime: simulationConfigRef.current.t_stop, + }); + appendConsoleEntry( + "info", + `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`, + ); + issues + .filter((issue) => issue.severity === "warning") + .forEach((issue) => appendConsoleEntry("warning", issue.message)); + await runBusyAction("正在编译模型并运行仿真...", async () => { const project = buildCurrentProject(); const xml = buildSystemXml(project); + const simulationId = createSimulationId(); + activeSimulationIdRef.current = simulationId; + setSimulationStopPending(false); setXmlText(xml); - const result = await postXml( - "/api/system-xml/simulate", - xml, - ); - setSimulationResult(result); - setSimulationResultSignature(JSON.stringify(project)); - const nextSnapshot: SimulationResultsSnapshot = { - id: `simulation-${Date.now()}`, - createdAt: new Date().toISOString(), - project: cloneValue(project), - result: cloneValue(result), - }; - setResultSnapshot(nextSnapshot); - storeResultSnapshot(nextSnapshot); - setStatusText( - result.success - ? `仿真完成,共 ${result.diagnostics.sampleCount} 个采样点` - : `仿真返回:${result.message}`, - ); - if (result.success) { - changeWorkspaceView("results"); + let lastLoggedPhase = ""; + let lastLoggedBucket = -1; + try { + const result = await streamSystemSimulation( + xml, + (event) => { + if (event.event !== "progress") { + return; + } + const normalizedProgress = normalizeSimulationProgressEvent( + event, + project.simulation, + ); + const reportsSimulationTime = ![ + "queued", + "validation", + "compilation", + "initialization", + "initializing", + ].includes(event.phase); + setSimulationProgress((current) => ({ + ...current, + state: "running", + percent: normalizedProgress.percent, + message: event.message, + simulatedTime: normalizedProgress.simulatedTime, + totalTime: normalizedProgress.totalTime, + })); + const progressBucket = Math.floor(normalizedProgress.percent / 10); + if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) { + const progressDetail = reportsSimulationTime + ? ` (${normalizedProgress.simulatedTime.toFixed(2)} s / ${normalizedProgress.totalTime.toFixed(2)} s,${normalizedProgress.percent}%)` + : ""; + appendConsoleEntry("info", `${event.message}${progressDetail}`); + lastLoggedPhase = event.phase; + lastLoggedBucket = progressBucket; + } + }, + simulationId, + ); + publishSimulationResult(result, project); + } catch (error) { + let failure: unknown = error; + if ( + error instanceof SimulationStreamError && + (error.code === "STREAM_IDLE_TIMEOUT" || error.code === "SOLVER_STALLED") + ) { + setSimulationStopPending(true); + appendConsoleEntry( + "error", + error.code === "SOLVER_STALLED" + ? "求解器连续 60 秒没有接受新积分步,正在终止任务并恢复部分结果" + : "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果", + ); + try { + await requestSimulationCancellation(simulationId, "stalled"); + const task = await waitForSimulationTaskCompletion(simulationId); + if (task.result) { + publishSimulationResult(task.result, project); + return; + } + if (task.error?.event === "error") { + failure = new SimulationStreamError( + task.error.message, + [], + task.error.status, + ); + } else { + failure = new SimulationStreamError("仿真异常终止且没有可恢复的部分结果"); + } + } catch (recoveryError) { + failure = recoveryError; + } + } + + const message = formatError(failure); + setSimulationProgress((current) => ({ + ...current, + state: "error", + percent: current.percent, + message: "仿真失败", + })); + appendConsoleEntry("error", `仿真失败:${message}`); + if (failure instanceof SimulationStreamError) { + failure.issues + .filter((issue) => issue !== message) + .forEach((issue) => appendConsoleEntry("error", issue)); + } + throw failure; + } finally { + activeSimulationIdRef.current = null; + setSimulationStopPending(false); } }); }; @@ -1344,6 +2793,12 @@ function FlowWorkbench() { }; }, []); + useEffect(() => { + if (activeView === "results" && hasUnreadResults) { + setHasUnreadResults(false); + } + }, [activeView, hasUnreadResults]); + useEffect(() => { if (activeView !== "modeling") { return; @@ -1392,21 +2847,44 @@ function FlowWorkbench() { undo, ]); + const simulationConsole = ( + setConsoleEntries([])} + onModeChange={changeConsoleMode} + onStop={() => void stopSimulation()} + progress={simulationProgress} + stopPending={simulationStopPending} + unreadCount={consoleUnreadCount} + /> + ); + if (activeView === "results") { - return resultSnapshot ? ( - - ) : ( - + return ( + <> + {resultSnapshot ? ( + + ) : ( + + )} + {simulationConsole} + ); } return ( -
+ <> +
System Simulation Flow
@@ -1415,6 +2893,7 @@ function FlowWorkbench() {
@@ -1428,22 +2907,6 @@ function FlowWorkbench() { /> {saveState} - - - - - - -
-
+ {simulationConsole} + ); } @@ -2291,6 +3886,7 @@ function isSimulationResultsSnapshot( Array.isArray(snapshot.project?.nodes) && Array.isArray(snapshot.project?.edges) && Boolean(snapshot.result) && + Array.isArray(snapshot.result?.variables) && Boolean(snapshot.result?.series) && Array.isArray(snapshot.result?.series?.time) ); @@ -2495,6 +4091,238 @@ function indentXmlElement(element: Element, depth = 0) { ); } +class SimulationStreamError extends Error { + readonly issues: string[]; + readonly status?: number; + readonly code?: "STREAM_IDLE_TIMEOUT" | "SOLVER_STALLED"; + + constructor( + message: string, + issues: string[] = [], + status?: number, + code?: "STREAM_IDLE_TIMEOUT" | "SOLVER_STALLED", + ) { + super(message); + this.name = "SimulationStreamError"; + this.issues = issues; + this.status = status; + this.code = code; + } +} + +async function readSimulationStreamChunk( + reader: ReadableStreamDefaultReader, +): Promise> { + let timeoutId: number | undefined; + const inactivityTimeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject( + new SimulationStreamError( + "超过 30 秒未收到仿真进度,进度连接可能已经中断,请刷新页面后重试", + [], + undefined, + "STREAM_IDLE_TIMEOUT", + ), + ); + }, SIMULATION_STREAM_IDLE_TIMEOUT_MS); + }); + + try { + return await Promise.race([reader.read(), inactivityTimeout]); + } finally { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + } +} + +async function streamSystemSimulation( + xml: string, + onEvent: (event: SimulationStreamEvent) => void, + simulationId: string, +): Promise { + const abortController = new AbortController(); + const response = await fetch("/api/system-xml/simulate-stream", { + method: "POST", + headers: { + "Content-Type": "application/xml", + "X-Simulation-Id": simulationId, + }, + body: xml, + signal: abortController.signal, + }); + if (!response.ok) { + if (response.status === 404) { + throw new SimulationStreamError( + "当前 FastAPI 未加载流式仿真接口,请重启后端服务", + [], + response.status, + ); + } + if (response.status === 502 || response.status === 503) { + throw new SimulationStreamError( + "无法连接 FastAPI 仿真服务,请确认 8000 端口已启动", + [], + response.status, + ); + } + throw new SimulationStreamError(await readApiError(response), [], response.status); + } + if (!response.body) { + throw new SimulationStreamError("浏览器未收到仿真进度数据流"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let result: SimulationResult | null = null; + let lastSolverProgressAt = Date.now(); + + const consumeLine = (line: string) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + const parsed = JSON.parse(trimmed) as Partial; + if ( + parsed.event !== "progress" && + parsed.event !== "result" && + parsed.event !== "error" + ) { + throw new SimulationStreamError("仿真服务返回了无法识别的进度消息"); + } + const event = parsed as SimulationStreamEvent; + if (event.event === "progress") { + if ( + event.heartbeat === true && + Date.now() - lastSolverProgressAt >= SIMULATION_SOLVER_STALL_TIMEOUT_MS + ) { + throw new SimulationStreamError( + "求解器连续 60 秒没有接受新的积分步,任务可能已经卡死", + [], + undefined, + "SOLVER_STALLED", + ); + } + if (event.heartbeat !== true) { + lastSolverProgressAt = Date.now(); + } + } + onEvent(event); + if (event.event === "result") { + result = event.result; + return; + } + if (event.event === "error") { + const detail = event.detail; + const issues = + detail && typeof detail === "object" && Array.isArray((detail as { issues?: unknown }).issues) + ? ((detail as { issues: SimulationStreamIssue[] }).issues ?? []) + .map((issue) => { + if (typeof issue.message !== "string") { + return ""; + } + return typeof issue.code === "string" + ? `${issue.code}:${issue.message}` + : issue.message; + }) + .filter(Boolean) + : typeof detail === "string" && detail !== event.message + ? [detail] + : []; + throw new SimulationStreamError(event.message, issues, event.status); + } + }; + + try { + while (true) { + const { done, value } = await readSimulationStreamChunk(reader); + buffer += decoder.decode(value, { stream: !done }); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex >= 0) { + consumeLine(buffer.slice(0, newlineIndex)); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + } + if (done) { + consumeLine(buffer); + break; + } + } + } finally { + abortController.abort(); + try { + await reader.cancel(); + } catch { + // The stream may already be closed or aborted. + } + reader.releaseLock(); + } + + if (!result) { + throw new SimulationStreamError("仿真进度流已结束,但没有返回结果"); + } + return result; +} + +function createSimulationId() { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `simulation-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +async function requestSimulationCancellation( + simulationId: string, + reason: SimulationCancelReason, +) { + const response = await fetch( + `/api/system-xml/simulations/${encodeURIComponent(simulationId)}/cancel`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }, + ); + if (!response.ok) { + throw new SimulationStreamError(await readApiError(response), [], response.status); + } + return (await response.json()) as { + simulationId: string; + accepted: boolean; + status: SimulationTaskResponse["status"]; + }; +} + +async function readSimulationTask(simulationId: string) { + const response = await fetch( + `/api/system-xml/simulations/${encodeURIComponent(simulationId)}`, + ); + if (!response.ok) { + throw new SimulationStreamError(await readApiError(response), [], response.status); + } + return (await response.json()) as SimulationTaskResponse; +} + +async function waitForSimulationTaskCompletion( + simulationId: string, + timeoutMs = 30_000, +) { + const deadline = Date.now() + timeoutMs; + while (true) { + const task = await readSimulationTask(simulationId); + if (task.result || task.error || ["completed", "stopped", "stalled", "failed"].includes(task.status)) { + return task; + } + if (Date.now() >= deadline) { + throw new SimulationStreamError( + "停止请求已发送,但后端未能在 30 秒内整理出部分结果", + ); + } + await new Promise((resolve) => window.setTimeout(resolve, 500)); + } +} + async function postXml(url: string, xml: string): Promise { const response = await fetch(url, { method: "POST", diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index 1859c7d..07cfa24 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -1,15 +1,29 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { + ChangeEvent as ReactChangeEvent, DragEvent, + KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from "react"; -import { X } from "lucide-react"; +import { + ChartNoAxesCombined, + ChartSpline, + Crosshair, + FileDown, + FolderOpen, + ImageDown, + ListChecks, + Move, + Scaling, + Sheet, + Trash2, + X, +} from "lucide-react"; import { Background, ConnectionLineType, ConnectionMode, Controls, - MiniMap, ReactFlow, type Edge, type Node, @@ -66,9 +80,28 @@ type ResultSimulationConfig = { method: string; }; +export type ResultVariableMetadata = { + key: string; + componentId: string; + componentType: string; + scope: "component" | "port"; + portName: string | null; + name: string; + label: string; + quantity: string; + unit: string; + category: string; + order: number; +}; + type ResultPayload = { success: boolean; + status?: "completed" | "stopped" | "stalled" | "failed"; + partial?: boolean; message: string; + simulatedUntil?: number; + requestedStopTime?: number; + variables: ResultVariableMetadata[]; final: Record; series: Record; diagnostics: { @@ -97,42 +130,119 @@ export type SimulationResultsSnapshot = { result: ResultPayload; }; +type SimulationResultFile = { + fileType: "system-simulation-result"; + formatVersion: 1; + exportedAt: string; + metadata: { + projectName: string; + resultCreatedAt: string; + simulation: ResultSimulationConfig & { + simulatedUntil: number | null; + }; + resultStatus: NonNullable; + componentCount: number; + connectionCount: number; + sampleCount: number; + componentParameters: Array<{ + id: string; + name: string; + componentType: string; + parameters: Record; + parameterUnits: Record; + }>; + }; + snapshot: SimulationResultsSnapshot; +}; + type SimulationResultsViewProps = { snapshot: SimulationResultsSnapshot; nodeTypes: NodeTypes; onViewChange: (view: WorkspaceView) => void; + onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; }; type EmptySimulationResultsViewProps = { onViewChange: (view: WorkspaceView) => void; -}; - -type ResultVariable = { - key: string; - label: string; - unit: string; - group: "component" | "port"; + onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; }; type ChartWindow = { id: string; + kind: "single" | "multi" | "mixed"; variableKey: string; + variableKeys: string[]; + hiddenVariableKeys: string[]; + variableUnits: Record; x: number; y: number; + width: number; + height: number; + unit: string; + cursorEnabled: boolean; zIndex: number; }; +type ResultUnitOption = { + value: string; + label: string; + fromBase: (value: number) => number; +}; + type WindowDragState = { windowId: string; pointerId: number; offsetX: number; offsetY: number; + width: number; + height: number; +}; + +type WindowResizeState = { + windowId: string; + pointerId: number; + startX: number; + startY: number; + startWidth: number; + startHeight: number; + windowX: number; + windowY: number; +}; + +type ResultsPaneLayout = { + inspectorWidth: number; + systemHeight: number; +}; + +type PaneResizeKind = "inspector-width" | "system-height"; + +type PaneResizeState = { + kind: PaneResizeKind; + pointerId: number; + startX: number; + startY: number; + startSize: number; }; const RESULT_VARIABLE_MIME = "application/x-system-simulation-variable"; +const RESULT_FILE_TYPE = "system-simulation-result"; +const RESULT_FILE_FORMAT_VERSION = 1; +const RESULT_FILE_MIME = "application/vnd.system-simulation.result+json"; +const MAX_RESULT_FILE_SIZE = 250 * 1024 * 1024; const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:"; +const RESULT_PANE_LAYOUT_KEY = "system-simulation-flow:result-pane-layout"; const CHART_WINDOW_WIDTH = 420; const CHART_WINDOW_HEIGHT = 260; +const MULTI_CHART_WINDOW_WIDTH = 560; +const MULTI_CHART_WINDOW_HEIGHT = 340; +const MIN_CHART_WINDOW_WIDTH = 360; +const MIN_CHART_WINDOW_HEIGHT = 240; +const PANE_SPLITTER_SIZE = 7; +const MIN_INSPECTOR_WIDTH = 320; +const MIN_CHART_WIDTH = 440; +const MIN_SYSTEM_HEIGHT = 180; +const MIN_VARIABLE_HEIGHT = 180; +const PANE_KEYBOARD_STEP = 20; const CHART_COLORS = [ "#1d6fb8", "#c2413b", @@ -142,32 +252,93 @@ const CHART_COLORS = [ "#087f8c", "#9a4f76", ]; - -const variableDefinitions: Record = { - p: { label: "压力", unit: "Pa" }, - T: { label: "温度", unit: "K" }, - m: { label: "质量", unit: "kg" }, - U: { label: "内能", unit: "J" }, - rho: { label: "密度", unit: "kg/m³" }, - u: { label: "比内能", unit: "J/kg" }, - h: { label: "比焓", unit: "J/kg" }, - m_flow: { label: "质量流量", unit: "kg/s" }, - h_outflow: { label: "流出比焓", unit: "J/kg" }, +const PRESSURE_RESULT_UNITS = [ + scaledResultUnit("Pa", "Pa", 1), + scaledResultUnit("kPa", "kPa", 1e3), + scaledResultUnit("MPa", "MPa", 1e6), + scaledResultUnit("bar", "bar", 1e5), +]; +const SPECIFIC_ENERGY_RESULT_UNITS = [ + scaledResultUnit("J/kg", "J/kg", 1), + scaledResultUnit("kJ/kg", "kJ/kg", 1e3), + scaledResultUnit("MJ/kg", "MJ/kg", 1e6), +]; +const RESULT_UNIT_OPTIONS: Record = { + pressure: PRESSURE_RESULT_UNITS, + absolute_pressure: PRESSURE_RESULT_UNITS, + temperature: [ + scaledResultUnit("K", "K", 1), + { value: "degC", label: "°C", fromBase: (value) => value - 273.15 }, + ], + mass: [ + scaledResultUnit("kg", "kg", 1), + scaledResultUnit("g", "g", 1e-3), + scaledResultUnit("t", "t", 1e3), + ], + internal_energy: [ + scaledResultUnit("J", "J", 1), + scaledResultUnit("kJ", "kJ", 1e3), + scaledResultUnit("MJ", "MJ", 1e6), + ], + energy: [ + scaledResultUnit("J", "J", 1), + scaledResultUnit("kJ", "kJ", 1e3), + scaledResultUnit("MJ", "MJ", 1e6), + ], + specific_internal_energy: SPECIFIC_ENERGY_RESULT_UNITS, + specific_enthalpy: SPECIFIC_ENERGY_RESULT_UNITS, + density: [ + scaledResultUnit("kg/m³", "kg/m³", 1), + scaledResultUnit("g/cm³", "g/cm³", 1e3), + ], + mass_flow: [ + scaledResultUnit("kg/s", "kg/s", 1), + scaledResultUnit("g/s", "g/s", 1e-3), + scaledResultUnit("kg/min", "kg/min", 1 / 60), + scaledResultUnit("kg/h", "kg/h", 1 / 3600), + ], + volume: [ + scaledResultUnit("m3", "m³", 1), + scaledResultUnit("L", "L", 1e-3), + scaledResultUnit("mL", "mL", 1e-6), + ], + length: [ + scaledResultUnit("m", "m", 1), + scaledResultUnit("cm", "cm", 1e-2), + scaledResultUnit("mm", "mm", 1e-3), + ], }; export function SimulationResultsView({ snapshot, nodeTypes, onViewChange, + onSnapshotLoad, }: SimulationResultsViewProps) { + const resultStatus = simulationResultStatus(snapshot.result); const [selectedNodeId, setSelectedNodeId] = useState( snapshot.project.nodes[0]?.id ?? null, ); const [chartWindows, setChartWindows] = useState(() => loadChartWindows(snapshot.id), ); + const [paneLayout, setPaneLayout] = useState( + loadResultsPaneLayout, + ); + const [activePaneResize, setActivePaneResize] = + useState(null); + const [csvDownloadPending, setCsvDownloadPending] = useState(false); + const [multiCreateMenuOpen, setMultiCreateMenuOpen] = useState(false); + const [mixedCreateMenuOpen, setMixedCreateMenuOpen] = useState(false); + const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = + useState(null); + const [multiDropTargetWindowId, setMultiDropTargetWindowId] = + useState(null); + const workbenchRef = useRef(null); const workspaceRef = useRef(null); const dragStateRef = useRef(null); + const resizeStateRef = useRef(null); + const paneResizeStateRef = useRef(null); const nextWindowIdRef = useRef(nextChartWindowNumber(chartWindows)); const topZIndexRef = useRef( Math.max(1, ...chartWindows.map((window) => window.zIndex)), @@ -177,6 +348,72 @@ export function SimulationResultsView({ storeChartWindows(snapshot.id, chartWindows); }, [chartWindows, snapshot.id]); + useEffect(() => { + storeResultsPaneLayout(paneLayout); + }, [paneLayout]); + + useEffect(() => { + const clampLayoutToWorkbench = () => { + const bounds = workbenchRef.current?.getBoundingClientRect(); + if (!bounds) { + return; + } + setPaneLayout((current) => { + const next = clampResultsPaneLayout(current, bounds.width, bounds.height); + return sameResultsPaneLayout(current, next) ? current : next; + }); + }; + clampLayoutToWorkbench(); + window.addEventListener("resize", clampLayoutToWorkbench); + return () => window.removeEventListener("resize", clampLayoutToWorkbench); + }, []); + + useEffect(() => { + const workspace = workspaceRef.current; + if (!workspace || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(([entry]) => { + if (!entry) { + return; + } + const { width, height } = entry.contentRect; + setChartWindows((current) => { + let changed = false; + const next = current.map((chartWindow) => { + const size = clampChartWindowDimensions( + chartWindow.width, + chartWindow.height, + width, + height, + ); + const x = clampWindowCoordinate( + chartWindow.x, + width, + size.width, + ); + const y = clampWindowCoordinate( + chartWindow.y, + height, + size.height, + ); + const windowChanged = + x !== chartWindow.x || + y !== chartWindow.y || + size.width !== chartWindow.width || + size.height !== chartWindow.height; + changed ||= windowChanged; + return windowChanged + ? { ...chartWindow, ...size, x, y } + : chartWindow; + }); + return changed ? next : current; + }); + }); + observer.observe(workspace); + return () => observer.disconnect(); + }, []); + const selectedNode = snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null; const readOnlyNodes = useMemo[]>( @@ -206,19 +443,139 @@ export function SimulationResultsView({ () => selectedNode ? resultVariablesForNode( + snapshot.result.variables, snapshot.result.series, selectedNode.id, - selectedNode.data.ports, ) : [], - [selectedNode, snapshot.result.series], + [selectedNode, snapshot.result.series, snapshot.result.variables], ); const componentVariables = selectedVariables.filter( - (variable) => variable.group === "component", + (variable) => variable.scope === "component", ); const portVariables = selectedVariables.filter( - (variable) => variable.group === "port", + (variable) => variable.scope === "port", ); + const plottableResultVariables = useMemo( + () => + snapshot.result.variables + .filter( + (variable) => + (snapshot.result.series[variable.key]?.length ?? 0) > 0, + ) + .sort((first, second) => + first.key.localeCompare(second.key, "zh-CN"), + ), + [snapshot.result.series, snapshot.result.variables], + ); + const multiCurveUnitGroups = useMemo(() => { + const groups = new Map(); + plottableResultVariables.forEach((variable) => { + const group = groups.get(variable.unit) ?? []; + group.push(variable); + groups.set(variable.unit, group); + }); + return Array.from(groups, ([unit, variables]) => ({ unit, variables })) + .filter((group) => group.variables.length >= 2) + .sort((first, second) => + (first.unit || "无量纲").localeCompare( + second.unit || "无量纲", + "zh-CN", + ), + ); + }, [plottableResultVariables]); + + const setPaneSize = (kind: PaneResizeKind, requestedSize: number) => { + const bounds = workbenchRef.current?.getBoundingClientRect(); + if (!bounds) { + return; + } + setPaneLayout((current) => + clampResultsPaneLayout( + { + ...current, + ...(kind === "inspector-width" + ? { inspectorWidth: requestedSize } + : { systemHeight: requestedSize }), + }, + bounds.width, + bounds.height, + ), + ); + }; + + const startPaneResize = ( + event: ReactPointerEvent, + kind: PaneResizeKind, + ) => { + event.preventDefault(); + paneResizeStateRef.current = { + kind, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startSize: + kind === "inspector-width" + ? paneLayout.inspectorWidth + : paneLayout.systemHeight, + }; + event.currentTarget.setPointerCapture(event.pointerId); + setActivePaneResize(kind); + }; + + const movePaneResize = (event: ReactPointerEvent) => { + const resize = paneResizeStateRef.current; + if (!resize || resize.pointerId !== event.pointerId) { + return; + } + const delta = + resize.kind === "inspector-width" + ? event.clientX - resize.startX + : event.clientY - resize.startY; + setPaneSize(resize.kind, resize.startSize + delta); + }; + + const stopPaneResize = (event: ReactPointerEvent) => { + if (paneResizeStateRef.current?.pointerId !== event.pointerId) { + return; + } + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + paneResizeStateRef.current = null; + setActivePaneResize(null); + }; + + const resizePaneWithKeyboard = ( + event: ReactKeyboardEvent, + kind: PaneResizeKind, + ) => { + const decreaseKey = kind === "inspector-width" ? "ArrowLeft" : "ArrowUp"; + const increaseKey = kind === "inspector-width" ? "ArrowRight" : "ArrowDown"; + if (event.key !== decreaseKey && event.key !== increaseKey) { + return; + } + event.preventDefault(); + const currentSize = + kind === "inspector-width" + ? paneLayout.inspectorWidth + : paneLayout.systemHeight; + setPaneSize( + kind, + currentSize + + (event.key === increaseKey ? PANE_KEYBOARD_STEP : -PANE_KEYBOARD_STEP), + ); + }; + + const resetPaneSize = (kind: PaneResizeKind) => { + const defaults = defaultResultsPaneLayout(); + setPaneSize( + kind, + kind === "inspector-width" + ? defaults.inspectorWidth + : defaults.systemHeight, + ); + }; const focusChartWindow = (windowId: string) => { const zIndex = ++topZIndexRef.current; @@ -238,13 +595,23 @@ export function SimulationResultsView({ return; } const existing = chartWindows.find( - (window) => window.variableKey === variableKey, + (window) => + window.kind === "single" && window.variableKey === variableKey, ); if (existing) { focusChartWindow(existing.id); return; } const bounds = workspace.getBoundingClientRect(); + const size = clampChartWindowDimensions( + CHART_WINDOW_WIDTH, + CHART_WINDOW_HEIGHT, + bounds.width, + bounds.height, + ); + const variable = snapshot.result.variables.find( + (item) => item.key === variableKey, + ); const index = chartWindows.length; const fallback = { x: 18 + (index % 3) * 26, @@ -256,22 +623,240 @@ export function SimulationResultsView({ ...current, { id: `chart-${nextWindowIdRef.current++}`, + kind: "single", variableKey, + variableKeys: [variableKey], + hiddenVariableKeys: [], + variableUnits: { [variableKey]: variable?.unit ?? "" }, x: clampWindowCoordinate( position.x, bounds.width, - CHART_WINDOW_WIDTH, + size.width, ), y: clampWindowCoordinate( position.y, bounds.height, - CHART_WINDOW_HEIGHT, + size.height, ), + width: size.width, + height: size.height, + unit: variable?.unit ?? "", + cursorEnabled: false, zIndex, }, ]); }; + const addMultiChartWindow = ( + variableKey: string, + requestedPosition?: { x: number; y: number }, + ) => { + const workspace = workspaceRef.current; + const variable = snapshot.result.variables.find( + (item) => item.key === variableKey, + ); + if (!workspace || !variable) { + return; + } + + const bounds = workspace.getBoundingClientRect(); + const size = clampChartWindowDimensions( + MULTI_CHART_WINDOW_WIDTH, + MULTI_CHART_WINDOW_HEIGHT, + bounds.width, + bounds.height, + ); + const index = chartWindows.length; + const position = requestedPosition ?? { + x: 28 + (index % 3) * 28, + y: 28 + (index % 4) * 28, + }; + const id = `chart-${nextWindowIdRef.current++}`; + const zIndex = ++topZIndexRef.current; + setChartWindows((current) => [ + ...current, + { + id, + kind: "multi", + variableKey, + variableKeys: [variableKey], + hiddenVariableKeys: [], + variableUnits: { [variableKey]: variable.unit }, + x: clampWindowCoordinate(position.x, bounds.width, size.width), + y: clampWindowCoordinate(position.y, bounds.height, size.height), + width: size.width, + height: size.height, + unit: variable.unit, + cursorEnabled: false, + zIndex, + }, + ]); + setActiveMultiPickerWindowId(id); + }; + + const addMixedChartWindow = ( + variableKey: string, + requestedPosition?: { x: number; y: number }, + ) => { + const workspace = workspaceRef.current; + const variable = snapshot.result.variables.find( + (item) => item.key === variableKey, + ); + if (!workspace || !variable) { + return; + } + + const bounds = workspace.getBoundingClientRect(); + const size = clampChartWindowDimensions( + MULTI_CHART_WINDOW_WIDTH, + MULTI_CHART_WINDOW_HEIGHT, + bounds.width, + bounds.height, + ); + const index = chartWindows.length; + const position = requestedPosition ?? { + x: 38 + (index % 3) * 28, + y: 38 + (index % 4) * 28, + }; + const id = `chart-${nextWindowIdRef.current++}`; + const zIndex = ++topZIndexRef.current; + setChartWindows((current) => [ + ...current, + { + id, + kind: "mixed", + variableKey, + variableKeys: [variableKey], + hiddenVariableKeys: [], + variableUnits: { [variableKey]: variable.unit }, + x: clampWindowCoordinate(position.x, bounds.width, size.width), + y: clampWindowCoordinate(position.y, bounds.height, size.height), + width: size.width, + height: size.height, + unit: variable.unit, + cursorEnabled: false, + zIndex, + }, + ]); + setActiveMultiPickerWindowId(id); + }; + + const toggleChartCurveVisibility = ( + windowId: string, + variableKey: string, + ) => { + setChartWindows((current) => + current.map((window) => { + if (window.id !== windowId || window.kind === "single") { + return window; + } + const hiddenVariableKeys = window.hiddenVariableKeys.includes(variableKey) + ? window.hiddenVariableKeys.filter((key) => key !== variableKey) + : [...window.hiddenVariableKeys, variableKey]; + return { + ...window, + hiddenVariableKeys, + }; + }), + ); + }; + + const removeChartCurve = (windowId: string, variableKey: string) => { + const removesWindow = chartWindows.some( + (window) => + window.id === windowId && + window.kind !== "single" && + window.variableKeys.length === 1, + ); + setChartWindows((current) => + current.flatMap((window) => { + if (window.id !== windowId || window.kind === "single") { + return [window]; + } + if (window.variableKeys.length === 1) { + return []; + } + const variableKeys = window.variableKeys.filter( + (key) => key !== variableKey, + ); + return [ + { + ...window, + variableKey: variableKeys[0], + variableKeys, + hiddenVariableKeys: window.hiddenVariableKeys.filter( + (key) => key !== variableKey, + ), + variableUnits: Object.fromEntries( + Object.entries(window.variableUnits).filter( + ([key]) => key !== variableKey, + ), + ), + }, + ]; + }), + ); + if (removesWindow) { + setActiveMultiPickerWindowId(null); + } + }; + + const addDroppedVariableToMultiChart = ( + event: DragEvent, + chartWindow: ChartWindow, + ) => { + event.preventDefault(); + event.stopPropagation(); + setMultiDropTargetWindowId(null); + const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME); + const variable = snapshot.result.variables.find( + (item) => item.key === variableKey, + ); + const primaryVariable = snapshot.result.variables.find( + (item) => item.key === chartWindow.variableKey, + ); + if ( + !variableKey || + !variable || + !primaryVariable || + chartWindow.kind === "single" + ) { + return; + } + if (chartWindow.kind === "multi" && variable.unit !== primaryVariable.unit) { + window.alert( + `无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`, + ); + return; + } + setChartWindows((current) => + current.map((window) => { + if (window.id !== chartWindow.id || window.kind === "single") { + return window; + } + if (window.variableKeys.includes(variableKey)) { + return { + ...window, + hiddenVariableKeys: window.hiddenVariableKeys.filter( + (key) => key !== variableKey, + ), + }; + } + return { + ...window, + variableKeys: [...window.variableKeys, variableKey], + hiddenVariableKeys: window.hiddenVariableKeys.filter( + (key) => key !== variableKey, + ), + variableUnits: { + ...window.variableUnits, + [variableKey]: variable.unit, + }, + }; + }), + ); + }; + const handleVariableDragStart = ( event: DragEvent, variableKey: string, @@ -308,6 +893,8 @@ export function SimulationResultsView({ pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top, + width: chartWindow.width, + height: chartWindow.height, }; event.currentTarget.setPointerCapture(event.pointerId); focusChartWindow(chartWindow.id); @@ -327,12 +914,12 @@ export function SimulationResultsView({ const x = clampWindowCoordinate( event.clientX - bounds.left - dragState.offsetX, bounds.width, - CHART_WINDOW_WIDTH, + dragState.width, ); const y = clampWindowCoordinate( event.clientY - bounds.top - dragState.offsetY, bounds.height, - CHART_WINDOW_HEIGHT, + dragState.height, ); setChartWindows((current) => current.map((window) => @@ -351,10 +938,136 @@ export function SimulationResultsView({ dragStateRef.current = null; }; + const startWindowResize = ( + event: ReactPointerEvent, + chartWindow: ChartWindow, + ) => { + event.preventDefault(); + event.stopPropagation(); + resizeStateRef.current = { + windowId: chartWindow.id, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startWidth: chartWindow.width, + startHeight: chartWindow.height, + windowX: chartWindow.x, + windowY: chartWindow.y, + }; + event.currentTarget.setPointerCapture(event.pointerId); + focusChartWindow(chartWindow.id); + }; + + const resizeChartWindow = (event: ReactPointerEvent) => { + const resize = resizeStateRef.current; + const workspace = workspaceRef.current; + if (!resize || resize.pointerId !== event.pointerId || !workspace) { + return; + } + event.preventDefault(); + const bounds = workspace.getBoundingClientRect(); + const width = clampChartWindowDimension( + resize.startWidth + event.clientX - resize.startX, + bounds.width - resize.windowX - 12, + MIN_CHART_WINDOW_WIDTH, + ); + const height = clampChartWindowDimension( + resize.startHeight + event.clientY - resize.startY, + bounds.height - resize.windowY - 12, + MIN_CHART_WINDOW_HEIGHT, + ); + setChartWindows((current) => + current.map((window) => + window.id === resize.windowId ? { ...window, width, height } : window, + ), + ); + }; + + const stopWindowResize = (event: ReactPointerEvent) => { + if (resizeStateRef.current?.pointerId !== event.pointerId) { + return; + } + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + resizeStateRef.current = null; + }; + const removeChartWindow = (windowId: string) => { setChartWindows((current) => current.filter((window) => window.id !== windowId), ); + setActiveMultiPickerWindowId((current) => + current === windowId ? null : current, + ); + }; + + const updateChartWindowUnit = (windowId: string, unit: string) => { + setChartWindows((current) => + current.map((window) => + window.id === windowId ? { ...window, unit } : window, + ), + ); + }; + + const updateChartCurveUnit = ( + windowId: string, + variableKey: string, + unit: string, + ) => { + setChartWindows((current) => + current.map((window) => + window.id === windowId + ? { + ...window, + variableUnits: { + ...window.variableUnits, + [variableKey]: unit, + }, + } + : window, + ), + ); + }; + + const toggleChartWindowCursor = (windowId: string) => { + setChartWindows((current) => + current.map((window) => + window.id === windowId + ? { ...window, cursorEnabled: !window.cursorEnabled } + : window, + ), + ); + }; + + const downloadResultsCsv = async () => { + if (csvDownloadPending) { + return; + } + setCsvDownloadPending(true); + try { + const response = await fetch("/api/simulation-results/csv", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectName: snapshot.project.name, + variables: snapshot.result.variables, + series: snapshot.result.series, + }), + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response)); + } + const filename = responseDownloadFilename( + response, + `${safeFileStem(snapshot.project.name)}-results.csv`, + ); + downloadBlob(await response.blob(), filename); + } catch (error) { + window.alert(`CSV 下载失败:${errorMessage(error)}`); + } finally { + setCsvDownloadPending(false); + } }; return ( @@ -369,54 +1082,301 @@ export function SimulationResultsView({ hasResults onViewChange={onViewChange} /> -
- {snapshot.project.name} - - {snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "} - {formatResultTimestamp(snapshot.createdAt)} - +
+ void downloadResultsCsv()} + onSnapshotLoad={onSnapshotLoad} + snapshot={snapshot} + /> +
+ {snapshot.project.name} + {resultStatus !== "completed" ? ( + + {simulationResultStatusText(snapshot.result, resultStatus)} + + ) : null} + + {snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "} + {formatResultTimestamp(snapshot.createdAt)} + +
-
-
-
- 系统图 - {snapshot.project.nodes.length} 个组件 +
+
+
+
+ 系统图 + {snapshot.project.nodes.length} 个组件 +
+
+ setSelectedNodeId(node.id)} + onPaneClick={() => setSelectedNodeId(null)} + panOnDrag + selectionOnDrag={false} + zoomOnDoubleClick={false} + > + + + +
+
+ +
resetPaneSize("system-height")} + onKeyDown={(event) => + resizePaneWithKeyboard(event, "system-height") + } + onPointerCancel={stopPaneResize} + onPointerDown={(event) => + startPaneResize(event, "system-height") + } + onPointerMove={movePaneResize} + onPointerUp={stopPaneResize} + role="separator" + tabIndex={0} + title="拖动调整上下区域,双击恢复默认" + > +
-
- setSelectedNodeId(node.id)} - onPaneClick={() => setSelectedNodeId(null)} - panOnDrag - selectionOnDrag={false} - zoomOnDoubleClick={false} - > - - - - -
-
+ + +
+ +
resetPaneSize("inspector-width")} + onKeyDown={(event) => + resizePaneWithKeyboard(event, "inspector-width") + } + onPointerCancel={stopPaneResize} + onPointerDown={(event) => + startPaneResize(event, "inspector-width") + } + onPointerMove={movePaneResize} + onPointerUp={stopPaneResize} + role="separator" + tabIndex={0} + title="拖动调整左右区域,双击恢复默认" + > +
-
+
曲线窗口 - {chartWindows.length} 条曲线 +
+ + {chartWindows.length} 个窗口 ·{" "} + {chartWindows.reduce( + (count, window) => count + window.variableKeys.length, + 0, + )}{" "} + 条曲线 + +
{ + if ( + !(event.relatedTarget instanceof HTMLElement) || + !event.currentTarget.contains(event.relatedTarget) + ) { + setMultiCreateMenuOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setMultiCreateMenuOpen(false); + } + }} + > + + {multiCreateMenuOpen ? ( +
+
选择 SI 单位
+ {multiCurveUnitGroups.map((group) => ( + + ))} +
+ ) : null} +
+
{ + if ( + !(event.relatedTarget instanceof HTMLElement) || + !event.currentTarget.contains(event.relatedTarget) + ) { + setMixedCreateMenuOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setMixedCreateMenuOpen(false); + } + }} + > + + {mixedCreateMenuOpen ? ( +
+
选择首条曲线
+ {plottableResultVariables.map((item) => { + const itemComponent = snapshot.project.nodes.find( + (node) => node.id === item.componentId, + ); + return ( + + ); + })} +
+ ) : null} +
+
{chartWindows.map((chartWindow) => { - const variable = describeResultVariable( - chartWindow.variableKey, - selectedNodeIdForVariable( - chartWindow.variableKey, - snapshot.project.nodes, - ), - snapshot.project.nodes, + const windowVariables = chartWindow.variableKeys + .map((key) => + snapshot.result.variables.find((item) => item.key === key), + ) + .filter( + (item): item is ResultVariableMetadata => item !== undefined, + ); + const variable = + windowVariables.find( + (item) => item.key === chartWindow.variableKey, + ) ?? windowVariables[0]; + if (!variable) { + return null; + } + const isCombined = chartWindow.kind !== "single"; + const isMixed = chartWindow.kind === "mixed"; + const label = resultVariableLabel(variable); + const component = snapshot.project.nodes.find( + (node) => node.id === variable.componentId, ); - const component = snapshot.project.nodes.find((node) => - chartWindow.variableKey.startsWith(`${node.id}.`), + const componentName = + component?.data.label ?? variable.componentId; + const chartTitle = resultVariableTitle(variable, componentName); + const unitOptions = resultUnitOptions(variable); + const selectedUnit = resolveResultUnitOption( + variable, + chartWindow.unit, ); + const displayValues = ( + snapshot.result.series[chartWindow.variableKey] ?? [] + ).map(selectedUnit.fromBase); + const combinedVariables = windowVariables.filter( + (item) => isMixed || item.unit === variable.unit, + ); + const visibleCombinedVariables = combinedVariables.filter( + (item) => !chartWindow.hiddenVariableKeys.includes(item.key), + ); + const multiSeries = visibleCombinedVariables.map((item) => { + const itemComponent = snapshot.project.nodes.find( + (node) => node.id === item.componentId, + ); + const itemUnit = isMixed + ? resolveResultUnitOption( + item, + chartWindow.variableUnits[item.key] ?? item.unit, + ) + : selectedUnit; + return { + key: item.key, + label: resultVariableTitle( + item, + itemComponent?.data.label ?? item.componentId, + ), + color: colorForVariable(item.key), + unit: itemUnit.label, + values: (snapshot.result.series[item.key] ?? []).map( + itemUnit.fromBase, + ), + }; + }); return (
{ + if ( + event.relatedTarget instanceof Element && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + if (multiDropTargetWindowId === chartWindow.id) { + setMultiDropTargetWindowId(null); + } + }} + onDragOver={(event) => { + if (!isCombined) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setMultiDropTargetWindowId(chartWindow.id); + }} + onDrop={(event) => { + if (isCombined) { + addDroppedVariableToMultiChart(event, chartWindow); + } + }} onPointerDown={() => focusChartWindow(chartWindow.id)} style={{ + height: chartWindow.height, left: chartWindow.x, top: chartWindow.y, + width: chartWindow.width, zIndex: chartWindow.zIndex, }} > @@ -461,31 +1501,298 @@ export function SimulationResultsView({ onPointerMove={moveChartWindow} onPointerUp={stopWindowDrag} > -
- {variable.label} - {component?.data.label ?? variable.componentId} +
+ + {isMixed + ? "异单位上下对比" + : isCombined + ? "多曲线对比" + : label} + + + {isCombined + ? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}` + : componentName} + +
+
+ + {isCombined ? ( +
{ + if ( + !(event.relatedTarget instanceof HTMLElement) || + !event.currentTarget.contains(event.relatedTarget) + ) { + setActiveMultiPickerWindowId(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setActiveMultiPickerWindowId(null); + } + }} + onPointerDown={(event) => event.stopPropagation()} + > + + {activeMultiPickerWindowId === chartWindow.id ? ( +
+
+ 曲线管理 + + {isMixed + ? "多单位" + : variable.unit || "无量纲"} + +
+
+ {combinedVariables.map((item) => { + const visible = + !chartWindow.hiddenVariableKeys.includes( + item.key, + ); + const itemComponent = + snapshot.project.nodes.find( + (node) => node.id === item.componentId, + ); + const itemTitle = resultVariableTitle( + item, + itemComponent?.data.label ?? item.componentId, + ); + const itemUnitOptions = resultUnitOptions(item); + const itemUnit = resolveResultUnitOption( + item, + chartWindow.variableUnits[item.key] ?? + item.unit, + ); + return ( +
+ + toggleChartCurveVisibility( + chartWindow.id, + item.key, + ) + } + type="checkbox" + /> + + + {itemTitle} + + {resultVariableLabel(item)} ·{" "} + {itemUnit.label || "无量纲"} + + + {isMixed && itemUnitOptions.length > 1 ? ( + + ) : null} + +
+ ); + })} +
+
+ ) : null} +
+ ) : null} + {!isMixed && unitOptions.length > 1 ? ( + + ) : null} + +
-
- + ) : isCombined ? ( + + ) : ( + + )} +
); })} @@ -494,37 +1801,6 @@ export function SimulationResultsView({ ) : null}
- -
); @@ -532,6 +1808,7 @@ export function SimulationResultsView({ export function EmptySimulationResultsView({ onViewChange, + onSnapshotLoad, }: EmptySimulationResultsViewProps) { return (
@@ -545,8 +1822,11 @@ export function EmptySimulationResultsView({ hasResults={false} onViewChange={onViewChange} /> -
- 尚无结果 +
+ +
+ 尚无结果 +
@@ -559,9 +1839,91 @@ export function EmptySimulationResultsView({ ); } +type ResultsFileToolbarProps = { + snapshot?: SimulationResultsSnapshot; + csvDownloadPending?: boolean; + onDownloadCsv?: () => void; + onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; +}; + +function ResultsFileToolbar({ + snapshot, + csvDownloadPending = false, + onDownloadCsv, + onSnapshotLoad, +}: ResultsFileToolbarProps) { + const fileInputRef = useRef(null); + const [resultFileLoadPending, setResultFileLoadPending] = useState(false); + + const loadResultFile = async ( + event: ReactChangeEvent, + ) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || resultFileLoadPending) { + return; + } + setResultFileLoadPending(true); + try { + if (file.size > MAX_RESULT_FILE_SIZE) { + throw new Error("结果文件超过 250 MB,无法在浏览器中安全加载。"); + } + onSnapshotLoad(parseSimulationResultFile(await file.text())); + } catch (error) { + window.alert(`结果文件加载失败:${errorMessage(error)}`); + } finally { + setResultFileLoadPending(false); + } + }; + + return ( +
+ {snapshot ? ( + + ) : null} + + {snapshot && onDownloadCsv ? ( + + ) : null} + void loadResultFile(event)} + ref={fileInputRef} + type="file" + /> +
+ ); +} + type VariableGroupProps = { title: string; - variables: ResultVariable[]; + variables: ResultVariableMetadata[]; chartWindows: ChartWindow[]; onClick: (variableKey: string) => void; onDragStart: ( @@ -586,8 +1948,11 @@ function VariableGroup({
{variables.map((variable) => { const visible = chartWindows.some( - (window) => window.variableKey === variable.key, + (window) => + window.variableKeys.includes(variable.key) && + !window.hiddenVariableKeys.includes(variable.key), ); + const label = resultVariableLabel(variable); return ( @@ -613,28 +1978,1257 @@ function VariableGroup({ ); } +type MultiCurveSeries = { + key: string; + label: string; + color: string; + unit: string; + values: number[]; +}; + +type MultiCurvePlotProps = { + time: number[]; + series: MultiCurveSeries[]; + title: string; + unit: string; + cursorEnabled: boolean; +}; + +function MultiCurvePlot({ + time, + series, + title, + unit, + cursorEnabled, +}: MultiCurvePlotProps) { + const bodyRef = useRef(null); + const cursorPanelRef = useRef(null); + const cursorPanelDragRef = useRef(null); + const cursorLineDragRef = useRef(null); + const [chartSize, setChartSize] = useState({ width: 520, height: 270 }); + const [cursorIndex, setCursorIndex] = useState(null); + const [cursorPanelPosition, setCursorPanelPosition] = + useState({ x: 330, y: 9 }); + const plottedSeries = useMemo( + () => + series + .map((curve) => ({ + ...curve, + samples: time + .map((timeValue, index) => ({ + x: Number(timeValue), + y: Number(curve.values[index]), + dataIndex: index, + })) + .filter( + (sample) => + Number.isFinite(sample.x) && Number.isFinite(sample.y), + ), + })) + .filter((curve) => curve.samples.length >= 2), + [series, time], + ); + const cursorSamples = useMemo( + () => + time + .map((timeValue, index) => ({ + x: Number(timeValue), + y: index, + })) + .filter((sample) => Number.isFinite(sample.x)), + [time], + ); + + useEffect(() => { + const body = bodyRef.current; + if (!body || typeof ResizeObserver === "undefined") { + return; + } + const updateSize = (width: number, height: number) => { + const nextSize = { + width: Math.max(280, Math.round(width)), + height: Math.max(150, Math.round(height)), + }; + setChartSize((current) => + current.width === nextSize.width && current.height === nextSize.height + ? current + : nextSize, + ); + }; + updateSize(body.clientWidth, body.clientHeight); + const observer = new ResizeObserver(([entry]) => { + if (entry) { + updateSize(entry.contentRect.width, entry.contentRect.height); + } + }); + observer.observe(body); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!cursorEnabled || cursorSamples.length === 0) { + return; + } + setCursorIndex((current) => + current !== null && current < cursorSamples.length + ? current + : Math.floor(cursorSamples.length / 2), + ); + }, [cursorEnabled, cursorSamples.length]); + + useEffect(() => { + const body = bodyRef.current; + if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") { + return; + } + const clampPanel = () => { + const panel = cursorPanelRef.current; + if (!panel) { + return; + } + setCursorPanelPosition((current) => + clampCursorPanelPosition( + current, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + const animationFrame = window.requestAnimationFrame(clampPanel); + const observer = new ResizeObserver(clampPanel); + observer.observe(body); + return () => { + window.cancelAnimationFrame(animationFrame); + observer.disconnect(); + }; + }, [cursorEnabled, plottedSeries.length]); + + if (plottedSeries.length === 0) { + return ( +
+
无可绘制数据
+
+ ); + } + + const { width, height } = chartSize; + const maximumLegendRows = Math.max( + 1, + Math.floor(Math.min(70, height * 0.26) / 14), + ); + const legendColumns = Math.max( + 1, + Math.ceil(plottedSeries.length / maximumLegendRows), + ); + const legendRows = Math.ceil(plottedSeries.length / legendColumns); + const allSamples = plottedSeries.flatMap((curve) => curve.samples); + const rawYMin = Math.min(...allSamples.map((sample) => sample.y)); + const rawYMax = Math.max(...allSamples.map((sample) => sample.y)); + const sharedYPadding = + rawYMin === rawYMax + ? Math.max(Math.abs(rawYMin) * 0.05, 1) + : (rawYMax - rawYMin) * 0.08; + const sharedYScale = { + minimum: rawYMin - sharedYPadding, + maximum: rawYMax + sharedYPadding, + }; + const margin = { + top: 25 + legendRows * 14, + right: 18, + bottom: 40, + left: 68, + }; + const plotWidth = Math.max(40, width - margin.left - margin.right); + const plotHeight = Math.max(30, height - margin.top - margin.bottom); + const xMin = Math.min(...allSamples.map((sample) => sample.x)); + const xMax = Math.max(...allSamples.map((sample) => sample.x)); + const xRange = Math.max(xMax - xMin, 1e-12); + const xPosition = (value: number) => + margin.left + ((value - xMin) / xRange) * plotWidth; + const yPosition = (value: number) => { + const range = Math.max( + sharedYScale.maximum - sharedYScale.minimum, + 1e-12, + ); + return ( + margin.top + + (1 - (value - sharedYScale.minimum) / range) * plotHeight + ); + }; + const xTicks = chartTicks(xMin, xMax, 5); + const yTicks = chartTicks( + sharedYScale.minimum, + sharedYScale.maximum, + 5, + ); + const legendWidth = plotWidth / legendColumns; + const activeCursorIndex = + cursorIndex === null + ? Math.floor(cursorSamples.length / 2) + : Math.min(cursorIndex, cursorSamples.length - 1); + const cursorSample = cursorSamples[activeCursorIndex]; + const cursorReadings = cursorSample + ? plottedSeries + .map((curve, index) => ({ + color: curve.color, + index, + label: curve.label, + unit: curve.unit, + y: Number(curve.values[Math.round(cursorSample.y)]), + })) + .filter((reading) => Number.isFinite(reading.y)) + : []; + + const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { + const bounds = svg.getBoundingClientRect(); + if (bounds.width <= 0 || cursorSamples.length === 0) { + return; + } + const svgX = ((clientX - bounds.left) / bounds.width) * width; + const clampedX = Math.min( + width - margin.right, + Math.max(margin.left, svgX), + ); + const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; + setCursorIndex(nearestSampleIndex(cursorSamples, targetX)); + }; + + const startCursorLineDrag = ( + event: ReactPointerEvent, + ) => { + if (!cursorEnabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cursorLineDragRef.current = { + pointerId: event.pointerId, + axis: "x", + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorLine = (event: ReactPointerEvent) => { + const drag = cursorLineDragRef.current; + const svg = event.currentTarget.ownerSVGElement; + if (!drag || drag.pointerId !== event.pointerId || !svg) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateCursorByX(event.clientX, svg); + }; + + const stopCursorLineDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorLineDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorLineDragRef.current = null; + }; + + const startCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const panelBounds = panel.getBoundingClientRect(); + const bodyBounds = body.getBoundingClientRect(); + cursorPanelDragRef.current = { + pointerId: event.pointerId, + offsetX: event.clientX - panelBounds.left, + offsetY: event.clientY - panelBounds.top, + }; + setCursorPanelPosition({ + x: panelBounds.left - bodyBounds.left, + y: panelBounds.top - bodyBounds.top, + }); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorPanel = (event: ReactPointerEvent) => { + const drag = cursorPanelDragRef.current; + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!drag || drag.pointerId !== event.pointerId || !body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const bodyBounds = body.getBoundingClientRect(); + setCursorPanelPosition( + clampCursorPanelPosition( + { + x: event.clientX - bodyBounds.left - drag.offsetX, + y: event.clientY - bodyBounds.top - drag.offsetY, + }, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + + const stopCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorPanelDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorPanelDragRef.current = null; + }; + + return ( +
+ { + if (cursorEnabled) { + event.preventDefault(); + updateCursorByX(event.clientX, event.currentTarget); + } + }} + role="img" + viewBox={`0 0 ${width} ${height}`} + > + + {title} + + + {unit || "无量纲"} + + + {plottedSeries.map((curve, index) => { + const column = Math.floor(index / legendRows); + const row = index % legendRows; + const x = margin.left + column * legendWidth; + const y = 27 + row * 14; + return ( + + + + {truncateLegendLabel( + `Y${index + 1} ${curve.label}`, + legendWidth - 20, + )} + + + ); + })} + + {yTicks.map((tick, index) => { + const y = yPosition(tick); + return ( + + + + + {formatChartNumber(tick)} + + + ); + })} + {xTicks.map((tick, index) => { + const x = xPosition(tick); + const textAnchor = + index === 0 + ? "start" + : index === xTicks.length - 1 + ? "end" + : "middle"; + return ( + + + + + {formatChartNumber(tick)} + + + ); + })} + + + {plottedSeries.map((curve) => { + const path = curve.samples + .map( + (sample, index) => + `${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`, + ) + .join(" "); + return ( + + ); + })} + {cursorEnabled && cursorSample ? ( + <> + + + {cursorReadings.map((reading) => ( + + ))} + + + + ) : null} + + 时间 (s) + + + {cursorEnabled && cursorSample ? ( +
event.stopPropagation()} + ref={cursorPanelRef} + style={{ + left: cursorPanelPosition.x, + top: cursorPanelPosition.y, + }} + > + +
+
+
X
+
{formatCursorNumber(cursorSample.x)} s
+
+ {cursorReadings.map((reading) => ( +
+
Y{reading.index + 1}
+
+
+
+ ))} +
+
+ ) : null} +
+ ); +} + +type StackedCurvePlotProps = { + time: number[]; + series: MultiCurveSeries[]; + cursorEnabled: boolean; +}; + +function StackedCurvePlot({ + time, + series, + cursorEnabled, +}: StackedCurvePlotProps) { + const bodyRef = useRef(null); + const cursorPanelRef = useRef(null); + const cursorPanelDragRef = useRef(null); + const cursorLineDragRef = useRef(null); + const [chartSize, setChartSize] = useState({ width: 520, height: 270 }); + const [cursorIndex, setCursorIndex] = useState(null); + const [cursorPanelPosition, setCursorPanelPosition] = + useState({ x: 330, y: 9 }); + const plottedSeries = useMemo( + () => + series + .map((curve) => ({ + ...curve, + samples: time + .map((timeValue, index) => ({ + x: Number(timeValue), + y: Number(curve.values[index]), + dataIndex: index, + })) + .filter( + (sample) => + Number.isFinite(sample.x) && Number.isFinite(sample.y), + ), + })) + .filter((curve) => curve.samples.length >= 2), + [series, time], + ); + const cursorSamples = useMemo( + () => + time + .map((timeValue, index) => ({ + x: Number(timeValue), + y: index, + })) + .filter((sample) => Number.isFinite(sample.x)), + [time], + ); + + useEffect(() => { + const body = bodyRef.current; + if (!body || typeof ResizeObserver === "undefined") { + return; + } + const updateSize = (width: number, height: number) => { + const nextSize = { + width: Math.max(280, Math.round(width)), + height: Math.max(180, Math.round(height)), + }; + setChartSize((current) => + current.width === nextSize.width && current.height === nextSize.height + ? current + : nextSize, + ); + }; + updateSize(body.clientWidth, body.clientHeight); + const observer = new ResizeObserver(([entry]) => { + if (entry) { + updateSize(entry.contentRect.width, entry.contentRect.height); + } + }); + observer.observe(body); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!cursorEnabled || cursorSamples.length === 0) { + return; + } + setCursorIndex((current) => + current !== null && current < cursorSamples.length + ? current + : Math.floor(cursorSamples.length / 2), + ); + }, [cursorEnabled, cursorSamples.length]); + + useEffect(() => { + const body = bodyRef.current; + if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") { + return; + } + const clampPanel = () => { + const panel = cursorPanelRef.current; + if (!panel) { + return; + } + setCursorPanelPosition((current) => + clampCursorPanelPosition( + current, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + const animationFrame = window.requestAnimationFrame(clampPanel); + const observer = new ResizeObserver(clampPanel); + observer.observe(body); + return () => { + window.cancelAnimationFrame(animationFrame); + observer.disconnect(); + }; + }, [cursorEnabled, plottedSeries.length]); + + if (plottedSeries.length === 0) { + return ( +
+
无可绘制数据
+
+ ); + } + + const { width, height } = chartSize; + const margin = { + top: 4, + right: 18, + bottom: 28, + left: 68, + }; + const plotWidth = Math.max(40, width - margin.left - margin.right); + const availableHeight = Math.max( + 80, + height - margin.top - margin.bottom, + ); + const bandHeight = availableHeight / plottedSeries.length; + const allSamples = plottedSeries.flatMap((curve) => curve.samples); + const xMin = Math.min(...allSamples.map((sample) => sample.x)); + const xMax = Math.max(...allSamples.map((sample) => sample.x)); + const xRange = Math.max(xMax - xMin, 1e-12); + const xPosition = (value: number) => + margin.left + ((value - xMin) / xRange) * plotWidth; + const xTicks = chartTicks(xMin, xMax, 5); + const plotBands = plottedSeries.map((curve, index) => { + const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y)); + const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y)); + const padding = + rawMinimum === rawMaximum + ? Math.max(Math.abs(rawMinimum) * 0.05, 1) + : (rawMaximum - rawMinimum) * 0.08; + const minimum = rawMinimum - padding; + const maximum = rawMaximum + padding; + const range = Math.max(maximum - minimum, 1e-12); + const bandTop = margin.top + index * bandHeight; + const plotTop = bandTop + 20; + const plotBottom = Math.max(plotTop + 22, bandTop + bandHeight - 5); + const plotHeight = plotBottom - plotTop; + return { + curve, + index, + bandTop, + plotTop, + plotBottom, + yTicks: chartTicks(minimum, maximum, bandHeight < 105 ? 2 : 3), + yPosition: (value: number) => + plotTop + (1 - (value - minimum) / range) * plotHeight, + }; + }); + const activeCursorIndex = + cursorIndex === null + ? Math.floor(cursorSamples.length / 2) + : Math.min(cursorIndex, cursorSamples.length - 1); + const cursorSample = cursorSamples[activeCursorIndex]; + const cursorReadings = cursorSample + ? plottedSeries + .map((curve, index) => ({ + color: curve.color, + index, + label: curve.label, + unit: curve.unit, + y: Number(curve.values[Math.round(cursorSample.y)]), + })) + .filter((reading) => Number.isFinite(reading.y)) + : []; + const cursorTop = plotBands[0].plotTop; + const cursorBottom = plotBands[plotBands.length - 1].plotBottom; + + const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { + const bounds = svg.getBoundingClientRect(); + if (bounds.width <= 0 || cursorSamples.length === 0) { + return; + } + const svgX = ((clientX - bounds.left) / bounds.width) * width; + const clampedX = Math.min( + width - margin.right, + Math.max(margin.left, svgX), + ); + const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; + setCursorIndex(nearestSampleIndex(cursorSamples, targetX)); + }; + + const startCursorLineDrag = ( + event: ReactPointerEvent, + ) => { + if (!cursorEnabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cursorLineDragRef.current = { + pointerId: event.pointerId, + axis: "x", + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorLine = (event: ReactPointerEvent) => { + const drag = cursorLineDragRef.current; + const svg = event.currentTarget.ownerSVGElement; + if (!drag || drag.pointerId !== event.pointerId || !svg) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateCursorByX(event.clientX, svg); + }; + + const stopCursorLineDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorLineDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorLineDragRef.current = null; + }; + + const startCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const panelBounds = panel.getBoundingClientRect(); + const bodyBounds = body.getBoundingClientRect(); + cursorPanelDragRef.current = { + pointerId: event.pointerId, + offsetX: event.clientX - panelBounds.left, + offsetY: event.clientY - panelBounds.top, + }; + setCursorPanelPosition({ + x: panelBounds.left - bodyBounds.left, + y: panelBounds.top - bodyBounds.top, + }); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorPanel = (event: ReactPointerEvent) => { + const drag = cursorPanelDragRef.current; + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!drag || drag.pointerId !== event.pointerId || !body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const bodyBounds = body.getBoundingClientRect(); + setCursorPanelPosition( + clampCursorPanelPosition( + { + x: event.clientX - bodyBounds.left - drag.offsetX, + y: event.clientY - bodyBounds.top - drag.offsetY, + }, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + + const stopCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorPanelDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorPanelDragRef.current = null; + }; + + return ( +
+ { + if (cursorEnabled) { + event.preventDefault(); + updateCursorByX(event.clientX, event.currentTarget); + } + }} + role="img" + viewBox={`0 0 ${width} ${height}`} + > + {plotBands.map((band) => { + const isLast = band.index === plotBands.length - 1; + const path = band.curve.samples + .map( + (sample, sampleIndex) => + `${sampleIndex === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${band.yPosition(sample.y).toFixed(2)}`, + ) + .join(" "); + return ( + + {band.index > 0 ? ( + + ) : null} + + {truncateLegendLabel( + `Y${band.index + 1} ${band.curve.label}`, + plotWidth - 66, + )} + + + {band.curve.unit || "无量纲"} + + {band.yTicks.map((tick, tickIndex) => { + const y = band.yPosition(tick); + return ( + + + + + {formatChartNumber(tick)} + + + ); + })} + {xTicks.map((tick, tickIndex) => { + const x = xPosition(tick); + const textAnchor = + tickIndex === 0 + ? "start" + : tickIndex === xTicks.length - 1 + ? "end" + : "middle"; + return ( + + + {isLast ? ( + <> + + + {formatChartNumber(tick)} + + + ) : null} + + ); + })} + + + + + ); + })} + {cursorEnabled && cursorSample ? ( + <> + + {plotBands.map((band) => ( + + ))} + {cursorReadings.map((reading) => { + const band = plotBands[reading.index]; + return band ? ( + + ) : null; + })} + + + + ) : null} + + 时间 (s) + + + {cursorEnabled && cursorSample ? ( +
event.stopPropagation()} + ref={cursorPanelRef} + style={{ + left: cursorPanelPosition.x, + top: cursorPanelPosition.y, + }} + > + +
+
+
X
+
{formatCursorNumber(cursorSample.x)} s
+
+ {cursorReadings.map((reading) => ( +
+
Y{reading.index + 1}
+
+
+
+ ))} +
+
+ ) : null} +
+ ); +} + type CurvePlotProps = { time: number[]; values: number[]; - label: string; + title: string; unit: string; color: string; + cursorEnabled: boolean; }; -function CurvePlot({ time, values, label, unit, color }: CurvePlotProps) { - const samples = time - .map((timeValue, index) => ({ - x: Number(timeValue), - y: Number(values[index]), - })) - .filter((sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y)); +type CurveSample = { + x: number; + y: number; +}; + +type CursorPanelPosition = { + x: number; + y: number; +}; + +type CursorPanelDragState = { + pointerId: number; + offsetX: number; + offsetY: number; +}; + +type CursorLineDragState = { + pointerId: number; + axis: "x" | "y"; +}; + +function CurvePlot({ + time, + values, + title, + unit, + color, + cursorEnabled, +}: CurvePlotProps) { + const bodyRef = useRef(null); + const cursorPanelRef = useRef(null); + const cursorPanelDragRef = useRef(null); + const cursorLineDragRef = useRef(null); + const [chartSize, setChartSize] = useState({ width: 400, height: 190 }); + const [cursorIndex, setCursorIndex] = useState(null); + const [cursorPanelPosition, setCursorPanelPosition] = + useState({ x: 238, y: 9 }); + const samples = useMemo( + () => + time + .map((timeValue, index) => ({ + x: Number(timeValue), + y: Number(values[index]), + })) + .filter( + (sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y), + ), + [time, values], + ); + + useEffect(() => { + const body = bodyRef.current; + if (!body || typeof ResizeObserver === "undefined") { + return; + } + const updateSize = (width: number, height: number) => { + const nextSize = { + width: Math.max(280, Math.round(width)), + height: Math.max(150, Math.round(height)), + }; + setChartSize((current) => + current.width === nextSize.width && current.height === nextSize.height + ? current + : nextSize, + ); + }; + updateSize(body.clientWidth, body.clientHeight); + const observer = new ResizeObserver(([entry]) => { + if (entry) { + updateSize(entry.contentRect.width, entry.contentRect.height); + } + }); + observer.observe(body); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!cursorEnabled || samples.length === 0) { + return; + } + setCursorIndex((current) => + current !== null && current < samples.length + ? current + : Math.floor(samples.length / 2), + ); + }, [cursorEnabled, samples.length]); + + useEffect(() => { + const body = bodyRef.current; + if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") { + return; + } + const clampPanel = () => { + const panel = cursorPanelRef.current; + if (!panel) { + return; + } + setCursorPanelPosition((current) => + clampCursorPanelPosition( + current, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + const animationFrame = window.requestAnimationFrame(clampPanel); + const observer = new ResizeObserver(clampPanel); + observer.observe(body); + return () => { + window.cancelAnimationFrame(animationFrame); + observer.disconnect(); + }; + }, [cursorEnabled]); + if (samples.length < 2) { - return
无可绘制数据
; + return ( +
+
无可绘制数据
+
+ ); } - const width = 400; - const height = 190; - const margin = { top: 14, right: 16, bottom: 30, left: 58 }; + const { width, height } = chartSize; + const margin = { top: 24, right: 18, bottom: 40, left: 68 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const xMin = Math.min(...samples.map((sample) => sample.x)); @@ -653,6 +3247,8 @@ function CurvePlot({ time, values, label, unit, color }: CurvePlotProps) { margin.left + ((value - xMin) / xRange) * plotWidth; const yPosition = (value: number) => margin.top + (1 - (value - yMin) / yRange) * plotHeight; + const xTicks = chartTicks(xMin, xMax, 5); + const yTicks = chartTicks(yMin, yMax, 5); const path = samples .map( (sample, index) => @@ -661,39 +3257,240 @@ function CurvePlot({ time, values, label, unit, color }: CurvePlotProps) { ).toFixed(2)}`, ) .join(" "); + const activeCursorIndex = + cursorIndex === null + ? Math.floor(samples.length / 2) + : Math.min(cursorIndex, samples.length - 1); + const cursorSample = samples[activeCursorIndex]; + + const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { + const bounds = svg.getBoundingClientRect(); + if (bounds.width <= 0) { + return; + } + const svgX = ((clientX - bounds.left) / bounds.width) * width; + const clampedX = Math.min( + width - margin.right, + Math.max(margin.left, svgX), + ); + const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; + setCursorIndex(nearestSampleIndex(samples, targetX)); + }; + + const updateCursorByY = (clientY: number, svg: SVGSVGElement) => { + const bounds = svg.getBoundingClientRect(); + if (bounds.height <= 0) { + return; + } + const svgY = ((clientY - bounds.top) / bounds.height) * height; + const clampedY = Math.min( + height - margin.bottom, + Math.max(margin.top, svgY), + ); + const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange; + setCursorIndex((current) => + nearestSampleIndexByY( + samples, + targetY, + current ?? activeCursorIndex, + ), + ); + }; + + const startCursorLineDrag = ( + event: ReactPointerEvent, + axis: CursorLineDragState["axis"], + ) => { + if (!cursorEnabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cursorLineDragRef.current = { + pointerId: event.pointerId, + axis, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorLine = (event: ReactPointerEvent) => { + const drag = cursorLineDragRef.current; + const svg = event.currentTarget.ownerSVGElement; + if (!drag || drag.pointerId !== event.pointerId || !svg) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (drag.axis === "x") { + updateCursorByX(event.clientX, svg); + } else { + updateCursorByY(event.clientY, svg); + } + }; + + const stopCursorLineDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorLineDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorLineDragRef.current = null; + }; + + const startCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const panelBounds = panel.getBoundingClientRect(); + const bodyBounds = body.getBoundingClientRect(); + cursorPanelDragRef.current = { + pointerId: event.pointerId, + offsetX: event.clientX - panelBounds.left, + offsetY: event.clientY - panelBounds.top, + }; + setCursorPanelPosition({ + x: panelBounds.left - bodyBounds.left, + y: panelBounds.top - bodyBounds.top, + }); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const moveCursorPanel = (event: ReactPointerEvent) => { + const drag = cursorPanelDragRef.current; + const body = bodyRef.current; + const panel = cursorPanelRef.current; + if (!drag || drag.pointerId !== event.pointerId || !body || !panel) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const bodyBounds = body.getBoundingClientRect(); + setCursorPanelPosition( + clampCursorPanelPosition( + { + x: event.clientX - bodyBounds.left - drag.offsetX, + y: event.clientY - bodyBounds.top - drag.offsetY, + }, + body.clientWidth, + body.clientHeight, + panel.offsetWidth, + panel.offsetHeight, + ), + ); + }; + + const stopCursorPanelDrag = ( + event: ReactPointerEvent, + ) => { + if (cursorPanelDragRef.current?.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + cursorPanelDragRef.current = null; + }; return ( -
+
{ + if (cursorEnabled) { + event.preventDefault(); + updateCursorByX(event.clientX, event.currentTarget); + } + }} role="img" viewBox={`0 0 ${width} ${height}`} > - {label} - {[0, 0.25, 0.5, 0.75, 1].map((fraction) => { - const y = margin.top + fraction * plotHeight; + + {title} + + + {unit || "无量纲"} + + {yTicks.map((tick, index) => { + const y = yPosition(tick); return ( - + + + + + {formatChartNumber(tick)} + + ); })} - {[0, 0.25, 0.5, 0.75, 1].map((fraction) => { - const x = margin.left + fraction * plotWidth; + {xTicks.map((tick, index) => { + const x = xPosition(tick); + const textAnchor = + index === 0 ? "start" : index === xTicks.length - 1 ? "end" : "middle"; return ( - + + + + + {formatChartNumber(tick)} + + ); })} + {cursorEnabled && cursorSample ? ( + <> + + + + + + startCursorLineDrag(event, "x")} + onPointerMove={moveCursorLine} + onPointerUp={stopCursorLineDrag} + x1={xPosition(cursorSample.x)} + x2={xPosition(cursorSample.x)} + y1={margin.top} + y2={height - margin.bottom} + /> + startCursorLineDrag(event, "y")} + onPointerMove={moveCursorLine} + onPointerUp={stopCursorLineDrag} + x1={margin.left} + x2={width - margin.right} + y1={yPosition(cursorSample.y)} + y2={yPosition(cursorSample.y)} + /> + + ) : null} - {formatChartNumber(rawYMax)} - - - {formatChartNumber(rawYMin)} - - - {formatChartNumber(xMin)} - - - {formatChartNumber(xMax)} s - - - {unit} + 时间 (s) + {cursorEnabled && cursorSample ? ( +
event.stopPropagation()} + ref={cursorPanelRef} + style={{ + left: cursorPanelPosition.x, + top: cursorPanelPosition.y, + }} + > + +
+
+
X
+
{formatCursorNumber(cursorSample.x)} s
+
+
+
Y
+
+ {formatCursorNumber(cursorSample.y)} {unit} +
+
+
+
+ ) : null}
); } +function nearestSampleIndex(samples: CurveSample[], targetX: number) { + let low = 0; + let high = samples.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (samples[middle].x < targetX) { + low = middle + 1; + } else { + high = middle; + } + } + if (low === 0) { + return 0; + } + return Math.abs(samples[low].x - targetX) < + Math.abs(samples[low - 1].x - targetX) + ? low + : low - 1; +} + +function nearestSampleIndexByY( + samples: CurveSample[], + targetY: number, + currentIndex: number, +) { + let nearestIndex = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearestIndexDistance = Number.POSITIVE_INFINITY; + samples.forEach((sample, index) => { + const distance = Math.abs(sample.y - targetY); + const indexDistance = Math.abs(index - currentIndex); + if ( + distance < nearestDistance || + (distance === nearestDistance && indexDistance < nearestIndexDistance) + ) { + nearestIndex = index; + nearestDistance = distance; + nearestIndexDistance = indexDistance; + } + }); + return nearestIndex; +} + +function clampCursorPanelPosition( + position: CursorPanelPosition, + containerWidth: number, + containerHeight: number, + panelWidth: number, + panelHeight: number, +) { + const margin = 6; + return { + x: Math.round( + Math.min( + Math.max(margin, position.x), + Math.max(margin, containerWidth - panelWidth - margin), + ), + ), + y: Math.round( + Math.min( + Math.max(margin, position.y), + Math.max(margin, containerHeight - panelHeight - margin), + ), + ), + }; +} + function resultVariablesForNode( + variables: ResultVariableMetadata[], series: Record, nodeId: string, - ports: ResultPortDefinition[], ) { - const portNames = new Set(ports.map((port) => port.name)); - return Object.entries(series) - .filter( - ([key, values]) => - key !== "time" && - key.startsWith(`${nodeId}.`) && + return variables + .filter((variable) => { + const values = series[variable.key]; + return ( + variable.componentId === nodeId && Array.isArray(values) && - values.length > 0, - ) - .map(([key]) => { - const described = describeResultVariable(key, nodeId); - const firstSegment = key.slice(nodeId.length + 1).split(".")[0]; - return { - key, - label: described.label, - unit: described.unit, - group: portNames.has(firstSegment) ? "port" : "component", - } satisfies ResultVariable; + values.length > 0 + ); }) .sort((first, second) => { - if (first.group !== second.group) { - return first.group === "component" ? -1 : 1; + if (first.scope !== second.scope) { + return first.scope === "component" ? -1 : 1; } - return first.label.localeCompare(second.label, "zh-CN"); + const portOrder = (first.portName ?? "").localeCompare( + second.portName ?? "", + "zh-CN", + ); + if (portOrder !== 0) { + return portOrder; + } + return ( + first.order - second.order || + first.label.localeCompare(second.label, "zh-CN") + ); }); } -function describeResultVariable( - key: string, - componentId: string, - nodes?: ResultProjectNode[], +function resultVariableLabel(variable: ResultVariableMetadata) { + return variable.scope === "port" && variable.portName + ? `${variable.portName} · ${variable.label}` + : variable.label; +} + +function resultVariableTitle( + variable: ResultVariableMetadata, + componentName: string, ) { - const prefix = `${componentId}.`; - const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : key; - const segments = suffix.split("."); - const variableName = segments.at(-1) ?? suffix; - const portName = segments.length > 1 ? segments.slice(0, -1).join(".") : null; - const definition = variableDefinitions[variableName] ?? { - label: variableName, - unit: "", - }; - const component = nodes?.find((node) => node.id === componentId); + const variablePath = variable.portName + ? `${variable.portName}.${variable.name}` + : variable.name; + return `${componentName}.${variablePath}`; +} + +function scaledResultUnit( + value: string, + label: string, + baseUnitsPerDisplayUnit: number, +): ResultUnitOption { return { - componentId, - label: portName - ? `${portName} · ${definition.label}` - : definition.label, - unit: definition.unit, - componentLabel: component?.data.label ?? componentId, + value, + label, + fromBase: (baseValue) => baseValue / baseUnitsPerDisplayUnit, }; } -function selectedNodeIdForVariable( - variableKey: string, - nodes: ResultProjectNode[], -) { +function resultUnitOptions(variable: ResultVariableMetadata) { return ( - nodes.find((node) => variableKey.startsWith(`${node.id}.`))?.id ?? - variableKey.split(".")[0] + RESULT_UNIT_OPTIONS[variable.quantity] ?? [ + scaledResultUnit(variable.unit, variable.unit || "无量纲", 1), + ] ); } +function resolveResultUnitOption( + variable: ResultVariableMetadata, + requestedUnit: string, +) { + const options = resultUnitOptions(variable); + return ( + options.find((option) => option.value === requestedUnit) ?? + options.find((option) => option.value === variable.unit) ?? + options[0] + ); +} + +function chartTicks(minimum: number, maximum: number, intervalCount: number) { + const range = maximum - minimum; + return Array.from( + { length: intervalCount + 1 }, + (_, index) => minimum + (range * index) / intervalCount, + ); +} + +function truncateLegendLabel(label: string, availableWidth: number) { + const maximumCharacters = Math.max(7, Math.floor(availableWidth / 5.8)); + return label.length <= maximumCharacters + ? label + : `${label.slice(0, maximumCharacters - 1)}…`; +} + +async function downloadChartImage( + button: HTMLButtonElement, + filenameStem: string, +) { + const chartWindow = button.closest(".result-chart-window"); + if (!(chartWindow instanceof HTMLElement)) { + throw new Error("没有找到可导出的曲线窗口。"); + } + const chartSurface = chartWindow.querySelector(".result-chart-body"); + if (!(chartSurface instanceof HTMLElement)) { + throw new Error("没有找到可导出的绘图区。"); + } + downloadBlob( + await chartSurfaceToPngBlob(chartSurface), + `${safeFileStem(filenameStem)}.png`, + ); +} + +async function chartSurfaceToPngBlob(chartSurface: HTMLElement) { + const { default: html2canvas } = await import("html2canvas"); + const canvas = await html2canvas(chartSurface, { + backgroundColor: "#ffffff", + imageTimeout: 0, + logging: false, + removeContainer: true, + scale: Math.min(Math.max(window.devicePixelRatio || 1, 2), 3), + useCORS: true, + }); + return await canvasBlob(canvas, "image/png"); +} + +function canvasBlob(canvas: HTMLCanvasElement, type: string) { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("曲线图片生成失败。")); + } + }, type); + }); +} + +function createSimulationResultFile( + snapshot: SimulationResultsSnapshot, +): SimulationResultFile { + const lastSeriesTime = snapshot.result.series.time?.at(-1); + const simulatedUntil = Number.isFinite(snapshot.result.simulatedUntil) + ? Number(snapshot.result.simulatedUntil) + : Number.isFinite(lastSeriesTime) + ? Number(lastSeriesTime) + : null; + return { + fileType: RESULT_FILE_TYPE, + formatVersion: RESULT_FILE_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + metadata: { + projectName: snapshot.project.name, + resultCreatedAt: snapshot.createdAt, + simulation: { + ...snapshot.project.simulation, + simulatedUntil, + }, + resultStatus: simulationResultStatus(snapshot.result), + componentCount: snapshot.project.nodes.length, + connectionCount: snapshot.project.edges.length, + sampleCount: snapshot.result.diagnostics.sampleCount, + componentParameters: snapshot.project.nodes.map((node) => ({ + id: node.id, + name: node.data.label, + componentType: node.data.componentType, + parameters: { ...node.data.parameters }, + parameterUnits: { ...(node.data.parameterUnits ?? {}) }, + })), + }, + snapshot, + }; +} + +function downloadSimulationResultFile(snapshot: SimulationResultsSnapshot) { + const payload = createSimulationResultFile(snapshot); + const timestamp = compactFileTimestamp(payload.exportedAt); + downloadBlob( + new Blob([JSON.stringify(payload)], { type: RESULT_FILE_MIME }), + `${safeFileStem(snapshot.project.name)}-${timestamp}.simresult`, + ); +} + +function parseSimulationResultFile(raw: string): SimulationResultsSnapshot { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("文件不是有效的 System Simulation 结果文件。"); + } + if (!isRecord(value) || value.fileType !== RESULT_FILE_TYPE) { + throw new Error("文件类型不正确,请选择 .simresult 文件。"); + } + if (value.formatVersion !== RESULT_FILE_FORMAT_VERSION) { + throw new Error(`不支持的结果文件版本:${String(value.formatVersion)}`); + } + if (!isRecord(value.metadata) || !isSimulationResultsSnapshotValue(value.snapshot)) { + throw new Error("结果文件内容不完整或已经损坏。"); + } + return { + ...value.snapshot, + id: `imported-${Date.now()}-${Math.random().toString(16).slice(2)}`, + }; +} + +function isSimulationResultsSnapshotValue( + value: unknown, +): value is SimulationResultsSnapshot { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.createdAt !== "string") { + return false; + } + const project = value.project; + const result = value.result; + if ( + !isRecord(project) || + typeof project.name !== "string" || + !Array.isArray(project.nodes) || + !project.nodes.every(isResultProjectNode) || + !Array.isArray(project.edges) || + !project.edges.every(isResultProjectEdge) || + !isResultSimulationConfig(project.simulation) || + !isRecord(result) || + typeof result.success !== "boolean" || + typeof result.message !== "string" || + !Array.isArray(result.variables) || + !result.variables.every(isResultVariableMetadata) || + !isRecord(result.final) || + !isNumericRecord(result.final) || + !isRecord(result.series) || + !Object.values(result.series).every(isNumericArray) || + !Array.isArray(result.series.time) || + !isRecord(result.diagnostics) || + typeof result.diagnostics.sampleCount !== "number" + ) { + return false; + } + return ( + result.status === undefined || + ["completed", "stopped", "stalled", "failed"].includes(String(result.status)) + ); +} + +function isResultProjectNode(value: unknown): value is ResultProjectNode { + if (!isRecord(value) || !isRecord(value.position) || !isRecord(value.data)) { + return false; + } + const data = value.data; + return ( + typeof value.id === "string" && + value.type === "simulationComponent" && + Number.isFinite(value.position.x) && + Number.isFinite(value.position.y) && + typeof data.label === "string" && + typeof data.componentType === "string" && + typeof data.modelType === "string" && + Array.isArray(data.ports) && + isRecord(data.parameters) && + (data.parameterUnits === undefined || isRecord(data.parameterUnits)) && + [0, 90, 180, 270].includes(Number(data.rotation)) && + typeof data.mirrored === "boolean" + ); +} + +function isResultProjectEdge(value: unknown): value is ResultProjectEdge { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.source === "string" && + typeof value.target === "string" && + (value.sourceHandle === null || typeof value.sourceHandle === "string") && + (value.targetHandle === null || typeof value.targetHandle === "string") + ); +} + +function isResultSimulationConfig( + value: unknown, +): value is ResultSimulationConfig { + return ( + isRecord(value) && + Number.isFinite(value.t_start) && + Number.isFinite(value.t_stop) && + Number.isFinite(value.step) && + Number.isFinite(value.max_step) && + typeof value.method === "string" + ); +} + +function isResultVariableMetadata( + value: unknown, +): value is ResultVariableMetadata { + return ( + isRecord(value) && + typeof value.key === "string" && + typeof value.componentId === "string" && + typeof value.componentType === "string" && + (value.scope === "component" || value.scope === "port") && + (value.portName === null || typeof value.portName === "string") && + typeof value.name === "string" && + typeof value.label === "string" && + typeof value.quantity === "string" && + typeof value.unit === "string" && + typeof value.category === "string" && + Number.isFinite(value.order) + ); +} + +function isNumericRecord(value: Record) { + return Object.values(value).every( + (item) => typeof item === "number" && Number.isFinite(item), + ); +} + +function isNumericArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.every((item) => typeof item === "number" && Number.isFinite(item)) + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function compactFileTimestamp(value: string) { + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "result"; + } + const pad = (part: number) => String(part).padStart(2, "0"); + return `${timestamp.getFullYear()}${pad(timestamp.getMonth() + 1)}${pad( + timestamp.getDate(), + )}-${pad(timestamp.getHours())}${pad(timestamp.getMinutes())}${pad( + timestamp.getSeconds(), + )}`; +} + +function responseDownloadFilename(response: Response, fallback: string) { + const disposition = response.headers.get("Content-Disposition") ?? ""; + const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded); + } catch { + return fallback; + } + } + return /filename="([^"]+)"/i.exec(disposition)?.[1] ?? fallback; +} + +async function responseErrorMessage(response: Response) { + try { + const payload = (await response.json()) as { detail?: unknown }; + if (typeof payload.detail === "string") { + return payload.detail; + } + } catch { + // Use the HTTP status when the server did not return a JSON error body. + } + return `${response.status} ${response.statusText}`.trim(); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function safeFileStem(value: string) { + const cleaned = value + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/[. ]+$/g, ""); + return cleaned.slice(0, 80) || "simulation"; +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.style.display = "none"; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + function colorForVariable(key: string) { let hash = 0; for (const character of key) { @@ -839,6 +4075,91 @@ function colorForVariable(key: string) { return CHART_COLORS[hash % CHART_COLORS.length]; } +function defaultResultsPaneLayout(): ResultsPaneLayout { + const viewportWidth = typeof window === "undefined" ? 1440 : window.innerWidth; + const viewportHeight = typeof window === "undefined" ? 900 : window.innerHeight; + return { + inspectorWidth: Math.round( + Math.min(560, Math.max(380, viewportWidth * 0.31)), + ), + systemHeight: Math.round( + Math.min(580, Math.max(260, (viewportHeight - 62) * 0.54)), + ), + }; +} + +function loadResultsPaneLayout(): ResultsPaneLayout { + const defaults = defaultResultsPaneLayout(); + try { + const raw = localStorage.getItem(RESULT_PANE_LAYOUT_KEY); + if (!raw) { + return defaults; + } + const value = JSON.parse(raw) as Partial; + return { + inspectorWidth: + typeof value.inspectorWidth === "number" && + Number.isFinite(value.inspectorWidth) + ? value.inspectorWidth + : defaults.inspectorWidth, + systemHeight: + typeof value.systemHeight === "number" && + Number.isFinite(value.systemHeight) + ? value.systemHeight + : defaults.systemHeight, + }; + } catch { + return defaults; + } +} + +function storeResultsPaneLayout(layout: ResultsPaneLayout) { + try { + localStorage.setItem(RESULT_PANE_LAYOUT_KEY, JSON.stringify(layout)); + } catch { + // Keep the active layout in memory when browser storage is unavailable. + } +} + +function clampResultsPaneLayout( + layout: ResultsPaneLayout, + workbenchWidth: number, + workbenchHeight: number, +): ResultsPaneLayout { + const maximumInspectorWidth = Math.max( + MIN_INSPECTOR_WIDTH, + workbenchWidth - PANE_SPLITTER_SIZE - MIN_CHART_WIDTH, + ); + const maximumSystemHeight = Math.max( + MIN_SYSTEM_HEIGHT, + workbenchHeight - PANE_SPLITTER_SIZE - MIN_VARIABLE_HEIGHT, + ); + return { + inspectorWidth: Math.round( + Math.min( + maximumInspectorWidth, + Math.max(MIN_INSPECTOR_WIDTH, layout.inspectorWidth), + ), + ), + systemHeight: Math.round( + Math.min( + maximumSystemHeight, + Math.max(MIN_SYSTEM_HEIGHT, layout.systemHeight), + ), + ), + }; +} + +function sameResultsPaneLayout( + first: ResultsPaneLayout, + second: ResultsPaneLayout, +) { + return ( + first.inspectorWidth === second.inspectorWidth && + first.systemHeight === second.systemHeight + ); +} + function clampWindowCoordinate( value: number, containerSize: number, @@ -850,6 +4171,38 @@ function clampWindowCoordinate( ); } +function clampChartWindowDimension( + requestedSize: number, + availableSize: number, + minimumSize: number, +) { + const maximumSize = Math.max(1, availableSize); + const effectiveMinimum = Math.min(minimumSize, maximumSize); + return Math.round( + Math.min(maximumSize, Math.max(effectiveMinimum, requestedSize)), + ); +} + +function clampChartWindowDimensions( + requestedWidth: number, + requestedHeight: number, + workspaceWidth: number, + workspaceHeight: number, +) { + return { + width: clampChartWindowDimension( + requestedWidth, + workspaceWidth - 24, + MIN_CHART_WINDOW_WIDTH, + ), + height: clampChartWindowDimension( + requestedHeight, + workspaceHeight - 24, + MIN_CHART_WINDOW_HEIGHT, + ), + }; +} + function loadChartWindows(snapshotId: string): ChartWindow[] { try { const raw = sessionStorage.getItem(`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`); @@ -860,7 +4213,7 @@ function loadChartWindows(snapshotId: string): ChartWindow[] { if (!Array.isArray(value)) { return []; } - return value.filter(isChartWindow); + return value.filter(isStoredChartWindow).map(normalizeStoredChartWindow); } catch { return []; } @@ -889,7 +4242,77 @@ function storeChartWindows(snapshotId: string, windows: ChartWindow[]) { } } -function isChartWindow(value: unknown): value is ChartWindow { +type StoredChartWindow = Pick< + ChartWindow, + "id" | "variableKey" | "x" | "y" | "zIndex" +> & + Partial< + Pick< + ChartWindow, + | "kind" + | "variableKeys" + | "hiddenVariableKeys" + | "variableUnits" + | "width" + | "height" + | "unit" + | "cursorEnabled" + > + >; + +function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow { + const kind = + window.kind === "multi" || window.kind === "mixed" + ? window.kind + : "single"; + const storedVariableKeys = Array.isArray(window.variableKeys) + ? window.variableKeys.filter( + (key, index, keys) => + typeof key === "string" && key.length > 0 && keys.indexOf(key) === index, + ) + : []; + const variableKeys = + kind !== "single" && storedVariableKeys.length > 0 + ? storedVariableKeys + : [window.variableKey]; + const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys) + ? window.hiddenVariableKeys.filter( + (key, index, keys) => + typeof key === "string" && + variableKeys.includes(key) && + keys.indexOf(key) === index, + ) + : []; + const variableUnits = + window.variableUnits && isRecord(window.variableUnits) + ? Object.fromEntries( + Object.entries(window.variableUnits).filter( + ([key, value]) => + variableKeys.includes(key) && typeof value === "string", + ), + ) + : {}; + return { + ...window, + kind, + variableKey: variableKeys[0], + variableKeys, + hiddenVariableKeys, + variableUnits, + width: + typeof window.width === "number" && Number.isFinite(window.width) + ? window.width + : CHART_WINDOW_WIDTH, + height: + typeof window.height === "number" && Number.isFinite(window.height) + ? window.height + : CHART_WINDOW_HEIGHT, + unit: typeof window.unit === "string" ? window.unit : "", + cursorEnabled: window.cursorEnabled === true, + }; +} + +function isStoredChartWindow(value: unknown): value is StoredChartWindow { if (!value || typeof value !== "object") { return false; } @@ -906,6 +4329,33 @@ function isChartWindow(value: unknown): value is ChartWindow { ); } +function simulationResultStatus( + result: ResultPayload, +): NonNullable { + return result.status ?? (result.success ? "completed" : "failed"); +} + +function simulationResultStatusText( + result: ResultPayload, + status: Exclude, "completed">, +) { + const lastSeriesTime = result.series.time?.at(-1); + const simulatedUntil = Number.isFinite(result.simulatedUntil) + ? Number(result.simulatedUntil) + : Number.isFinite(lastSeriesTime) + ? Number(lastSeriesTime) + : null; + const timeText = simulatedUntil === null + ? "" + : ` · 计算至 t=${simulatedUntil.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} s`; + const labels = { + stopped: "部分结果:仿真已手动终止", + stalled: "部分结果:仿真因进度异常终止", + failed: "部分结果:仿真因求解异常终止", + } as const; + return `${labels[status]}${timeText}`; +} + function formatResultTimestamp(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) @@ -930,3 +4380,19 @@ function formatChartNumber(value: number) { } return value.toFixed(3).replace(/\.?0+$/, ""); } + +function formatCursorNumber(value: number) { + if (!Number.isFinite(value)) { + return "-"; + } + if ( + Math.abs(value) >= 1e7 || + (Math.abs(value) > 0 && Math.abs(value) < 1e-5) + ) { + return value.toExponential(6); + } + return value.toLocaleString("zh-CN", { + maximumSignificantDigits: 8, + useGrouping: false, + }); +} diff --git a/frontend/src/WorkspaceViewTabs.tsx b/frontend/src/WorkspaceViewTabs.tsx index 079ecb5..76d1c8f 100644 --- a/frontend/src/WorkspaceViewTabs.tsx +++ b/frontend/src/WorkspaceViewTabs.tsx @@ -3,12 +3,14 @@ export type WorkspaceView = "modeling" | "results"; type WorkspaceViewTabsProps = { activeView: WorkspaceView; hasResults: boolean; + hasUnreadResults?: boolean; onViewChange: (view: WorkspaceView) => void; }; export function WorkspaceViewTabs({ activeView, hasResults, + hasUnreadResults = false, onViewChange, }: WorkspaceViewTabsProps) { return ( @@ -23,14 +25,18 @@ export function WorkspaceViewTabs({ 建模
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3c10ccc..f509c0a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -27,17 +27,18 @@ textarea { .app-shell { display: grid; - grid-template-rows: auto 42px minmax(0, 1fr); + grid-template-rows: 62px 42px minmax(0, 1fr); width: 100%; height: 100%; + min-width: 980px; } .topbar { - display: flex; + display: grid; + grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr); align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 72px; + gap: 18px; + min-height: 0; padding: 8px 14px; border-bottom: 1px solid #cfd8e3; background: #ffffff; @@ -99,13 +100,14 @@ textarea { height: 6px; border-radius: 50%; background: #2f7d55; + box-shadow: 0 0 0 2px rgba(47, 125, 85, 0.14); } .topbar-actions { display: flex; - flex-wrap: wrap; justify-content: flex-end; align-items: center; + justify-self: end; gap: 8px; } @@ -224,8 +226,7 @@ textarea { white-space: nowrap; } -.toolbar-item:hover::after, -.toolbar-item:focus-within::after { +.toolbar-item:hover::after { opacity: 1; transform: translate(-50%, 0); } @@ -267,16 +268,90 @@ textarea { box-shadow: inset 0 0 0 1px rgba(29, 111, 184, 0.08); } +.editor-tool-button.active:hover:not(:disabled) { + border-color: #155a94; + color: #0f4f82; + background: #cfe7f8; +} + .editor-tool-button.accent:not(.active) { color: #26724a; } +.editor-tool-button.primary { + border-color: #1d6fb8; + color: #ffffff; + background: #1d6fb8; +} + +.editor-tool-button.primary:hover:not(:disabled) { + border-color: #155a94; + color: #ffffff; + background: #155a94; +} + .workbench { + --palette-width: 230px; + --properties-width: 330px; display: grid; - grid-template-columns: 230px minmax(0, 1fr) 330px; + grid-template-columns: + var(--palette-width) 7px minmax(0, 1fr) 7px + var(--properties-width); min-height: 0; } +.workbench.resizing-palette-width, +.workbench.resizing-properties-width { + cursor: col-resize; + user-select: none; +} + +.modeling-pane-splitter { + position: relative; + z-index: 8; + display: grid; + width: 7px; + min-width: 7px; + height: 100%; + padding: 0; + border: 0; + place-items: center; + background: #eef2f6; + cursor: col-resize; + touch-action: none; +} + +.modeling-pane-splitter::before { + position: absolute; + inset: 0 auto 0 3px; + width: 1px; + background: #c7d1dc; + content: ""; +} + +.modeling-pane-splitter span { + position: relative; + width: 3px; + height: 42px; + border-radius: 2px; + background: #9eacbb; + opacity: 0; + transition: opacity 120ms ease, background 120ms ease; +} + +.modeling-pane-splitter:hover span, +.modeling-pane-splitter:focus-visible span, +.workbench.resizing-palette-width .modeling-pane-splitter:first-of-type span, +.workbench.resizing-properties-width .modeling-pane-splitter:last-of-type span { + background: #1d6fb8; + opacity: 1; +} + +.modeling-pane-splitter:focus-visible { + outline: 2px solid #1d6fb8; + outline-offset: -2px; +} + .palette, .properties { min-width: 0; @@ -287,11 +362,11 @@ textarea { } .palette { - border-right: 1px solid #cfd8e3; + border-right: 0; } .properties { - border-left: 1px solid #cfd8e3; + border-left: 0; } .status-strip { @@ -681,6 +756,26 @@ textarea { .flow-canvas .react-flow__connection-path { stroke: #64748b; stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + transition: stroke 120ms ease-out, stroke-width 120ms ease-out, filter 120ms ease-out; + vector-effect: non-scaling-stroke; +} + +.flow-canvas .react-flow__edge-interaction { + cursor: pointer; +} + +.flow-canvas .react-flow__edge:hover .react-flow__edge-path { + stroke: #3977a8; + stroke-width: 2.25; +} + +.flow-canvas .react-flow__edge.selected .react-flow__edge-path, +.flow-canvas .react-flow__edge.editor-edge-selected .react-flow__edge-path { + stroke: #1d6fb8; + stroke-width: 3.25; + filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42)); } .field { @@ -924,18 +1019,91 @@ textarea { font-size: 11px; } +.results-run-meta .results-run-status { + padding: 2px 5px; + border: 1px solid #d9a441; + border-radius: 4px; + color: #80591b; + background: #fff8e8; + font-weight: 700; +} + +.results-run-meta .results-run-status.stalled, +.results-run-meta .results-run-status.failed { + border-color: #d99b96; + color: #9f2f28; + background: #fff1f0; +} + .results-run-meta.empty { color: #7b8795; } +.results-run-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.results-file-toolbar { + display: flex; + align-items: center; + gap: 3px; + padding: 2px; + border: 1px solid #c4cfdb; + border-radius: 6px; + background: #f6f8fa; +} + +.results-file-toolbar button { + display: grid; + flex: 0 0 30px; + width: 30px; + height: 28px; + padding: 0; + border: 1px solid #b9c7d6; + border-radius: 4px; + place-items: center; + color: #214f78; + background: #ffffff; + cursor: pointer; +} + +.results-file-toolbar button:hover:not(:disabled) { + border-color: #7da8ce; + background: #eef6fc; +} + +.results-file-toolbar button:disabled { + color: #8492a2; + background: #f3f5f7; + cursor: wait; +} + +.results-file-input { + display: none; +} + .results-workbench { display: grid; - grid-template-columns: - minmax(360px, 0.85fr) - minmax(480px, 1.15fr) - 290px; min-width: 0; min-height: 0; + overflow: hidden; + background: #dfe5ec; +} + +.results-workbench.resizing-inspector-width, +.results-workbench.resizing-system-height { + user-select: none; +} + +.results-inspector-pane { + display: grid; + min-width: 0; + min-height: 0; + overflow: hidden; } .results-system-panel, @@ -949,16 +1117,81 @@ textarea { } .results-system-panel { - border-right: 1px solid #cfd8e3; + overflow: hidden; } .results-chart-panel { - border-right: 1px solid #cfd8e3; + min-width: 0; + overflow: hidden; } .results-variable-panel { - display: block; + overflow: hidden; +} + +.results-variable-content { + min-width: 0; + min-height: 0; overflow: auto; + background: #fbfcfd; +} + +.results-pane-splitter { + position: relative; + z-index: 30; + display: grid; + place-items: center; + padding: 0; + border: 0; + outline: none; + background: #e4e9ef; + touch-action: none; +} + +.results-pane-splitter::before { + position: absolute; + inset: 0; + background: transparent; + content: ""; +} + +.results-pane-splitter span { + position: relative; + display: block; + border-radius: 1px; + background: #91a0b0; +} + +.results-pane-splitter.vertical { + cursor: col-resize; +} + +.results-pane-splitter.vertical span { + width: 2px; + height: 34px; +} + +.results-pane-splitter.horizontal { + cursor: row-resize; +} + +.results-pane-splitter.horizontal span { + width: 34px; + height: 2px; +} + +.results-pane-splitter:hover, +.results-pane-splitter:focus-visible, +.results-workbench.resizing-inspector-width > .results-pane-splitter.vertical, +.results-workbench.resizing-system-height .results-pane-splitter.horizontal { + background: #d7e9f7; +} + +.results-pane-splitter:hover span, +.results-pane-splitter:focus-visible span, +.results-workbench.resizing-inspector-width > .results-pane-splitter.vertical span, +.results-workbench.resizing-system-height .results-pane-splitter.horizontal span { + background: #1d6fb8; } .results-section-heading { @@ -982,6 +1215,114 @@ textarea { font-size: 11px; } +.results-chart-heading { + position: relative; + z-index: 25; + overflow: visible; +} + +.results-chart-heading-tools { + display: flex; + align-items: center; + gap: 8px; +} + +.results-multi-create { + position: relative; +} + +.results-multi-create > button { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + color: #526172; + background: transparent; + cursor: pointer; +} + +.results-multi-create > button:hover:not(:disabled), +.results-multi-create > button.active { + border-color: #74a9d7; + color: #0f5f9f; + background: #e8f3fc; +} + +.results-multi-create > button:disabled { + cursor: default; + opacity: 0.38; +} + +.results-multi-create-menu { + position: absolute; + top: 32px; + right: 0; + z-index: 40; + width: 210px; + max-height: 240px; + overflow: auto; + border: 1px solid #8fa1b4; + border-radius: 5px; + color: #26384b; + background: #ffffff; + box-shadow: 0 8px 22px rgba(30, 41, 59, 0.2); +} + +.results-multi-create-title { + height: 31px; + padding: 8px 10px; + border-bottom: 1px solid #dbe2ea; + color: #5e6e80; + background: #f3f6f9; + font-size: 10px; + font-weight: 650; +} + +.results-multi-create-menu > button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-bottom: 1px solid #edf0f4; + color: #28394c; + background: #ffffff; + cursor: pointer; +} + +.results-multi-create-menu > button:last-child { + border-bottom: 0; +} + +.results-multi-create-menu > button:hover, +.results-multi-create-menu > button:focus-visible { + outline: none; + background: #edf4fa; +} + +.results-multi-create-menu > button strong { + min-width: 0; + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.results-multi-create-menu > button span { + flex: 0 0 auto; + font-size: 10px; +} + +.results-multi-create-menu.variables { + width: 320px; +} + .results-system-canvas { position: relative; min-width: 0; @@ -1013,6 +1354,7 @@ textarea { .results-chart-workspace { position: relative; + z-index: 0; min-width: 0; min-height: 0; overflow: hidden; @@ -1020,6 +1362,7 @@ textarea { background-image: radial-gradient(#cbd5e1 0.7px, transparent 0.7px); background-position: 0 0; background-size: 18px 18px; + isolation: isolate; } .results-chart-workspace.empty { @@ -1039,8 +1382,6 @@ textarea { position: absolute; display: grid; grid-template-rows: 44px minmax(0, 1fr); - width: min(420px, calc(100% - 24px)); - height: 260px; overflow: hidden; border: 1px solid #99a8b8; border-radius: 6px; @@ -1048,6 +1389,12 @@ textarea { box-shadow: 0 7px 20px rgba(30, 41, 59, 0.18); } +.result-chart-window.drop-target { + border-color: #1d6fb8; + box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.2), + 0 7px 20px rgba(30, 41, 59, 0.18); +} + .result-chart-window-header { display: flex; align-items: center; @@ -1062,24 +1409,51 @@ textarea { user-select: none; } -.result-chart-window-header > div { +.result-chart-window-title { + flex: 1 1 auto; min-width: 0; } -.result-chart-window-header strong, -.result-chart-window-header span { +.result-chart-window-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 2px; +} + +.result-chart-window-actions select { + width: 74px; + height: 28px; + padding: 0 20px 0 7px; + border: 1px solid #c5cfda; + border-radius: 4px; + color: #34465a; + background: #ffffff; + font: inherit; + font-size: 10px; + cursor: pointer; +} + +.result-chart-window-actions select:focus-visible { + border-color: #1d6fb8; + outline: 2px solid rgba(29, 111, 184, 0.18); + outline-offset: 1px; +} + +.result-chart-window-title strong, +.result-chart-window-title span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.result-chart-window-header strong { +.result-chart-window-title strong { color: #1f2933; font-size: 12px; } -.result-chart-window-header span { +.result-chart-window-title span { margin-top: 1px; color: #64748b; font-size: 10px; @@ -1101,45 +1475,380 @@ textarea { .result-chart-window-header button:hover { border-color: #c5cfda; - color: #b42318; + color: #1d6fb8; background: #ffffff; } -.result-chart-body { - min-width: 0; - min-height: 0; - padding: 5px 8px 7px 4px; +.result-chart-window-header button.cursor.active { + border-color: #74a9d7; + color: #0f5f9f; + background: #e8f3fc; } -.result-chart-body svg { +.result-chart-window-header button.active { + border-color: #74a9d7; + color: #0f5f9f; + background: #e8f3fc; +} + +.result-chart-multi-picker { + position: relative; + flex: 0 0 auto; +} + +.result-chart-multi-menu { + position: absolute; + top: 31px; + right: 0; + z-index: 20; + width: 340px; + max-width: min(340px, calc(100vw - 32px)); + overflow: hidden; + border: 1px solid #8fa1b4; + border-radius: 5px; + color: #26384b; + background: #ffffff; + box-shadow: 0 8px 22px rgba(30, 41, 59, 0.2); + cursor: default; +} + +.result-chart-multi-menu-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 32px; + padding: 0 9px; + border-bottom: 1px solid #dbe2ea; + background: #f3f6f9; + font-size: 10px; +} + +.result-chart-multi-menu-title span { + color: #5e6e80; +} + +.result-chart-multi-menu-title strong { + font-size: 10px; +} + +.result-chart-multi-menu-list { + max-height: 156px; + overflow: auto; + padding: 4px; +} + +.result-chart-multi-menu-row { + display: grid; + grid-template-columns: 16px 5px minmax(0, 1fr) 78px 26px; + gap: 7px; + align-items: center; + min-height: 36px; + padding: 4px 6px; + border-radius: 3px; +} + +.result-chart-multi-menu-row:hover { + background: #edf4fa; +} + +.result-chart-multi-menu-row > input { + width: 14px; + height: 14px; + margin: 0; + accent-color: #1d6fb8; + cursor: pointer; +} + +.result-chart-multi-menu-list .result-variable-swatch { + width: 4px; + height: 25px; + border-radius: 2px; +} + +.result-chart-multi-menu-label { + min-width: 0; +} + +.result-chart-multi-menu-label b, +.result-chart-multi-menu-label small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-chart-multi-menu-label b { + color: #27384b; + font-size: 10px; +} + +.result-chart-multi-menu-label small { + margin-top: 1px; + color: #718093; + font-size: 9px; +} + +.result-chart-window-actions .result-chart-multi-menu-row > select { + grid-column: 4; + width: 78px; + height: 26px; + padding: 0 18px 0 5px; + border: 1px solid #c5cfda; + border-radius: 4px; + color: #34465a; + background: #ffffff; + font: inherit; + font-size: 9px; +} + +.result-chart-multi-menu-row > .remove { + grid-column: 5; + width: 26px; + height: 26px; + color: #8b4650; +} + +.result-chart-multi-menu-row > .remove:hover:not(:disabled) { + border-color: #d5a1a6; + color: #a52f3a; + background: #fff1f2; +} + +.result-chart-multi-menu-row > .remove:disabled { + cursor: default; + opacity: 0.3; +} + +.result-chart-window-header button.close:hover { + color: #b42318; +} + +.result-chart-body { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + padding: 5px 8px 7px 4px; + -webkit-user-select: none; + user-select: none; +} + +.result-chart-body > svg { display: block; width: 100%; height: 100%; + touch-action: none; +} + +.result-chart-body > svg text { + pointer-events: none; + -webkit-user-select: none; + user-select: none; +} + +.result-chart-body > svg.cursor-enabled { + cursor: crosshair; } .result-chart-grid-line { - stroke: #e1e7ee; + stroke: #dce4ec; stroke-width: 1; vector-effect: non-scaling-stroke; } .result-chart-axis { - stroke: #76869a; + stroke: #64758a; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.result-chart-tick { + stroke: #64758a; stroke-width: 1; vector-effect: non-scaling-stroke; } .result-chart-label, -.result-chart-unit { +.result-chart-unit, +.result-chart-title, +.result-chart-axis-title { fill: #5b6878; font-family: inherit; font-size: 9px; } +.result-chart-cursor line { + stroke: #0f766e; + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} + +.result-chart-cursor circle { + stroke: #ffffff; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.result-chart-cursor-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; + touch-action: none; + vector-effect: non-scaling-stroke; +} + +.result-chart-cursor-hit.x { + cursor: ew-resize; +} + +.result-chart-cursor-hit.y { + cursor: ns-resize; +} + +.result-chart-cursor-panel { + position: absolute; + z-index: 3; + width: 154px; + max-width: calc(100% - 12px); + overflow: hidden; + border: 1px solid #7f91a5; + border-radius: 4px; + color: #25364a; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 3px 10px rgba(30, 41, 59, 0.16); + user-select: none; +} + +.result-chart-cursor-panel-header { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + height: 25px; + padding: 0 7px; + border: 0; + border-bottom: 1px solid #d4dde6; + border-radius: 0; + color: #34465a; + background: #edf2f6; + font: inherit; + font-size: 10px; + font-weight: 650; + cursor: move; + touch-action: none; +} + +.result-chart-cursor-panel dl { + display: grid; + gap: 3px; + margin: 0; + padding: 6px 8px 7px; + font-size: 10px; +} + +.result-chart-cursor-panel dl > div { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 5px; + align-items: baseline; +} + +.result-chart-cursor-panel dt { + color: #64748b; + font-weight: 700; +} + +.result-chart-cursor-panel dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: #18293c; + font-variant-numeric: tabular-nums; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-chart-cursor-panel.multi { + width: 208px; +} + +.result-chart-cursor-panel.multi dl { + max-height: 150px; + overflow: auto; +} + +.result-chart-cursor-panel.multi dl > div { + grid-template-columns: 24px minmax(0, 1fr); +} + +.result-chart-cursor-panel.multi dd { + display: flex; + align-items: center; + gap: 5px; +} + +.result-chart-cursor-value-swatch { + flex: 0 0 auto; + width: 10px; + height: 3px; + border-radius: 1px; +} + +.result-chart-resize-handle { + position: absolute; + right: 0; + bottom: 0; + z-index: 5; + display: grid; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-top: 1px solid #d5dee7; + border-left: 1px solid #d5dee7; + border-radius: 4px 0 0; + place-items: center; + color: #56697c; + background: rgba(246, 248, 250, 0.94); + cursor: nwse-resize; + touch-action: none; +} + +.result-chart-resize-handle:hover { + color: #0f5f9f; + background: #e8f3fc; +} + +.result-chart-title { + fill: #263648; + font-size: 10px; + font-weight: 650; +} + +.result-chart-legend text { + fill: #34465a; + font-family: inherit; + font-size: 8px; +} + +.result-chart-stack-divider { + stroke: #aebbc8; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + .result-chart-unit { font-weight: 650; } +.result-chart-axis-title { + fill: #46576a; + font-weight: 600; +} + .result-chart-empty { display: grid; height: 100%; @@ -1149,10 +1858,14 @@ textarea { } .results-selected-component { - display: grid; - gap: 3px; - padding: 13px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 46px; + padding: 8px 12px; border-bottom: 1px solid #e3e8ef; + background: #ffffff; } .results-selected-component strong { @@ -1164,6 +1877,7 @@ textarea { } .results-selected-component span { + flex: 0 0 auto; color: #64748b; font-size: 11px; } @@ -1182,6 +1896,7 @@ textarea { .results-variable-list { display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; } @@ -1280,21 +1995,419 @@ textarea { background: #f2f6f9; } +.simulation-console { + position: fixed; + z-index: 200; + overflow: hidden; + border: 1px solid #718297; + border-radius: 6px; + background: #ffffff; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22); +} + +.simulation-console.dragging { + user-select: none; +} + +.simulation-console.normal { + bottom: 14px; + left: 50%; + display: grid; + grid-template-rows: 38px 50px minmax(0, 1fr); + width: min(760px, calc(100vw - 28px)); + height: 270px; + transform: translateX(-50%); +} + +.simulation-console.maximized { + inset: 12px; + display: grid; + grid-template-rows: 38px 50px minmax(0, 1fr); +} + +.simulation-console.minimized { + right: 14px; + bottom: 14px; + width: min(480px, calc(100vw - 28px)); + height: 40px; +} + +.simulation-console.minimized.has-unread { + border-color: #1d6fb8; + animation: simulation-console-attention 1.2s ease-out 2; +} + +.simulation-console-collapsed { + display: grid; + grid-template-columns: 34px 34px minmax(0, 1fr); + align-items: center; + width: 100%; + height: 100%; + background: #f7f9fb; +} + +.simulation-console-collapsed-move, +.simulation-console-collapsed-stop, +.simulation-console-collapsed-open { + height: 100%; + padding: 0; + border: 0; + color: #253447; + background: transparent; + font: inherit; +} + +.simulation-console-collapsed-move { + display: grid; + place-items: center; + border-right: 1px solid #d4dde7; + color: #526172; + cursor: grab; + touch-action: none; +} + +.simulation-console-collapsed-stop { + display: grid; + place-items: center; + border-right: 1px solid #d4dde7; + color: #9aa5b1; + cursor: default; +} + +.simulation-console-collapsed-stop.active { + color: #b42318; + background: #fff1f0; + cursor: pointer; +} + +.simulation-console-collapsed-open { + display: grid; + grid-template-columns: 18px auto minmax(0, 1fr) auto 18px; + align-items: center; + gap: 8px; + min-width: 0; + padding: 0 10px; + cursor: pointer; +} + +.simulation-console-collapsed-move:hover, +.simulation-console-collapsed-open:hover { + background: #edf3f8; +} + +.simulation-console-collapsed-stop.active:hover { + color: #941f17; + background: #ffe6e3; +} + +.simulation-console-collapsed-open > svg:last-child { + grid-column: 5; +} + +.simulation-console-collapsed strong { + font-size: 12px; + white-space: nowrap; +} + +.simulation-console-collapsed-message, +.simulation-console-collapsed-status { + min-width: 0; + overflow: hidden; + color: #657487; + font-size: 11px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simulation-console-collapsed-status { + color: #1767a8; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.simulation-console-unread { + display: inline-flex; + align-items: center; + gap: 3px; + min-width: 24px; + height: 20px; + padding: 0 5px; + border-radius: 10px; + color: #ffffff; + background: #c13d35; + font-size: 10px; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.simulation-console-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 0 6px 0 11px; + border-bottom: 1px solid #d4dde7; + background: #f4f7f9; +} + +.simulation-console-title, +.simulation-console-actions { + display: flex; + align-items: center; +} + +.simulation-console-title { + min-width: 0; + gap: 7px; +} + +.simulation-console-title strong { + font-size: 12px; +} + +.simulation-console-title span { + color: #1767a8; + font-size: 10px; + font-weight: 700; +} + +.simulation-console-actions { + flex: 0 0 auto; + gap: 2px; +} + +.simulation-console-actions button { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + color: #526172; + background: transparent; + cursor: pointer; +} + +.simulation-console-actions button:hover:not(:disabled) { + border-color: #bdc9d5; + color: #155a94; + background: #ffffff; +} + +.simulation-console-actions button:disabled { + cursor: default; + opacity: 0.38; +} + +.simulation-console-actions .simulation-console-move-button { + cursor: grab; + touch-action: none; +} + +.simulation-console-actions .simulation-console-stop-button.active { + border-color: #e2aaa5; + color: #b42318; + background: #fff1f0; +} + +.simulation-console-actions .simulation-console-stop-button.active:hover:not(:disabled) { + border-color: #d47c74; + color: #941f17; + background: #ffe6e3; +} + +.simulation-console.dragging .simulation-console-move-button, +.simulation-console.dragging .simulation-console-collapsed-move { + color: #155a94; + background: #eaf3fa; + cursor: grabbing; +} + +.simulation-console-progress { + padding: 8px 11px 9px; + border-bottom: 1px solid #dbe2ea; + background: #ffffff; +} + +.simulation-console-progress-label { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + color: #526172; + font-size: 11px; +} + +.simulation-console-progress-label span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simulation-console-progress-label strong { + flex: 0 0 auto; + color: #334155; + font-variant-numeric: tabular-nums; +} + +.simulation-console-progress-metrics { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 10px; + font-variant-numeric: tabular-nums; +} + +.simulation-console-progress-label .simulation-console-progress-metrics span { + overflow: visible; + color: #1767a8; + font-weight: 650; + text-overflow: clip; +} + +.simulation-console-progress-track { + height: 6px; + overflow: hidden; + border-radius: 3px; + background: #e2e8f0; +} + +.simulation-console-progress-track span { + display: block; + height: 100%; + background: #2478b8; + transition: width 160ms ease-out; +} + +.simulation-console-progress.success .simulation-console-progress-track span { + background: #2f7d55; +} + +.simulation-console-progress.warning .simulation-console-progress-track span { + background: #b7791f; +} + +.simulation-console-progress.error .simulation-console-progress-track span { + background: #c13d35; +} + +.simulation-console-log { + min-width: 0; + min-height: 0; + padding: 5px 0 8px; + overflow: auto; + color: #273548; + background: #fbfcfd; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 11px; +} + +.simulation-console-empty { + padding: 18px 12px; + color: #8793a2; + text-align: center; +} + +.simulation-console-entry { + display: grid; + grid-template-columns: 68px 38px minmax(0, 1fr); + gap: 8px; + align-items: start; + min-height: 25px; + padding: 5px 12px 5px 9px; + border-left: 3px solid #5c8fbd; + line-height: 1.35; +} + +.simulation-console-entry:nth-child(even) { + background: #f5f7fa; +} + +.simulation-console-entry time { + color: #7a8796; + font-variant-numeric: tabular-nums; +} + +.simulation-console-entry-level { + color: #526172; + font-weight: 700; +} + +.simulation-console-entry.success { + border-left-color: #2f7d55; +} + +.simulation-console-entry.success .simulation-console-entry-level { + color: #267047; +} + +.simulation-console-entry.warning { + border-left-color: #b7791f; +} + +.simulation-console-entry.warning .simulation-console-entry-level { + color: #94631b; +} + +.simulation-console-entry.error { + border-left-color: #c13d35; +} + +.simulation-console-entry.error .simulation-console-entry-level { + color: #a9322d; +} + +@keyframes simulation-console-attention { + 0%, + 100% { + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22); + } + 50% { + box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.2), 0 10px 28px rgba(15, 23, 42, 0.22); + } +} + +@media (prefers-reduced-motion: reduce) { + .simulation-console.minimized.has-unread { + animation: none; + } + + .simulation-console-progress-track span { + transition: none; + } +} + @media (max-width: 1100px) { - .workbench { - grid-template-columns: 180px minmax(0, 1fr) 280px; - } - - .topbar { - align-items: flex-start; - padding-top: 8px; - } - .project-name-field input { width: 120px; } - .results-workbench { - grid-template-columns: 340px minmax(400px, 1fr) 260px; +} + +@media (max-width: 640px) { + .simulation-console.normal { + bottom: 8px; + width: calc(100vw - 16px); + height: min(300px, calc(100vh - 16px)); + } + + .simulation-console.maximized { + inset: 8px; + } + + .simulation-console.minimized { + right: 8px; + bottom: 8px; + width: calc(100vw - 16px); + } + + .simulation-console-entry { + grid-template-columns: 62px 34px minmax(0, 1fr); + gap: 5px; + padding-right: 8px; } } diff --git a/tests/test_component_metadata.py b/tests/test_component_metadata.py new file mode 100644 index 0000000..6a52cb5 --- /dev/null +++ b/tests/test_component_metadata.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import unittest + +from PythonModels.components.cylinder import Cylinder +from PythonModels.components.orifice import Orifice +from PythonModels.components.pipe import Pipe +from PythonModels.components.resistive_pipe import ResistivePipe +from PythonModels.components.tank import Tank +from PythonModels.components.tee import Tee +from PythonModels.core.medium import IdealGasMedium +from PythonModels.core.ports import PortVariableDefinition +from PythonModels.registry import COMPONENT_MODEL_REGISTRY + + +class ComponentMetadataTests(unittest.TestCase): + def setUp(self) -> None: + self.medium = IdealGasMedium() + + def component_instances(self): + return ( + Cylinder("cylinder_1", self.medium), + Tank("tank_1", self.medium), + Pipe("pipe_dynamic_1", self.medium), + ResistivePipe("pipe_1", self.medium), + Orifice("orifice_1"), + Tee("tee_1"), + ) + + def test_every_component_declares_a_complete_model_contract(self) -> None: + for component in self.component_instances(): + with self.subTest(component=component.name): + for attribute in ( + "MODEL_TYPE", + "PORTS", + "PARAMETERS", + "RESULT_VARIABLES", + ): + self.assertIn(attribute, type(component).__dict__) + self.assertTrue(component.MODEL_TYPE) + self.assertEqual(component.model_type, component.MODEL_TYPE) + self.assertEqual(component.port_definitions, component.PORTS) + self.assertEqual( + set(component.parameter_values), + {definition.name for definition in component.PARAMETERS}, + ) + self.assertTrue( + all(definition.label for definition in component.PARAMETERS) + ) + self.assertTrue( + all(definition.quantity for definition in component.PARAMETERS) + ) + + def test_registry_reuses_component_parameter_and_port_declarations(self) -> None: + registered_classes = { + "cylinder": Cylinder, + "tank": Tank, + "pipe": ResistivePipe, + "orifice": Orifice, + "tee": Tee, + } + for model_type, component_class in registered_classes.items(): + with self.subTest(model_type=model_type): + spec = COMPONENT_MODEL_REGISTRY[model_type] + self.assertEqual(spec.model_type, component_class.MODEL_TYPE) + self.assertEqual(spec.ports, component_class.PORTS) + self.assertEqual(spec.parameters, component_class.PARAMETERS) + component = spec.create(f"{model_type}_1", self.medium, {}) + self.assertEqual( + component.parameter_values, + { + definition.name: definition.default + for definition in component_class.PARAMETERS + }, + ) + + def test_result_metadata_exactly_describes_each_exposed_value(self) -> None: + for component in self.component_instances(): + with self.subTest(component=component.name): + values = component.result_values() + metadata = component.result_variable_metadata() + keys = [variable.key for variable in metadata] + self.assertEqual(len(keys), len(set(keys))) + self.assertEqual( + set(values), + { + variable.key.removeprefix(f"{component.name}.") + for variable in metadata + }, + ) + self.assertTrue(all(variable.label for variable in metadata)) + self.assertTrue(all(variable.quantity for variable in metadata)) + + def test_storage_and_port_variables_include_display_metadata(self) -> None: + cylinder = Cylinder("cylinder_1", self.medium) + metadata = { + variable.key: variable + for variable in cylinder.result_variable_metadata() + } + + pressure = metadata["cylinder_1.p"] + self.assertEqual(pressure.scope, "component") + self.assertEqual(pressure.label, "压力") + self.assertEqual(pressure.quantity, "pressure") + self.assertEqual(pressure.unit, "Pa") + + mass_flow = metadata["cylinder_1.port_b.m_flow"] + self.assertEqual(mass_flow.scope, "port") + self.assertEqual(mass_flow.port_name, "port_b") + self.assertEqual(mass_flow.label, "质量流量") + self.assertEqual(mass_flow.quantity, "mass_flow") + self.assertEqual(mass_flow.unit, "kg/s") + + def test_port_display_metadata_is_not_part_of_the_physical_contract(self) -> None: + first = PortVariableDefinition( + "p", + "effort", + "equal", + label="压力", + quantity="pressure", + unit="Pa", + ) + second = PortVariableDefinition( + "p", + "effort", + "equal", + label="Pressure", + quantity="absolute_pressure", + unit="kPa", + ) + + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generic_system_xml_simulation.py b/tests/test_generic_system_xml_simulation.py index 7c92116..ae0d2e6 100644 --- a/tests/test_generic_system_xml_simulation.py +++ b/tests/test_generic_system_xml_simulation.py @@ -1,15 +1,26 @@ from __future__ import annotations import asyncio +import json +import threading +import time import unittest +from unittest.mock import patch +from uuid import uuid4 from fastapi import HTTPException, Request from app.main import ( ReactFlowEdgePayload, ReactFlowProjectPayload, + SimulationCancellationPayload, + _mark_simulation_task_result, + _register_simulation_task, build_reactflow_system_xml, + cancel_system_xml_simulation, compile_reactflow_network, + get_system_xml_simulation, + simulation_event_stream, simulate_system_xml, ) from PythonModels.components.resistive_pipe import ResistivePipe @@ -242,6 +253,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None: network = compile_reactflow_network(chain_project()) + progress: list[tuple[float, str]] = [] result = GenericFluidSystem(network).simulate( SolveIVPConfig( t_start=0.0, @@ -250,9 +262,20 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): max_step=0.001, ), sample_step=0.005, + progress_callback=lambda value, phase: progress.append((value, phase)), ) self.assertTrue(result.success) + self.assertEqual(progress[0], (0.0, "initializing")) + self.assertEqual(progress[-1], (1.0, "complete")) + self.assertTrue( + all( + current[0] <= following[0] + for current, following in zip(progress, progress[1:]) + ) + ) + self.assertIn("integrating", {phase for _, phase in progress}) + self.assertIn("postprocessing", {phase for _, phase in progress}) self.assertLess(result.series["cylinder_1.p"][-1], 500000.0) self.assertGreater(result.series["tank_1.p"][-1], 100000.0) total_mass = [ @@ -276,6 +299,58 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): 1e-7, ) + def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None: + cancel_event = threading.Event() + + def request_cancel_after_progress(progress: float, phase: str) -> None: + if phase == "integrating" and progress >= 0.2: + cancel_event.set() + + result = GenericFluidSystem( + compile_reactflow_network(chain_project()) + ).simulate( + SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001), + sample_step=0.005, + progress_callback=request_cancel_after_progress, + cancel_check=cancel_event.is_set, + ) + + self.assertFalse(result.success) + self.assertEqual(result.status, "cancelled") + self.assertGreaterEqual(result.diagnostics["sampleCount"], 2) + self.assertGreater(result.simulated_until, 0.0) + self.assertLess(result.simulated_until, 0.05) + self.assertEqual( + len(result.series["time"]), + len(result.series["cylinder_1.p"]), + ) + + def test_task_registry_distinguishes_user_stop_and_stalled_stop(self) -> None: + for reason, expected_status in (("user", "stopped"), ("stalled", "stalled")): + simulation_id = f"test-{uuid4().hex}" + task = _register_simulation_task(simulation_id) + response = cancel_system_xml_simulation( + simulation_id, + SimulationCancellationPayload(reason=reason), + ) + self.assertTrue(response["accepted"]) + + result = _mark_simulation_task_result( + task, + { + "success": False, + "status": "cancelled", + "partial": True, + "series": {"time": [0.0, 0.1]}, + }, + ) + snapshot = get_system_xml_simulation(simulation_id) + + self.assertEqual(result["status"], expected_status) + self.assertEqual(snapshot["status"], expected_status) + self.assertEqual(snapshot["cancelReason"], reason) + self.assertEqual(snapshot["result"]["series"]["time"], [0.0, 0.1]) + def test_physical_edge_order_does_not_change_simulation(self) -> None: forward = GenericFluidSystem( compile_reactflow_network(chain_project()) @@ -344,6 +419,129 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): self.assertEqual(response["model"]["pressureFlowSystem"]["unknownCount"], 12) self.assertEqual(response["diagnostics"]["stateCount"], 4) self.assertGreater(response["final"]["tank_1.p"], 100000.0) + variables = {variable["key"]: variable for variable in response["variables"]} + self.assertEqual(set(variables), set(response["series"]) - {"time"}) + self.assertEqual(variables["cylinder_1.p"]["componentId"], "cylinder_1") + self.assertEqual(variables["cylinder_1.p"]["label"], "压力") + self.assertEqual(variables["cylinder_1.p"]["unit"], "Pa") + self.assertEqual(variables["pipe_1.port_a.m_flow"]["scope"], "port") + self.assertEqual( + variables["pipe_1.port_a.m_flow"]["portName"], + "port_a", + ) + + def test_streaming_endpoint_events_have_monotonic_progress_and_result(self) -> None: + project = chain_project() + xml = build_reactflow_system_xml(project) + + events = [json.loads(line) for line in simulation_event_stream(xml)] + + progress_events = [ + event for event in events if event["event"] == "progress" + ] + progress = [event["progress"] for event in progress_events] + self.assertGreater(len(progress), 3) + self.assertTrue( + all(current <= following for current, following in zip(progress, progress[1:])) + ) + preparation_events = [ + event + for event in progress_events + if event["phase"] in {"validation", "compilation", "initialization"} + ] + self.assertTrue(preparation_events) + self.assertTrue(all(event["progress"] == 0 for event in preparation_events)) + timed_events = [ + event + for event in progress_events + if event.get("simulatedTime") is not None + ] + self.assertGreater(len(timed_events), 1) + for event in timed_events: + self.assertEqual(event["totalTime"], project.simulation.t_stop) + expected_progress = round( + 100 + * (event["simulatedTime"] - project.simulation.t_start) + / (project.simulation.t_stop - project.simulation.t_start) + ) + self.assertEqual(event["progress"], expected_progress) + self.assertEqual(events[-1]["event"], "result") + self.assertEqual(events[-1]["progress"], 100) + self.assertTrue(events[-1]["result"]["success"]) + + def test_streaming_task_stop_returns_partial_result(self) -> None: + project = branched_project() + project.simulation.t_stop = 2.0 + project.simulation.step = 0.02 + project.simulation.max_step = 0.01 + xml = build_reactflow_system_xml(project) + simulation_id = f"test-{uuid4().hex}" + task = _register_simulation_task(simulation_id) + events: list[dict[str, object]] = [] + stop_requested = False + + for line in simulation_event_stream(xml, task=task): + event = json.loads(line) + events.append(event) + if ( + not stop_requested + and event["event"] == "progress" + and event["phase"] == "integrating" + and event["progress"] >= 20 + ): + response = cancel_system_xml_simulation( + simulation_id, + SimulationCancellationPayload(reason="user"), + ) + self.assertTrue(response["accepted"]) + stop_requested = True + + result_event = next(event for event in events if event["event"] == "result") + result = result_event["result"] + snapshot = get_system_xml_simulation(simulation_id) + + self.assertTrue(stop_requested) + self.assertEqual(result["status"], "stopped") + self.assertTrue(result["partial"]) + self.assertGreaterEqual(result["diagnostics"]["sampleCount"], 2) + self.assertGreater(result["simulatedUntil"], 0.0) + self.assertLess(result["simulatedUntil"], 2.0) + self.assertEqual(snapshot["status"], "stopped") + self.assertEqual(snapshot["result"]["status"], "stopped") + + def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None: + def delayed_simulation(_xml_bytes, progress_callback, _cancel_check=None): + progress_callback(49, "integrating", "正在进行时间积分与压力流量求解") + time.sleep(0.03) + return {"success": True} + + with ( + patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005), + patch( + "app.main.run_system_xml_simulation", + side_effect=delayed_simulation, + ), + ): + events = [ + json.loads(line) + for line in simulation_event_stream(b"") + ] + + heartbeats = [event for event in events if event.get("heartbeat") is True] + self.assertGreaterEqual(len(heartbeats), 1) + self.assertTrue(all(event["progress"] == 49 for event in heartbeats)) + self.assertTrue(all(event["phase"] == "integrating" for event in heartbeats)) + self.assertEqual(events[-1]["event"], "result") + + def test_streaming_endpoint_returns_structured_validation_error(self) -> None: + events = [ + json.loads(line) + for line in simulation_event_stream(b"") + ] + + self.assertEqual(events[-1]["event"], "error") + self.assertEqual(events[-1]["status"], 422) + self.assertIn("issues", events[-1]["detail"]) def test_simulation_endpoint_returns_422_for_ideal_storage_coupling(self) -> None: project = chain_project() diff --git a/tests/test_result_csv_export.py b/tests/test_result_csv_export.py new file mode 100644 index 0000000..818e3eb --- /dev/null +++ b/tests/test_result_csv_export.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import csv +import io +import unittest + +from fastapi import HTTPException + +from app.main import ( + SimulationResultCsvPayload, + SimulationResultVariablePayload, + export_simulation_results_csv, +) + + +def result_variable( + key: str, + component_id: str, + name: str, + label: str, + unit: str, +) -> SimulationResultVariablePayload: + return SimulationResultVariablePayload( + key=key, + componentId=component_id, + componentType="tank", + scope="component", + name=name, + label=label, + quantity="pressure", + unit=unit, + ) + + +class ResultCsvExportTests(unittest.TestCase): + def valid_payload(self) -> SimulationResultCsvPayload: + return SimulationResultCsvPayload( + projectName="储气系统", + variables=[ + result_variable( + "cylinder_1.p", + "cylinder_1", + "p", + "压力", + "Pa", + ), + result_variable("tank_1.p", "tank_1", "p", "压力", "Pa"), + ], + series={ + "time": [0.0, 0.1], + "cylinder_1.p": [35000000.0, 34900000.0], + "tank_1.p": [100000.0, 101000.0], + }, + ) + + def test_csv_export_preserves_result_keys_and_rows(self) -> None: + response = export_simulation_results_csv(self.valid_payload()) + + text = response.body.decode("utf-8-sig") + rows = list(csv.reader(io.StringIO(text))) + self.assertEqual( + rows[0], + ["time", "cylinder_1.p", "tank_1.p"], + ) + self.assertEqual(rows[1], ["0.0", "35000000.0", "100000.0"]) + self.assertEqual(rows[2], ["0.1", "34900000.0", "101000.0"]) + self.assertIn( + "filename*=UTF-8''", + response.headers["content-disposition"], + ) + + def test_csv_export_rejects_inconsistent_column_lengths(self) -> None: + payload = self.valid_payload() + payload.series["tank_1.p"] = [100000.0] + + with self.assertRaises(HTTPException) as caught: + export_simulation_results_csv(payload) + + self.assertEqual(caught.exception.status_code, 422) + self.assertIn("inconsistent length", str(caught.exception.detail)) + + def test_csv_export_requires_metadata_for_every_result_column(self) -> None: + payload = self.valid_payload() + payload.series["orphan.value"] = [1.0, 2.0] + + with self.assertRaises(HTTPException) as caught: + export_simulation_results_csv(payload) + + self.assertEqual(caught.exception.status_code, 422) + self.assertIn("unmapped orphan.value", str(caught.exception.detail)) + + +if __name__ == "__main__": + unittest.main()