From 410ef535e8c419e188031e8213c263a6908c2d73 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Sun, 2 Aug 2026 00:57:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84AMESim=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=B8=8E=E4=BB=BF=E7=9C=9F=E6=B1=82=E8=A7=A3?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 5 + .../components/amesim/boundary/sources.py | 2 +- .../components/amesim/flow/orifices.py | 6 +- .../components/amesim/flow/pipes.py | 8 +- .../components/amesim/junctions/nodes.py | 4 +- .../amesim/mechanical/translational.py | 10 +- .../components/amesim/signals/sources.py | 67 ++- .../components/amesim/storage/chambers.py | 4 +- app/simulation/solvers/algebraic.py | 300 ++++++++++-- app/simulation/solvers/signal.py | 47 ++ app/simulation/solvers/solver.py | 419 ++++++++++++---- app/simulation/systems/generic.py | 6 + frontend/src/App.tsx | 363 ++++++++++++-- frontend/src/ComponentSymbol.tsx | 111 +++-- frontend/src/ParameterTable.tsx | 12 +- frontend/src/componentSymbols/boundary.tsx | 15 + frontend/src/componentSymbols/mechanical.tsx | 217 +++++++++ frontend/src/componentSymbols/media.tsx | 38 ++ frontend/src/componentSymbols/pneumatic.tsx | 233 +++++++++ frontend/src/componentSymbols/primitives.tsx | 41 ++ frontend/src/componentSymbols/signals.tsx | 71 +++ frontend/src/componentSymbols/storage.tsx | 69 +++ frontend/src/componentSymbols/types.ts | 8 + frontend/src/parameterExpression.ts | 458 ++++++++++++++++++ frontend/src/styles.css | 36 ++ frontend/tests/e2e/component-symbols.spec.ts | 156 ++++++ .../tests/e2e/parameter-expression.spec.ts | 42 ++ frontend/tests/e2e/parameter-table.spec.ts | 105 ++++ tests/test_amesim_helium_step_long_run.py | 256 ++++++++++ tests/test_amesim_pnvo001_signal_xml.py | 140 ++++++ tests/test_amesim_signal_components.py | 22 + tests/test_component_catalog.py | 3 + tests/test_core_solver.py | 119 +++++ ...est_pressure_flow_solver_initialization.py | 198 ++++++++ 34 files changed, 3340 insertions(+), 251 deletions(-) create mode 100644 frontend/src/componentSymbols/boundary.tsx create mode 100644 frontend/src/componentSymbols/mechanical.tsx create mode 100644 frontend/src/componentSymbols/media.tsx create mode 100644 frontend/src/componentSymbols/pneumatic.tsx create mode 100644 frontend/src/componentSymbols/primitives.tsx create mode 100644 frontend/src/componentSymbols/signals.tsx create mode 100644 frontend/src/componentSymbols/storage.tsx create mode 100644 frontend/src/componentSymbols/types.ts create mode 100644 frontend/src/parameterExpression.ts create mode 100644 frontend/tests/e2e/component-symbols.spec.ts create mode 100644 frontend/tests/e2e/parameter-expression.spec.ts create mode 100644 tests/test_amesim_helium_step_long_run.py create mode 100644 tests/test_pressure_flow_solver_initialization.py diff --git a/app/main.py b/app/main.py index 02655dd..96967f0 100644 --- a/app/main.py +++ b/app/main.py @@ -641,6 +641,11 @@ def run_system_xml_simulation( t_start=project.simulation.t_start, t_stop=project.simulation.t_stop, method=project.simulation.method, + # The pressure-flow closure is solved to a scaled 1e-7 + # residual. Asking the outer adaptive integrator for 1e-6 + # relative accuracy makes its finite-difference Jacobian chase + # algebraic solver noise after discontinuous signal events. + rtol=1.0e-5, max_step=project.simulation.max_step, ), sample_step=project.simulation.step, diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py index 87aca87..4eb81aa 100644 --- a/app/simulation/components/amesim/boundary/sources.py +++ b/app/simulation/components/amesim/boundary/sources.py @@ -26,7 +26,7 @@ class AmesimPnpl01(AlgebraicComponent): label="PNPL01 零气动流边界", library_id="amesim", category_id="boundary", - symbol="generic", + symbol="amesim_pnpl01", ports=(PortDisplaySpec("port_1", "left", order=10),), order=10, ) diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index 52da861..689c73c 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -105,7 +105,7 @@ class AmesimPnor001(AlgebraicComponent): label="PNOR001 常系数气动孔口", library_id="amesim", category_id="flow", - symbol="orifice", + symbol="amesim_pnor001", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -412,7 +412,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent): label="PNVO001 固定开度气动孔口", library_id="amesim", category_id="flow", - symbol="orifice", + symbol="amesim_pnvo001_fixed", ports=( PortDisplaySpec("port_2", "left", order=10), PortDisplaySpec("port_3", "right", order=20), @@ -633,7 +633,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening): label="PNVO001 信号开度气动孔口", library_id="amesim", category_id="flow", - symbol="orifice", + symbol="amesim_pnvo001", ports=( PortDisplaySpec("res", "left", order=5), PortDisplaySpec("port_2", "left", order=10), diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index 3af34d9..b445a72 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -105,7 +105,7 @@ class AmesimPnl00r(AlgebraicComponent): label="PNL00R 气动管路阻力", library_id="amesim", category_id="flow", - symbol="pipe", + symbol="amesim_pnl00r", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -440,7 +440,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): label="PNL0001 C-R 动态管路", library_id="amesim", category_id="flow", - symbol="pipe", + symbol="amesim_pnl0001", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -720,7 +720,7 @@ class AmesimPnl0002(AmesimPnl0001): label="PNL0002 R-C-R 动态管路", library_id="amesim", category_id="flow", - symbol="pipe", + symbol="amesim_pnl0002", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -920,7 +920,7 @@ class AmesimPnl0003(DynamicComponent): label="PNL0003 C-R-C 动态管路", library_id="amesim", category_id="flow", - symbol="pipe", + symbol="amesim_pnl0003", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py index a7cd64c..56315be 100644 --- a/app/simulation/components/amesim/junctions/nodes.py +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -91,7 +91,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode): label="PN3NODE2 三端气动节点", library_id="amesim", category_id="junctions", - symbol="tee", + symbol="amesim_pn3node2", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -128,7 +128,7 @@ class AmesimP4Node2(_AmesimPneumaticNode): label="P4NODE2 四端气动节点", library_id="amesim", category_id="junctions", - symbol="generic", + symbol="amesim_p4node2", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py index e6437ca..b0ec204 100644 --- a/app/simulation/components/amesim/mechanical/translational.py +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -22,7 +22,7 @@ class AmesimF000(AlgebraicComponent): label="F000 零力源", library_id="amesim", category_id="mechanical", - symbol="generic", + symbol="amesim_f000", ports=(PortDisplaySpec("port_1", "right", order=10),), order=10, ) @@ -73,7 +73,7 @@ class AmesimForc(AlgebraicComponent): label="FORC 信号转力", library_id="amesim", category_id="mechanical", - symbol="signal", + symbol="amesim_forc", ports=( PortDisplaySpec("res", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -167,7 +167,7 @@ class AmesimMecmas21(DynamicComponent): label="MECMAS21 一维质量", library_id="amesim", category_id="mechanical", - symbol="generic", + symbol="amesim_mecmas21", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -323,7 +323,7 @@ class AmesimLstp00a(AlgebraicComponent): label="LSTP00A 弹性接触", library_id="amesim", category_id="mechanical", - symbol="generic", + symbol="amesim_lstp00a", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -425,7 +425,7 @@ class AmesimLmechn1(AlgebraicComponent): label="LMECHN1 线性机械节点", library_id="amesim", category_id="mechanical", - symbol="junction", + symbol="amesim_lmechn1", ports=tuple( [PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)] + [PortDisplaySpec("port_9", "right", order=90)] diff --git a/app/simulation/components/amesim/signals/sources.py b/app/simulation/components/amesim/signals/sources.py index e6f9442..90cc837 100644 --- a/app/simulation/components/amesim/signals/sources.py +++ b/app/simulation/components/amesim/signals/sources.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from math import floor from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec @@ -27,7 +28,7 @@ class AmesimStep0(AlgebraicComponent): label="STEP0 阶跃信号", library_id="amesim", category_id="signals", - symbol="signal", + symbol="amesim_step0", ports=(PortDisplaySpec("out", "right", order=10),), order=10, ) @@ -71,6 +72,15 @@ class AmesimStep0(AlgebraicComponent): def signal_output_values(self, time: float) -> dict[str, float]: return {"out": self.output_at(time)} + def signal_event_times( + self, + start_time: float, + stop_time: float, + ) -> tuple[float, ...]: + """Expose the exact STEP0 switch time as an integration split point.""" + + return (self.time,) if start_time < self.time < stop_time else () + def component_result_values(self) -> Mapping[str, float]: return {"y": self.out.signal} @@ -117,7 +127,7 @@ class AmesimUd00(AlgebraicComponent): label="UD00 分段线性信号", library_id="amesim", category_id="signals", - symbol="signal", + symbol="amesim_ud00", ports=(PortDisplaySpec("out", "right", order=10),), order=20, ) @@ -200,5 +210,58 @@ class AmesimUd00(AlgebraicComponent): def signal_output_values(self, time: float) -> dict[str, float]: return {"out": self.output_at(time)} + def signal_event_times( + self, + start_time: float, + stop_time: float, + ) -> tuple[float, ...]: + """Return UD00 start, stage, and repeated cycle boundaries. + + The final non-cyclic stage is intentionally not given an end event: + ``output_at`` continues that stage's slope after its configured duration. + """ + + if stop_time <= start_time: + return () + + active_durations = self.durations[: self.nstages] + stage_offsets = [0.0] + elapsed = 0.0 + for duration in active_durations[:-1]: + elapsed += duration + stage_offsets.append(elapsed) + + if not self.iscyclic: + return tuple( + sorted( + { + event_time + for offset in stage_offsets + if start_time + < (event_time := self.tstart + offset) + < stop_time + } + ) + ) + + cycle_duration = sum(active_durations) + if cycle_duration <= 0.0: + return () + + events: set[float] = set() + for offset in stage_offsets: + first_boundary = self.tstart + offset + cycle_index = max( + 0, + floor((start_time - first_boundary) / cycle_duration) + 1, + ) + event_time = first_boundary + cycle_index * cycle_duration + while event_time < stop_time: + if event_time > start_time: + events.add(event_time) + cycle_index += 1 + event_time = first_boundary + cycle_index * cycle_duration + return tuple(sorted(events)) + def component_result_values(self) -> Mapping[str, float]: return {"y": self.out.signal} diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index 5f0e9a2..b77f069 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -100,7 +100,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent): label="PNCH023 固定容积气室", library_id="amesim", category_id="storage", - symbol="tank", + symbol="amesim_pnch023", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), @@ -340,7 +340,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent): label="PNCH012 变容气室", library_id="amesim", category_id="storage", - symbol="tank", + symbol="amesim_pnch012", ports=( PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "right", order=20), diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index 77198d0..7d596b0 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from math import sqrt +from math import isfinite, sqrt from app.simulation.core.ports import PortState, VariableRole from app.simulation.systems.network import SimulationNetwork @@ -68,6 +68,7 @@ class PressureFlowSolver: self.residual_tolerance = residual_tolerance self.max_evaluations = max_evaluations self.unknowns = self._build_unknowns() + self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns} self.last_diagnostics: AlgebraicSolveDiagnostics | None = None def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]: @@ -91,48 +92,220 @@ class PressureFlowSolver: ) return tuple(unknowns) - def _seed_equal_pressures(self) -> None: - for _ in range(max(2, len(self.network.connections))): - changed = False - for connection in self.network.connections: - if connection.kind != "physical": - continue - first = self.network.components[ - connection.endpoint_a.component - ].get_port(connection.endpoint_a.port) - second = self.network.components[ - connection.endpoint_b.component - ].get_port(connection.endpoint_b.port) - if first.p > 0.0 and second.p <= 0.0: - second.p = first.p - changed = True - elif second.p > 0.0 and first.p <= 0.0: - first.p = second.p - changed = True + @staticmethod + def _port_key(variable: str, expected_variable: str) -> tuple[str, str] | None: + try: + component_name, port_name, variable_name = variable.rsplit(".", 2) + except ValueError: + return None + if variable_name != expected_variable: + return None + return component_name, port_name - for component in self.network.components.values(): - equal_pressure_equations = [ - equation - for equation in component.pressure_flow_equation_residuals() - if equation.relation == "equal" and equation.role == "effort" + def _seed_equal_pressures(self) -> None: + """Lift current state pressures across their complete equality groups. + + Dynamic components refresh their own pressure ports before each closure, + while connected algebraic ports retain values from the preceding RHS + evaluation. Merely filling non-positive pressures therefore leaves a + stale, and sometimes badly conditioned, nonlinear initial guess. State + equations expose the current pressure as ``port.p - target``; use that + target as the authoritative anchor for every connected/equal port. + """ + + pressure_unknowns = { + (unknown.component, unknown.port): unknown + for unknown in self.unknowns + if unknown.variable == "p" + } + if not pressure_unknowns: + return + + parent = {key: key for key in pressure_unknowns} + + def find(key: tuple[str, str]) -> tuple[str, str]: + root = key + while parent[root] != root: + root = parent[root] + while parent[key] != key: + next_key = parent[key] + parent[key] = root + key = next_key + return root + + def union(first: tuple[str, str], second: tuple[str, str]) -> None: + first_root = find(first) + second_root = find(second) + if first_root != second_root: + parent[second_root] = first_root + + for connection in self.network.connections: + if connection.kind != "physical": + continue + first = connection.endpoint_a.key + second = connection.endpoint_b.key + if first in pressure_unknowns and second in pressure_unknowns: + union(first, second) + + component_equations = { + component.name: component.pressure_flow_equation_residuals() + for component in self.network.components.values() + } + for equations in component_equations.values(): + for equation in equations: + if equation.relation != "equal" or equation.role != "effort": + continue + endpoints = [ + endpoint + for variable in equation.variables + if ( + (endpoint := self._port_key(variable, "p")) + in pressure_unknowns + ) ] - for equation in equal_pressure_equations: - states = [] - for variable in equation.variables: - _, port_name, variable_name = variable.rsplit(".", 2) - if variable_name == "p": - states.append(component.get_port(port_name)) - if len(states) != 2: - continue - first, second = states - if first.p > 0.0 and second.p <= 0.0: - second.p = first.p - changed = True - elif second.p > 0.0 and first.p <= 0.0: - first.p = second.p - changed = True - if not changed: - break + for endpoint in endpoints[1:]: + union(endpoints[0], endpoint) + + members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {} + for endpoint in pressure_unknowns: + members_by_root.setdefault(find(endpoint), []).append(endpoint) + + anchors_by_root: dict[tuple[str, str], list[float]] = {} + for equations in component_equations.values(): + for equation in equations: + if equation.relation != "state" or equation.role != "effort": + continue + endpoints = [ + endpoint + for variable in equation.variables + if ( + (endpoint := self._port_key(variable, "p")) + in pressure_unknowns + ) + ] + if len(endpoints) != 1: + continue + endpoint = endpoints[0] + unknown = pressure_unknowns[endpoint] + target_pressure = unknown.read() - float(equation.value) + if not isfinite(target_pressure): + continue + # Keep the state-owned port current even when an invalid model + # has conflicting storage anchors in one equality group. + unknown.write(target_pressure) + anchors_by_root.setdefault(find(endpoint), []).append(target_pressure) + + for root, members in members_by_root.items(): + anchors = anchors_by_root.get(root, []) + if anchors: + pressure_scale = max([abs(value) for value in anchors] + [1.0]) + if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale: + # A conflicting multi-storage group is structurally invalid; + # leave it for the residual solver/preparation diagnostics. + continue + target_pressure = sum(anchors) / len(anchors) + for endpoint in members: + pressure_unknowns[endpoint].write(target_pressure) + continue + + positive_seed = next( + ( + pressure_unknowns[endpoint].read() + for endpoint in members + if pressure_unknowns[endpoint].read() > 0.0 + ), + None, + ) + if positive_seed is None: + continue + for endpoint in members: + unknown = pressure_unknowns[endpoint] + if unknown.read() <= 0.0: + unknown.write(positive_seed) + + def _seed_explicit_mass_flows(self) -> None: + """Initialize explicit ``m_flow - f(...)`` constitutive relations. + + AMESim orifices and quasi-steady pneumatic lines expose one mass-flow + unknown with unit coefficient. Once pressure anchors are current, a + residual correction places that flow directly on its constitutive + surface and avoids asking the nonlinear optimizer to discover the + square-root branch from a stale preceding-step value. + """ + + seeded_ids: set[str] = set() + for component in self.network.components.values(): + for equation in component.pressure_flow_equation_residuals(): + if equation.relation != "constitutive" or equation.role != "flow": + continue + mass_flow_unknowns = [ + self._unknowns_by_id[variable] + for variable in equation.variables + if variable in self._unknowns_by_id + and self._unknowns_by_id[variable].variable == "m_flow" + ] + if len(mass_flow_unknowns) != 1: + continue + unknown = mass_flow_unknowns[0] + target_flow = unknown.read() - float(equation.value) + if not isfinite(target_flow): + continue + unknown.write(target_flow) + seeded_ids.add(unknown.id) + + # Complete local two-port balances for explicit elements. Connection + # flow equations remain available to align the adjacent component port. + for component in self.network.components.values(): + for equation in component.pressure_flow_equation_residuals(): + if equation.relation != "sumToZero" or equation.role != "flow": + continue + mass_flow_unknowns = [ + self._unknowns_by_id[variable] + for variable in equation.variables + if variable in self._unknowns_by_id + and self._unknowns_by_id[variable].variable == "m_flow" + ] + if len(mass_flow_unknowns) != 2: + continue + seeded = [ + unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids + ] + if len(seeded) != 1: + continue + other = next( + unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids + ) + other.write(-seeded[0].read()) + seeded_ids.add(other.id) + + # A physical connector imposes the same sum-to-zero flow rule as a + # two-port component. Once an explicit component flow is known, carry + # that guess to the connected storage/boundary port as well. For the + # common volume-orifice-volume topology this makes the seeded state an + # exact algebraic solution and avoids an unnecessary nonlinear solve on + # every ODE/Jacobian evaluation. + for connection in self.network.connections: + if connection.kind != "physical": + continue + endpoint_unknowns = [] + for endpoint in connection.endpoints: + unknown = self._unknowns_by_id.get( + f"{endpoint.component}.{endpoint.port}.m_flow" + ) + if unknown is not None: + endpoint_unknowns.append(unknown) + if len(endpoint_unknowns) != 2: + continue + seeded = [ + unknown for unknown in endpoint_unknowns if unknown.id in seeded_ids + ] + if len(seeded) != 1: + continue + other = next( + unknown for unknown in endpoint_unknowns if unknown.id not in seeded_ids + ) + other.write(-seeded[0].read()) + seeded_ids.add(other.id) def _scales(self) -> dict[str, float]: pressure_scale = max( @@ -184,6 +357,7 @@ class PressureFlowSolver: ) from exc self._seed_equal_pressures() + self._seed_explicit_mass_flows() scales = self._scales() pressure_scale = scales["p"] flow_scale = scales["m_flow"] @@ -216,6 +390,40 @@ class PressureFlowSolver: return pressure_scale return max([scales.get(name, 1.0) for name in variable_names] + [1.0]) + seeded_equations = self.network.pressure_flow_equation_residuals() + seeded_scaled = [ + abs(equation.value / equation_scale(equation)) + for equation in seeded_equations + ] + seeded_max_scaled_residual = max(seeded_scaled, default=0.0) + seeded_unknown_values = [ + (unknown, unknown.read()) for unknown in self.unknowns + ] + seeded_unknowns_are_feasible = all( + isfinite(value) + and (unknown.variable != "p" or value >= 1.0) + for unknown, value in seeded_unknown_values + ) + if ( + seeded_unknowns_are_feasible + and all(isfinite(value) for value in seeded_scaled) + and seeded_max_scaled_residual <= self.residual_tolerance + ): + diagnostics = AlgebraicSolveDiagnostics( + success=True, + message="Seeded pressure-flow state satisfies the residual tolerance.", + evaluations=0, + pressure_scale=pressure_scale, + flow_scale=flow_scale, + max_scaled_residual=seeded_max_scaled_residual, + max_raw_residual=max( + (abs(item.value) for item in seeded_equations), + default=0.0, + ), + ) + self.last_diagnostics = diagnostics + return diagnostics + x0 = np.asarray( [ ( @@ -269,14 +477,20 @@ class PressureFlowSolver: ) for equation in equations ] - success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance + max_scaled_residual = max(scaled, default=0.0) + residuals_converged = ( + all(isfinite(value) for value in scaled) + and max_scaled_residual <= self.residual_tolerance + ) + optimizer_status_is_acceptable = bool(result.success) or int(result.status) == 0 + success = residuals_converged and optimizer_status_is_acceptable diagnostics = AlgebraicSolveDiagnostics( success=success, message=str(result.message), evaluations=int(result.nfev), pressure_scale=pressure_scale, flow_scale=flow_scale, - max_scaled_residual=max(scaled, default=0.0), + max_scaled_residual=max_scaled_residual, max_raw_residual=max((abs(item.value) for item in equations), default=0.0), ) self.last_diagnostics = diagnostics diff --git a/app/simulation/solvers/signal.py b/app/simulation/solvers/signal.py index 467639b..f0f467a 100644 --- a/app/simulation/solvers/signal.py +++ b/app/simulation/solvers/signal.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from math import isfinite from typing import Protocol from app.simulation.systems.network import Endpoint, SimulationNetwork @@ -13,6 +14,21 @@ class SignalOutputComponent(Protocol): ... +class SignalEventSource(Protocol): + """Optional contract for signal sources with known time discontinuities.""" + + name: str + + def signal_event_times( + self, + start_time: float, + stop_time: float, + ) -> tuple[float, ...]: + """Return event times strictly inside ``(start_time, stop_time)``.""" + + ... + + @dataclass(frozen=True) class SignalSolveDiagnostics: propagated: int @@ -51,6 +67,37 @@ class SignalResolver: self.last_diagnostics = diagnostics return diagnostics + def event_times(self, start_time: float, stop_time: float) -> tuple[float, ...]: + """Collect optional source events that can be used as integration splits. + + Event discovery is deliberately duck typed so existing signal-output + components remain valid without implementing ``signal_event_times``. + """ + + start = float(start_time) + stop = float(stop_time) + if not isfinite(start) or not isfinite(stop): + raise ValueError("Signal event interval must be finite.") + if stop < start: + raise ValueError("Signal event interval stop must not precede start.") + if stop == start: + return () + + events: set[float] = set() + for component in self.network.components.values(): + source_event_times = getattr(component, "signal_event_times", None) + if source_event_times is None: + continue + for raw_time in source_event_times(start, stop): + event_time = float(raw_time) + if not isfinite(event_time): + raise ValueError( + f"Signal event time from component '{component.name}' must be finite." + ) + if start < event_time < stop: + events.add(event_time) + return tuple(sorted(events)) + def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]: first, second = endpoints first_port = self.network.components[first.component].get_port(first.port) diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py index acdb17f..1a0842d 100644 --- a/app/simulation/solvers/solver.py +++ b/app/simulation/solvers/solver.py @@ -1,7 +1,8 @@ from __future__ import annotations +import math from dataclasses import dataclass -from typing import Callable, Literal +from typing import Callable, Literal, Sequence CancellationCheck = Callable[[], bool] @@ -51,6 +52,33 @@ def _append_solution_sample( states[index].append(float(value)) +def _normalize_breakpoints( + config: SolveIVPConfig, + breakpoints: Sequence[float] | None, +) -> list[float]: + """Return sorted, unique breakpoints strictly inside the integration span.""" + + if breakpoints is None or len(breakpoints) == 0: + return [] + if config.t_stop < config.t_start: + raise ValueError("Segmented integration requires t_stop to follow t_start.") + + normalized: list[float] = [] + for raw_breakpoint in breakpoints: + breakpoint = float(raw_breakpoint) + if not math.isfinite(breakpoint): + raise ValueError("Integration breakpoints must be finite numbers.") + if config.t_start < breakpoint < config.t_stop: + normalized.append(breakpoint) + + normalized.sort() + return [ + breakpoint + for index, breakpoint in enumerate(normalized) + if index == 0 or breakpoint != normalized[index - 1] + ] + + def _runge_kutta_4( rhs: Callable[[float, list[float]], list[float]], initial_state: list[float], @@ -114,6 +142,137 @@ def _runge_kutta_4( ) +def _runge_kutta_4_segmented( + rhs: Callable[[float, list[float]], list[float]], + initial_state: list[float], + config: SolveIVPConfig, + t_eval: list[float] | None, + breakpoints: Sequence[float], + cancel_check: CancellationCheck | None = None, + accepted_step_callback: AcceptedStepCallback | None = None, +) -> ODESolution: + """RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint.""" + + if t_eval is None: + point_count = max( + 2, + int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1, + ) + sample_step = (config.t_stop - config.t_start) / (point_count - 1) + sample_times = [ + config.t_start + index * sample_step for index in range(point_count) + ] + else: + sample_times = [float(time) for time in t_eval] + + state = [float(value) for value in initial_state] + states = [[value] for value in state] + times = [float(config.t_start)] + current_time = float(config.t_start) + sample_index = 0 + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= config.t_start + 1e-12 + ): + sample_index += 1 + + status: IntegrationStatus = "completed" + message = "Integrated with built-in RK4 fallback because SciPy is unavailable." + error: Exception | None = None + last_reported_step: float | None = None + + def report_step(time: float) -> None: + nonlocal last_reported_step + if accepted_step_callback is None: + return + if last_reported_step is not None and time <= last_reported_step: + return + accepted_step_callback(float(time)) + last_reported_step = float(time) + + def advance_to( + target_time: float, reported_terminal_time: float | None = None + ) -> None: + nonlocal current_time, state + 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 + report_time = current_time + if ( + reported_terminal_time is not None + and current_time >= target_time - 1e-15 + ): + report_time = reported_terminal_time + report_step(report_time) + + try: + segment_ends = [*breakpoints, float(config.t_stop)] + for segment_index, segment_end in enumerate(segment_ends): + is_breakpoint = segment_index < len(breakpoints) + integration_end = ( + math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end + ) + + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= integration_end + ): + sample_time = float(sample_times[sample_index]) + advance_to(sample_time) + _append_solution_sample(times, states, sample_time, state) + sample_index += 1 + + advance_to( + integration_end, + segment_end if is_breakpoint else None, + ) + + if is_breakpoint: + current_time = float(segment_end) + report_step(current_time) + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= segment_end + ): + sample_time = float(sample_times[sample_index]) + _append_solution_sample(times, states, sample_time, state) + sample_index += 1 + except _IntegrationCancelled: + status = "cancelled" + message = "Simulation was stopped before reaching the requested end time." + _append_solution_sample(times, states, current_time, state) + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + _append_solution_sample(times, states, current_time, state) + + return ODESolution( + t=times, + y=states, + success=status == "completed", + message=message, + status=status, + error=error, + ) + + def _integrate_scipy_stepwise( rhs: Callable[[float, list[float]], list[float]], initial_state: list[float], @@ -121,6 +280,7 @@ def _integrate_scipy_stepwise( t_eval: list[float] | None, cancel_check: CancellationCheck, accepted_step_callback: AcceptedStepCallback | None, + breakpoints: Sequence[float] = (), ) -> ODESolution: import numpy as np from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau @@ -141,7 +301,7 @@ def _integrate_scipy_stepwise( 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_times = [float(time) for time in (t_eval or [])] sample_index = 0 while ( sample_index < len(sample_times) @@ -154,96 +314,160 @@ def _integrate_scipy_stepwise( 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", - ) - - solver_options = { - "rtol": config.rtol, - "atol": config.atol, - "max_step": config.max_step, - } - if config.first_step is not None: - solver_options["first_step"] = config.first_step - - try: - solver = solver_type( - cancellable_rhs, - config.t_start, - np.asarray(initial_state, dtype=float), - config.t_stop, - **solver_options, - ) - 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 + last_reported_step: float | None = None - while solver.status == "running": + def report_step(time: float) -> None: + nonlocal last_reported_step + if accepted_step_callback is None: + return + if last_reported_step is not None and time <= last_reported_step: + return + accepted_step_callback(float(time)) + last_reported_step = float(time) + + segment_ends = [*breakpoints, float(config.t_stop)] + for segment_index, segment_end in enumerate(segment_ends): 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, + message = ( + "Simulation was stopped before integration started." + if segment_index == 0 + else "Simulation was stopped before reaching the requested end time." ) - if accepted_step_callback is not None: - accepted_step_callback(last_accepted_time) + break + + is_breakpoint = segment_index < len(breakpoints) + integration_end = ( + math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end + ) + has_integration_interval = integration_end > last_accepted_time + + if has_integration_interval: + solver_options = { + "rtol": config.rtol, + "atol": config.atol, + "max_step": config.max_step, + } + if config.first_step is not None: + solver_options["first_step"] = min( + config.first_step, + integration_end - last_accepted_time, + ) + + try: + solver = solver_type( + cancellable_rhs, + last_accepted_time, + np.asarray(last_accepted_state, dtype=float), + integration_end, + **solver_options, + ) + except _IntegrationCancelled: + status = "cancelled" + message = ( + "Simulation was stopped before integration started." + if segment_index == 0 + else "Simulation was stopped before reaching the requested end time." + ) + break + except Exception as exc: + status = "failed" + message = str(exc) + error = exc + break + + 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] + reported_time = ( + float(segment_end) + if is_breakpoint and solver.status == "finished" + else last_accepted_time + ) + if sample_times: + dense_output = solver.dense_output() + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= last_accepted_time + ): + 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, + reported_time, + last_accepted_state, + ) + report_step(reported_time) + + if status != "completed": + break + + if is_breakpoint: + # The old equation is integrated only to the representable point just + # left of the event. The continuous state is then lifted to the exact + # event time, where the freshly constructed next solver sees the new + # equation immediately. + last_accepted_time = float(segment_end) + if sample_times: + while ( + sample_index < len(sample_times) + and sample_times[sample_index] <= segment_end + ): + sample_time = float(sample_times[sample_index]) + _append_solution_sample( + times, + states, + sample_time, + last_accepted_state, + ) + sample_index += 1 + elif not has_integration_interval: + _append_solution_sample( + times, + states, + last_accepted_time, + last_accepted_state, + ) + report_step(last_accepted_time) if status != "completed": _append_solution_sample( @@ -270,8 +494,14 @@ def integrate_ode( t_eval: list[float] | None = None, cancel_check: CancellationCheck | None = None, accepted_step_callback: AcceptedStepCallback | None = None, + breakpoints: Sequence[float] | None = None, ): - """Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback.""" + """Integrate an ODE, optionally restarting at equation discontinuities. + + Breakpoints are interpreted as right-continuous equation changes: the old + equation is integrated to the floating-point left limit, then a fresh solver + starts at the exact breakpoint with the unchanged continuous state. + """ if abs(config.t_stop - config.t_start) <= 1e-15: return ODESolution( @@ -281,9 +511,21 @@ def integrate_ode( message="Skipped integration because t_start equals t_stop.", ) + normalized_breakpoints = _normalize_breakpoints(config, breakpoints) + try: from scipy.integrate import solve_ivp except ImportError: + if normalized_breakpoints: + return _runge_kutta_4_segmented( + rhs, + initial_state, + config, + t_eval, + normalized_breakpoints, + cancel_check, + accepted_step_callback, + ) return _runge_kutta_4( rhs, initial_state, @@ -293,14 +535,15 @@ def integrate_ode( accepted_step_callback, ) - if cancel_check is not None: + if cancel_check is not None or normalized_breakpoints: return _integrate_scipy_stepwise( rhs, initial_state, config, t_eval, - cancel_check, + cancel_check or (lambda: False), accepted_step_callback, + normalized_breakpoints, ) solve_options = { diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index 3944c20..dda580f 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -327,6 +327,10 @@ class GenericFluidSystem: report_progress(0.0, "initializing", force=True) t_eval = simulation_sample_times(config, sample_step) + signal_event_times = self.signal_resolver.event_times( + config.t_start, + config.t_stop, + ) initial_state = self.consistent_initial_state_vector() report_progress(0.0, "integrating", force=True) duration = config.t_stop - config.t_start @@ -356,6 +360,7 @@ class GenericFluidSystem: accepted_step_callback=( report_solver_time if cancel_check is not None else None ), + breakpoints=signal_event_times, ) if isinstance(solution, ODESolution): run_status: SimulationRunStatus = solution.status @@ -428,6 +433,7 @@ class GenericFluidSystem: }, "signal": { "propagations": self.signal_propagation_count, + "eventTimes": list(signal_event_times), "last": ( self.signal_resolver.last_diagnostics.as_dict() if self.signal_resolver.last_diagnostics is not None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 409625d..5b4c058 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -89,6 +89,10 @@ import { type ParameterColumnLayout, type ParameterTableRow, } from "./ParameterTable"; +import { + evaluateParameterExpression, + isParameterExpressionValue, +} from "./parameterExpression"; import { WorkspaceViewTabs, type WorkspaceView, @@ -219,7 +223,17 @@ type SimulationNodeData = { type SimulationNode = Node; type SimulationEdge = Edge; +type SimulationNumericKey = "t_start" | "t_stop" | "step" | "max_step"; + type SimulationConfig = { + t_start: ParameterValue; + t_stop: ParameterValue; + step: ParameterValue; + max_step: ParameterValue; + method: string; +}; + +type ResolvedSimulationConfig = { t_start: number; t_stop: number; step: number; @@ -255,6 +269,10 @@ type ReactFlowProjectPayload = { simulation: SimulationConfig; }; +type ExecutableProjectPayload = Omit & { + simulation: ResolvedSimulationConfig; +}; + type SimulationResult = { success: boolean; status: "completed" | "stopped" | "stalled" | "failed"; @@ -534,7 +552,7 @@ const unitOptions: Record = { ], }; -const defaultSimulationConfig: SimulationConfig = { +const defaultSimulationConfig: ResolvedSimulationConfig = { t_start: 0, t_stop: 2, step: 0.1, @@ -625,6 +643,12 @@ type SimulationStreamEvent = totalTime?: number; }; const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; +const simulationConfigLabels: Record = { + t_start: "起始时间", + t_stop: "结束时间", + step: "采样步长", + max_step: "最大积分步长", +}; function physicalPort( name: string, @@ -1527,7 +1551,7 @@ type NormalizedSimulationProgress = { function normalizeSimulationProgressEvent( event: Extract, - config: SimulationConfig, + config: ResolvedSimulationConfig, ): NormalizedSimulationProgress { const totalTime = Number.isFinite(event.totalTime) ? Number(event.totalTime) @@ -2184,7 +2208,7 @@ function DeferredNumberInput({ { editingRef.current = false; if (cancelCommitRef.current) { @@ -3013,10 +3037,11 @@ function FlowWorkbench() { const selectedUnit = selectedNode?.data.parameterUnits[key] ?? definition.unit ?? ""; const unit = findUnitOption(definition, selectedUnit); - const numericValue = Number(displayValue); + const normalizedDisplayValue = displayValue.trim(); + const numericValue = Number(normalizedDisplayValue); const baseValue = - displayValue.trim() === "" || !Number.isFinite(numericValue) - ? displayValue + normalizedDisplayValue === "" || !Number.isFinite(numericValue) + ? normalizedDisplayValue : unit.toBase(numericValue); updateSelectedNode((node) => ({ @@ -3216,15 +3241,22 @@ function FlowWorkbench() { ]); const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { + const normalizedValue = value.trim(); + const numericValue = Number(normalizedValue); setSimulationConfig((current) => ({ ...current, - [key]: key === "method" ? value : Number(value), + [key]: + key === "method" + ? value + : normalizedValue !== "" && Number.isFinite(numericValue) + ? numericValue + : normalizedValue, })); }; const generateXml = () => { try { - const xml = buildSystemXml(buildCurrentProject()); + const xml = buildSystemXml(buildCurrentProject(), componentDefinitions); changeConsoleMode("normal"); appendConsoleEntry( "success", @@ -3239,7 +3271,7 @@ function FlowWorkbench() { const downloadXml = () => { try { - const xml = buildSystemXml(buildCurrentProject()); + const xml = buildSystemXml(buildCurrentProject(), componentDefinitions); downloadText(`${safeFilename(projectName)}.xml`, xml); appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`); } catch (error) { @@ -3402,7 +3434,7 @@ function FlowWorkbench() { const publishSimulationResult = ( result: SimulationResult, - project: ReactFlowProjectPayload, + project: ExecutableProjectPayload, ) => { const resultStatus = result.status ?? (result.success ? "completed" : "failed"); const sampleCount = Math.min( @@ -3510,34 +3542,48 @@ function FlowWorkbench() { const runSimulation = async () => { const issues = checkModel(); const modelErrors = issues.filter((issue) => issue.severity === "error"); + const simulationResolution = resolveSimulationConfig( + simulationConfigRef.current, + ); + const progressConfig = simulationResolution.ok + ? simulationResolution.value + : defaultSimulationConfig; 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, + startTime: progressConfig.t_start, + simulatedTime: progressConfig.t_start, + totalTime: progressConfig.t_stop, }); return; } + if (!simulationResolution.ok) { + changeConsoleMode("normal"); + appendConsoleEntry("error", simulationResolution.message); + return; + } + const project: ExecutableProjectPayload = { + ...buildCurrentProject(), + simulation: simulationResolution.value, + }; changeConsoleMode("normal"); setSimulationProgress({ state: "running", percent: 0, message: "正在提交仿真任务", - startTime: simulationConfigRef.current.t_start, - simulatedTime: simulationConfigRef.current.t_start, - totalTime: simulationConfigRef.current.t_stop, + startTime: project.simulation.t_start, + simulatedTime: project.simulation.t_start, + totalTime: project.simulation.t_stop, }); appendConsoleEntry( "info", - `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`, + `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${project.simulation.t_start} - ${project.simulation.t_stop} s`, ); await runBusyAction("正在编译模型并运行仿真...", async () => { - const project = buildCurrentProject(); - const xml = buildSystemXml(project); + const xml = buildSystemXml(project, componentDefinitions); const simulationId = createSimulationId(); activeSimulationIdRef.current = simulationId; setSimulationStopPending(false); @@ -3988,7 +4034,11 @@ function FlowWorkbench() { const error = gasReference && !gasReferenceAvailable ? "引用的介质定义不存在" - : parameterValidationMessage(definition, baseValue); + : parameterValidationMessage( + definition, + baseValue, + selectedUnit, + ); let valueControl: ReactNode; if (mediumDefinitionIndex) { valueControl = ( @@ -4103,12 +4153,20 @@ function FlowWorkbench() { definition, selectedUnit, ), + calculatedValue: parameterCalculatedValueMetadata( + baseValue, + definition, + selectedUnit, + ), }; }, ), ] : []; + const simulationSettingErrors = simulationConfigValidationMessages( + simulationConfig, + ); const simulationSettingRows: ParameterTableRow[] = [ { id: "simulation-t-start", @@ -4116,6 +4174,7 @@ function FlowWorkbench() { value: ( updateSimulationConfig("t_start", value)} onEditEnd={finishHistoryEdit} @@ -4127,6 +4186,11 @@ function FlowWorkbench() { defaultValue: `${defaultSimulationConfig.t_start} s`, range: "有限数值", currentUnit: "s", + error: simulationSettingErrors.t_start, + calculatedValue: simulationCalculatedValueMetadata( + simulationConfig.t_start, + "s", + ), }, { id: "simulation-t-stop", @@ -4134,6 +4198,7 @@ function FlowWorkbench() { value: ( updateSimulationConfig("t_stop", value)} onEditEnd={finishHistoryEdit} @@ -4145,6 +4210,11 @@ function FlowWorkbench() { defaultValue: `${defaultSimulationConfig.t_stop} s`, range: "> 起始时间", currentUnit: "s", + error: simulationSettingErrors.t_stop, + calculatedValue: simulationCalculatedValueMetadata( + simulationConfig.t_stop, + "s", + ), }, { id: "simulation-step", @@ -4152,6 +4222,7 @@ function FlowWorkbench() { value: ( updateSimulationConfig("step", value)} onEditEnd={finishHistoryEdit} @@ -4163,6 +4234,11 @@ function FlowWorkbench() { defaultValue: `${defaultSimulationConfig.step} s`, range: "> 0", currentUnit: "s", + error: simulationSettingErrors.step, + calculatedValue: simulationCalculatedValueMetadata( + simulationConfig.step, + "s", + ), }, { id: "simulation-max-step", @@ -4170,6 +4246,7 @@ function FlowWorkbench() { value: ( updateSimulationConfig("max_step", value)} onEditEnd={finishHistoryEdit} @@ -4181,6 +4258,11 @@ function FlowWorkbench() { defaultValue: `${defaultSimulationConfig.max_step} s`, range: "> 0", currentUnit: "s", + error: simulationSettingErrors.max_step, + calculatedValue: simulationCalculatedValueMetadata( + simulationConfig.max_step, + "s", + ), }, { id: "simulation-method", @@ -4838,7 +4920,9 @@ function normalizeLoadedNode( label: node.data.label || definition?.label || node.id, componentType: node.data.componentType || definition?.type || "component", modelType: node.data.modelType || definition?.modelType || node.data.componentType, - symbol: node.data.symbol || definition?.symbol || node.data.componentType, + // Catalog symbols are versioned presentation metadata. Prefer the current + // definition so projects saved with an old generic icon are upgraded on load. + symbol: definition?.symbol || node.data.symbol || node.data.componentType, ports: normalizeLoadedPorts(node.data.ports, definition), parameters, parameterUnits, @@ -5122,17 +5206,95 @@ function parameterCurrentUnitMetadata( : undefined; } +type NumericInputResolution = + | { ok: true; value: number; expression: boolean } + | { ok: false; message: string; expression: boolean }; + +function resolveNumericInput(value: ParameterValue): NumericInputResolution { + if (typeof value === "number") { + return Number.isFinite(value) + ? { ok: true, value, expression: false } + : { ok: false, message: "请输入有限数值", expression: false }; + } + + const normalizedValue = value.trim(); + if (!normalizedValue) { + return { ok: false, message: "参数不能为空", expression: false }; + } + const numericValue = Number(normalizedValue); + if (Number.isFinite(numericValue)) { + return { ok: true, value: numericValue, expression: false }; + } + + const evaluated = evaluateParameterExpression(normalizedValue); + return evaluated.ok + ? { ok: true, value: evaluated.value, expression: true } + : { + ok: false, + message: `等式无法计算:${evaluated.message}`, + expression: true, + }; +} + +function resolveParameterValue( + value: ParameterValue, + definition: ParameterDefinition, + selectedUnit: string, +): NumericInputResolution { + const resolved = resolveNumericInput(value); + if (!resolved.ok) { + return resolved; + } + if (resolved.expression && definition.editor) { + return { + ok: false, + message: "该离散参数只能通过下拉栏选择", + expression: true, + }; + } + if (!resolved.expression) { + return resolved; + } + + const baseValue = findUnitOption(definition, selectedUnit).toBase( + resolved.value, + ); + return Number.isFinite(baseValue) + ? { ...resolved, value: baseValue } + : { + ok: false, + message: "等式换算到基础单位后不是有限数值", + expression: true, + }; +} + +function parameterCalculatedValueMetadata( + value: ParameterValue, + definition: ParameterDefinition, + selectedUnit: string, +) { + if (!isParameterExpressionValue(value)) { + return undefined; + } + const resolved = resolveParameterValue(value, definition, selectedUnit); + if (!resolved.ok || !resolved.expression) { + return undefined; + } + const unit = findUnitOption(definition, selectedUnit); + const displayValue = unit.fromBase(resolved.value); + return `${formatEditableNumber(displayValue)}${unit.label ? ` ${unit.label}` : ""}`; +} + function parameterValidationMessage( definition: ParameterDefinition, value: ParameterValue, + selectedUnit: string, ) { - if (typeof value === "string" && value.trim() === "") { - return "参数不能为空"; - } - const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - return "请输入有效数字"; + const resolved = resolveParameterValue(value, definition, selectedUnit); + if (!resolved.ok) { + return resolved.message; } + const numericValue = resolved.value; if ( definition.options && !definition.options.some((option) => option.value === numericValue) @@ -5153,6 +5315,85 @@ function parameterValidationMessage( return ""; } +function simulationCalculatedValueMetadata( + value: ParameterValue, + unit: string, +) { + if (!isParameterExpressionValue(value)) { + return undefined; + } + const resolved = resolveNumericInput(value); + if (!resolved.ok || !resolved.expression) { + return undefined; + } + return `${formatEditableNumber(resolved.value)}${unit ? ` ${unit}` : ""}`; +} + +function simulationConfigValidationMessages( + simulation: SimulationConfig, +): Partial> { + const resolutions = { + t_start: resolveNumericInput(simulation.t_start), + t_stop: resolveNumericInput(simulation.t_stop), + step: resolveNumericInput(simulation.step), + max_step: resolveNumericInput(simulation.max_step), + } satisfies Record; + const messages: Partial> = {}; + + (Object.keys(resolutions) as SimulationNumericKey[]).forEach((key) => { + const resolved = resolutions[key]; + if (!resolved.ok) { + messages[key] = resolved.message; + } + }); + if ( + resolutions.t_start.ok && + resolutions.t_stop.ok && + resolutions.t_stop.value <= resolutions.t_start.value + ) { + messages.t_stop = "必须大于起始时间"; + } + if (resolutions.step.ok && resolutions.step.value <= 0) { + messages.step = "必须大于 0"; + } + if (resolutions.max_step.ok && resolutions.max_step.value <= 0) { + messages.max_step = "必须大于 0"; + } + return messages; +} + +type SimulationConfigResolution = + | { ok: true; value: ResolvedSimulationConfig } + | { ok: false; message: string }; + +function resolveSimulationConfig( + simulation: SimulationConfig, +): SimulationConfigResolution { + const messages = simulationConfigValidationMessages(simulation); + const firstInvalidKey = (Object.keys(messages) as SimulationNumericKey[])[0]; + if (firstInvalidKey) { + return { + ok: false, + message: `仿真设置“${simulationConfigLabels[firstInvalidKey]}”:${messages[firstInvalidKey]}`, + }; + } + return { + ok: true, + value: { + t_start: (resolveNumericInput(simulation.t_start) as { ok: true; value: number }) + .value, + t_stop: (resolveNumericInput(simulation.t_stop) as { ok: true; value: number }) + .value, + step: (resolveNumericInput(simulation.step) as { ok: true; value: number }) + .value, + max_step: ( + resolveNumericInput(simulation.max_step) as { ok: true; value: number } + ).value, + method: simulation.method, + }, + }; +} + function findDuplicateNames(nodes: SimulationNode[]) { const counts = new Map(); nodes.forEach((node) => { @@ -5236,9 +5477,16 @@ function validateModel( return; } Object.entries(definition.parameters).forEach(([key, parameter]) => { - const message = parameterValidationMessage(parameter, node.data.parameters[key] ?? ""); + const selectedUnit = + node.data.parameterUnits[key] ?? parameter.unit ?? ""; + const message = parameterValidationMessage( + parameter, + node.data.parameters[key] ?? "", + selectedUnit, + ); if (message) { - addIssue("error", `${node.data.label}:${parameter.label}${message}`, { + const messageSeparator = message.startsWith("等式") ? ":" : ""; + addIssue("error", `${node.data.label}:${parameter.label}${messageSeparator}${message}`, { nodeId: node.id, }); } @@ -5416,17 +5664,13 @@ function validateModel( }); }); - if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) { - addIssue("error", "仿真起止时间必须是有效数字"); - } else if (simulation.t_stop <= simulation.t_start) { - addIssue("error", "仿真结束时间必须大于起始时间"); - } - if (!Number.isFinite(simulation.step) || simulation.step <= 0) { - addIssue("error", "采样步长必须大于 0"); - } - if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) { - addIssue("error", "最大积分步长必须大于 0"); - } + const simulationErrors = simulationConfigValidationMessages(simulation); + (Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => { + const message = simulationErrors[key]; + if (message) { + addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`); + } + }); if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) { addIssue("error", "求解器不受支持"); } @@ -5548,7 +5792,15 @@ function formatTime(date: Date) { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); } -function buildSystemXml(project: ReactFlowProjectPayload) { +function buildSystemXml( + project: ReactFlowProjectPayload, + componentDefinitions: ComponentDefinition[], +) { + const simulationResolution = resolveSimulationConfig(project.simulation); + if (!simulationResolution.ok) { + throw new Error(simulationResolution.message); + } + const resolvedSimulation = simulationResolution.value; const xmlDocument = document.implementation.createDocument("", "System", null); const system = xmlDocument.documentElement; system.setAttribute("name", project.name); @@ -5557,16 +5809,22 @@ function buildSystemXml(project: ReactFlowProjectPayload) { system.setAttribute("unitSystem", "SI"); const simulation = xmlDocument.createElement("Simulation"); - simulation.setAttribute("tStart", String(project.simulation.t_start)); - simulation.setAttribute("tStop", String(project.simulation.t_stop)); - simulation.setAttribute("step", String(project.simulation.step)); - simulation.setAttribute("maxStep", String(project.simulation.max_step)); - simulation.setAttribute("method", project.simulation.method); + simulation.setAttribute("tStart", String(resolvedSimulation.t_start)); + simulation.setAttribute("tStop", String(resolvedSimulation.t_stop)); + simulation.setAttribute("step", String(resolvedSimulation.step)); + simulation.setAttribute("maxStep", String(resolvedSimulation.max_step)); + simulation.setAttribute("method", resolvedSimulation.method); const components = xmlDocument.createElement("Components"); const connections = xmlDocument.createElement("Connections"); system.append(simulation, components, connections); project.nodes.forEach((node) => { + const definition = componentDefinitions.find( + (candidate) => candidate.type === node.data.componentType, + ); + if (!definition) { + throw new Error(`组件 ${node.data.label || node.id} 的类型不受支持`); + } const component = xmlDocument.createElement("Component"); component.setAttribute("id", node.id); component.setAttribute("name", node.data.label); @@ -5590,9 +5848,20 @@ function buildSystemXml(project: ReactFlowProjectPayload) { component.appendChild(port); }); Object.entries(node.data.parameters).forEach(([name, value]) => { + const parameterDefinition = definition.parameters[name]; + const selectedUnit = + node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? ""; + const resolved = parameterDefinition + ? resolveParameterValue(value, parameterDefinition, selectedUnit) + : resolveNumericInput(value); + if (!resolved.ok) { + throw new Error( + `组件“${node.data.label || node.id}”的参数“${parameterDefinition?.label ?? name}”:${resolved.message}`, + ); + } const parameter = xmlDocument.createElement("Parameter"); parameter.setAttribute("name", name); - parameter.setAttribute("value", String(value)); + parameter.setAttribute("value", formatEditableNumber(resolved.value)); component.appendChild(parameter); }); components.appendChild(component); diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index d6aa6da..41d947b 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -1,39 +1,43 @@ -import type { ReactNode } from "react"; +import { AmesimPnpl01Symbol } from "./componentSymbols/boundary"; +import { + AmesimF000Symbol, + AmesimForcSymbol, + AmesimLmechn1Symbol, + AmesimLstp00aSymbol, + AmesimMecmas21Symbol, +} from "./componentSymbols/mechanical"; +import { + AmesimHeliumMediumSymbol, + AmesimIdealAirMediumSymbol, +} from "./componentSymbols/media"; +import { + AmesimPn3Node2Symbol, + AmesimP4Node2Symbol, + AmesimPnl0001Symbol, + AmesimPnl0002Symbol, + AmesimPnl0003Symbol, + AmesimPnl00rSymbol, + AmesimPnor001Symbol, + AmesimPnvo001FixedSymbol, + AmesimPnvo001SignalSymbol, +} from "./componentSymbols/pneumatic"; +import { SymbolSvg } from "./componentSymbols/primitives"; +import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals"; +import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage"; +import type { + ComponentSymbolPresentation, + SymbolDefinition, +} from "./componentSymbols/types"; + +export type { ComponentSymbolPresentation } from "./componentSymbols/types"; export type ComponentSymbolSurface = "palette" | "canvas"; -export type ComponentSymbolPresentation = "framed" | "bare"; - type ComponentSymbolProps = { symbol: string; surface: ComponentSymbolSurface; className?: string; }; -type SymbolDefinition = { - presentation: ComponentSymbolPresentation; - render: () => ReactNode; -}; - -type SymbolSvgProps = { - children: ReactNode; - className?: string; -}; - -function SymbolSvg({ children, className }: SymbolSvgProps) { - return ( - - ); -} - function CylinderSymbol() { return ( @@ -145,28 +149,6 @@ function JunctionSymbol() { ); } -function MediumSymbol({ label }: { label: "Air" | "He" }) { - return ( - - - - {label} - - - ); -} - const symbolRegistry = { cylinder: { presentation: "framed", render: CylinderSymbol }, tank: { presentation: "framed", render: TankSymbol }, @@ -176,13 +158,38 @@ const symbolRegistry = { generic: { presentation: "framed", render: GenericSymbol }, signal: { presentation: "framed", render: SignalSymbol }, junction: { presentation: "framed", render: JunctionSymbol }, + amesim_pnpl01: { presentation: "framed", render: AmesimPnpl01Symbol }, + amesim_pnch023: { presentation: "framed", render: AmesimPnch023Symbol }, + amesim_pnch012: { presentation: "framed", render: AmesimPnch012Symbol }, + amesim_pnor001: { presentation: "framed", render: AmesimPnor001Symbol }, + amesim_pnvo001_fixed: { + presentation: "framed", + render: AmesimPnvo001FixedSymbol, + }, + amesim_pnvo001: { + presentation: "framed", + render: AmesimPnvo001SignalSymbol, + }, + amesim_pnl00r: { presentation: "framed", render: AmesimPnl00rSymbol }, + amesim_pnl0001: { presentation: "framed", render: AmesimPnl0001Symbol }, + amesim_pnl0002: { presentation: "framed", render: AmesimPnl0002Symbol }, + amesim_pnl0003: { presentation: "framed", render: AmesimPnl0003Symbol }, + amesim_pn3node2: { presentation: "framed", render: AmesimPn3Node2Symbol }, + amesim_p4node2: { presentation: "framed", render: AmesimP4Node2Symbol }, + amesim_step0: { presentation: "framed", render: AmesimStep0Symbol }, + amesim_ud00: { presentation: "framed", render: AmesimUd00Symbol }, + amesim_f000: { presentation: "framed", render: AmesimF000Symbol }, + amesim_forc: { presentation: "framed", render: AmesimForcSymbol }, + amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol }, + amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol }, + amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol }, amesim_ideal_air_medium: { presentation: "bare", - render: () => , + render: AmesimIdealAirMediumSymbol, }, amesim_helium_medium: { presentation: "bare", - render: () => , + render: AmesimHeliumMediumSymbol, }, } satisfies Record; diff --git a/frontend/src/ParameterTable.tsx b/frontend/src/ParameterTable.tsx index 2407a74..df842b0 100644 --- a/frontend/src/ParameterTable.tsx +++ b/frontend/src/ParameterTable.tsx @@ -32,6 +32,7 @@ export type ParameterTableRow = { defaultValue?: string; range?: string; currentUnit?: string; + calculatedValue?: string; }; type ParameterTableProps = { @@ -144,7 +145,8 @@ function rowHasHelpPreview(row: ParameterTableRow) { (typeof row.help === "string" && row.help) || row.defaultValue || row.range || - row.currentUnit, + row.currentUnit || + row.calculatedValue, ); } @@ -462,10 +464,16 @@ export function ParameterTable({ {previewDescription} ) : null} - {previewRow.defaultValue || + {previewRow.calculatedValue || + previewRow.defaultValue || previewRow.range || previewRow.currentUnit ? ( + {previewRow.calculatedValue ? ( + + 计算值:{previewRow.calculatedValue} + + ) : null} {previewRow.defaultValue ? ( 默认值:{previewRow.defaultValue} ) : null} diff --git a/frontend/src/componentSymbols/boundary.tsx b/frontend/src/componentSymbols/boundary.tsx new file mode 100644 index 0000000..4115000 --- /dev/null +++ b/frontend/src/componentSymbols/boundary.tsx @@ -0,0 +1,15 @@ +import { SYMBOL_ACCENT, SYMBOL_STROKE, SymbolSvg } from "./primitives"; + +export function AmesimPnpl01Symbol() { + return ( + + + + + ); +} diff --git a/frontend/src/componentSymbols/mechanical.tsx b/frontend/src/componentSymbols/mechanical.tsx new file mode 100644 index 0000000..ffff848 --- /dev/null +++ b/frontend/src/componentSymbols/mechanical.tsx @@ -0,0 +1,217 @@ +import { + SYMBOL_ACCENT, + SYMBOL_FILL, + SYMBOL_MUTED, + SYMBOL_STROKE, + SymbolSvg, +} from "./primitives"; + +const MECHANICAL_LINE_WIDTH = 1.8; + +const mechanicalStroke = { + fill: "none", + stroke: SYMBOL_STROKE, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + strokeWidth: MECHANICAL_LINE_WIDTH, +}; + +function MechanicalSocket({ side }: { side: "left" | "right" }) { + const facingLeft = side === "left"; + return ( + + + + + ); +} + +function GuideHatching({ y, upward }: { y: number; upward: boolean }) { + const direction = upward ? -1 : 1; + return ( + + {[14, 22, 30, 38, 46].map((x) => ( + + ))} + + ); +} + +export function AmesimF000Symbol() { + return ( + + + + + + ); +} + +export function AmesimForcSymbol() { + return ( + + + + + + + + F + + + + ); +} + +export function AmesimMecmas21Symbol() { + return ( + + + + + + + + + M + + + + ); +} + +export function AmesimLstp00aSymbol() { + return ( + + + + + + + + + + + + + ); +} + +export function AmesimLmechn1Symbol() { + const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42]; + return ( + + + {leftPortRows.map((y) => ( + + ))} + + + + ); +} diff --git a/frontend/src/componentSymbols/media.tsx b/frontend/src/componentSymbols/media.tsx new file mode 100644 index 0000000..ff8a948 --- /dev/null +++ b/frontend/src/componentSymbols/media.tsx @@ -0,0 +1,38 @@ +import { SYMBOL_ACCENT, SYMBOL_FILL, SYMBOL_STROKE, SymbolSvg } from "./primitives"; + +function MediumSymbol({ label }: { label: "Air" | "He" }) { + return ( + + + + {label} + + + ); +} + +export function AmesimIdealAirMediumSymbol() { + return ; +} + +export function AmesimHeliumMediumSymbol() { + return ; +} diff --git a/frontend/src/componentSymbols/pneumatic.tsx b/frontend/src/componentSymbols/pneumatic.tsx new file mode 100644 index 0000000..989da51 --- /dev/null +++ b/frontend/src/componentSymbols/pneumatic.tsx @@ -0,0 +1,233 @@ +import { + SYMBOL_ACCENT, + SYMBOL_FILL, + SYMBOL_MUTED, + SYMBOL_STROKE, + SymbolSvg, +} from "./primitives"; + +const PNEUMATIC_STROKE_WIDTH = 1.7; + +function OrificeBody({ + centerY = 25, + leftPortY = centerY, +}: { + centerY?: number; + leftPortY?: number; +}) { + const top = centerY - 10; + const bottom = centerY + 10; + const leftConnector = + leftPortY === centerY + ? `M2 ${centerY}h18` + : `M2 ${leftPortY}h14L20 ${centerY}`; + + return ( + <> + + + + ); +} + +/** Constant-coefficient pneumatic orifice (AMESim PNOR001). */ +export function AmesimPnor001Symbol() { + return ( + + + + ); +} + +/** PNVO001 convenience variant whose opening is fixed by a parameter. */ +export function AmesimPnvo001FixedSymbol() { + return ( + + + + + ); +} + +/** Signal-modulated pneumatic orifice (AMESim PNVO001 / pn_morifice). */ +export function AmesimPnvo001SignalSymbol() { + return ( + + + + + ); +} + +type PipeSection = "C" | "R"; + +function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) { + const left = 9; + const right = 55; + const top = 13; + const bottom = 35; + const sectionWidth = (right - left) / sections.length; + + return ( + <> + + + {sections.slice(1).map((_, index) => { + const x = left + sectionWidth * (index + 1); + return ( + + ); + })} + {sections.map((section, index) => ( + + {section} + + ))} + + ); +} + +/** Pure pneumatic resistance line (AMESim PNL00R). */ +export function AmesimPnl00rSymbol() { + return ( + + + + ); +} + +/** C-R dynamic pneumatic line (AMESim PNL0001). */ +export function AmesimPnl0001Symbol() { + return ( + + + + ); +} + +/** R-C-R dynamic pneumatic line (AMESim PNL0002). */ +export function AmesimPnl0002Symbol() { + return ( + + + + ); +} + +/** C-R-C dynamic pneumatic line (AMESim PNL0003). */ +export function AmesimPnl0003Symbol() { + return ( + + + + ); +} + +function JunctionTerminal({ x, y }: { x: number; y: number }) { + return ( + + ); +} + +/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */ +export function AmesimPn3Node2Symbol() { + return ( + + + + + + + + ); +} + +/** Four-port pneumatic junction with one inlet-side and three outlet-side ports. */ +export function AmesimP4Node2Symbol() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/componentSymbols/primitives.tsx b/frontend/src/componentSymbols/primitives.tsx new file mode 100644 index 0000000..3de202c --- /dev/null +++ b/frontend/src/componentSymbols/primitives.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +export type SymbolDomain = + | "neutral" + | "pneumatic" + | "mechanical" + | "signal" + | "medium"; + +type SymbolSvgProps = { + children: ReactNode; + className?: string; + domain?: SymbolDomain; + viewBox?: string; +}; + +export const SYMBOL_STROKE = "var(--component-symbol-stroke, currentColor)"; +export const SYMBOL_FILL = "var(--component-symbol-fill, #ffffff)"; +export const SYMBOL_ACCENT = "var(--component-symbol-accent, currentColor)"; +export const SYMBOL_MUTED = "var(--component-symbol-muted, #64748b)"; + +export function SymbolSvg({ + children, + className, + domain = "neutral", + viewBox = "0 0 64 48", +}: SymbolSvgProps) { + return ( + + ); +} diff --git a/frontend/src/componentSymbols/signals.tsx b/frontend/src/componentSymbols/signals.tsx new file mode 100644 index 0000000..005e78d --- /dev/null +++ b/frontend/src/componentSymbols/signals.tsx @@ -0,0 +1,71 @@ +import { + SYMBOL_ACCENT, + SYMBOL_FILL, + SYMBOL_STROKE, + SymbolSvg, +} from "./primitives"; + +const SIGNAL_LINE_WIDTH = 1.8; + +function SignalOutput() { + return ( + + + + + ); +} + +export function AmesimStep0Symbol() { + return ( + + + + + + ); +} + +export function AmesimUd00Symbol() { + return ( + + + + + + ); +} diff --git a/frontend/src/componentSymbols/storage.tsx b/frontend/src/componentSymbols/storage.tsx new file mode 100644 index 0000000..510af12 --- /dev/null +++ b/frontend/src/componentSymbols/storage.tsx @@ -0,0 +1,69 @@ +import { + SYMBOL_ACCENT, + SYMBOL_FILL, + SYMBOL_STROKE, + SymbolSvg, +} from "./primitives"; + +function ChamberCore({ variable }: { variable: boolean }) { + return ( + <> + + + Cp + + {variable ? ( + + + + + + + ) : null} + + ); +} + +export function AmesimPnch023Symbol() { + return ( + + + + + ); +} + +export function AmesimPnch012Symbol() { + return ( + + + + + ); +} diff --git a/frontend/src/componentSymbols/types.ts b/frontend/src/componentSymbols/types.ts new file mode 100644 index 0000000..752bbec --- /dev/null +++ b/frontend/src/componentSymbols/types.ts @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; + +export type ComponentSymbolPresentation = "framed" | "bare"; + +export type SymbolDefinition = { + presentation: ComponentSymbolPresentation; + render: () => ReactNode; +}; diff --git a/frontend/src/parameterExpression.ts b/frontend/src/parameterExpression.ts new file mode 100644 index 0000000..37d8a49 --- /dev/null +++ b/frontend/src/parameterExpression.ts @@ -0,0 +1,458 @@ +export type ParameterExpressionEvaluation = + | { ok: true; value: number } + | { ok: false; message: string }; + +const MAX_INPUT_LENGTH = 512; +const MAX_TOKEN_COUNT = 256; +const MAX_OPERATION_COUNT = 256; +const MAX_NESTING_DEPTH = 32; +const MAX_FUNCTION_ARGUMENTS = 16; + +const NUMERIC_LITERAL_PATTERN = + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; +const UNSIGNED_NUMBER_PREFIX = + /^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/; + +type TokenKind = + | "number" + | "identifier" + | "operator" + | "leftParenthesis" + | "rightParenthesis" + | "comma" + | "end"; + +type Token = { + kind: TokenKind; + text: string; + position: number; + value?: number; +}; + +class ParameterExpressionError extends Error {} + +/** + * Distinguishes a stored equation from an ordinary numeric parameter value. + * Invalid non-numeric strings intentionally count as expressions so callers + * can run them through the evaluator and present its specific error message. + */ +export function isParameterExpressionValue(value: number | string): value is string { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return trimmed.startsWith("=") || !NUMERIC_LITERAL_PATTERN.test(trimmed); +} + +/** + * Safely evaluates the arithmetic subset accepted by AMESim-style parameter + * fields. This parser never executes JavaScript and cannot access globals, + * object properties, or arbitrary functions. + */ +export function evaluateParameterExpression( + input: string, +): ParameterExpressionEvaluation { + try { + let source = input.trim(); + if (source.startsWith("=")) { + source = source.slice(1).trim(); + } + if (!source) { + throw new ParameterExpressionError("表达式不能为空"); + } + if (source.length > MAX_INPUT_LENGTH) { + throw new ParameterExpressionError( + `表达式长度不能超过 ${MAX_INPUT_LENGTH} 个字符`, + ); + } + + const parser = new ParameterExpressionParser(tokenize(source)); + return { ok: true, value: parser.parse() }; + } catch (error) { + return { + ok: false, + message: + error instanceof ParameterExpressionError + ? error.message + : "表达式无法计算", + }; + } +} + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let position = 0; + + const pushToken = (token: Token) => { + tokens.push(token); + if (tokens.length > MAX_TOKEN_COUNT) { + throw new ParameterExpressionError( + `表达式过于复杂,最多允许 ${MAX_TOKEN_COUNT} 个词元`, + ); + } + }; + + while (position < source.length) { + const character = source[position]; + if (/\s/.test(character)) { + position += 1; + continue; + } + + if (/\d/.test(character) || (character === "." && /\d/.test(source[position + 1] ?? ""))) { + const match = UNSIGNED_NUMBER_PREFIX.exec(source.slice(position)); + if (!match) { + throw new ParameterExpressionError( + `第 ${position + 1} 个字符附近的数字格式无效`, + ); + } + const text = match[0]; + const value = Number(text); + if (!Number.isFinite(value)) { + throw new ParameterExpressionError(`数字“${text}”不是有限数值`); + } + pushToken({ kind: "number", text, position, value }); + position += text.length; + continue; + } + + if (/[A-Za-z_]/.test(character)) { + let end = position + 1; + while (end < source.length && /[A-Za-z0-9_]/.test(source[end])) { + end += 1; + } + const text = source.slice(position, end); + pushToken({ kind: "identifier", text, position }); + position = end; + continue; + } + + if (character === "*" && source[position + 1] === "*") { + pushToken({ kind: "operator", text: "**", position }); + position += 2; + continue; + } + if (["+", "-", "*", "/", "^"].includes(character)) { + pushToken({ kind: "operator", text: character, position }); + position += 1; + continue; + } + if (character === "(") { + pushToken({ kind: "leftParenthesis", text: character, position }); + position += 1; + continue; + } + if (character === ")") { + pushToken({ kind: "rightParenthesis", text: character, position }); + position += 1; + continue; + } + if (character === ",") { + pushToken({ kind: "comma", text: character, position }); + position += 1; + continue; + } + + throw new ParameterExpressionError( + `第 ${position + 1} 个字符包含不支持的符号“${character}”`, + ); + } + + tokens.push({ kind: "end", text: "", position: source.length }); + return tokens; +} + +class ParameterExpressionParser { + private index = 0; + private operationCount = 0; + private readonly tokens: readonly Token[]; + + constructor(tokens: readonly Token[]) { + this.tokens = tokens; + } + + parse() { + const value = this.parseAdditive(0); + const trailing = this.current(); + if (trailing.kind !== "end") { + throw new ParameterExpressionError( + `第 ${trailing.position + 1} 个字符附近存在多余内容“${trailing.text}”`, + ); + } + return ensureFinite(value, "表达式的计算结果"); + } + + private parseAdditive(depth: number): number { + let value = this.parseMultiplicative(depth); + while (this.isOperator("+") || this.isOperator("-")) { + const operator = this.advance().text; + const right = this.parseMultiplicative(depth); + this.countOperation(); + value = ensureFinite( + operator === "+" ? value + right : value - right, + `运算“${operator}”的结果`, + ); + } + return value; + } + + private parseMultiplicative(depth: number): number { + let value = this.parseUnary(depth); + while (this.isOperator("*") || this.isOperator("/")) { + const operator = this.advance().text; + const right = this.parseUnary(depth); + this.countOperation(); + if (operator === "/" && right === 0) { + throw new ParameterExpressionError("除数不能为 0"); + } + value = ensureFinite( + operator === "*" ? value * right : value / right, + `运算“${operator}”的结果`, + ); + } + return value; + } + + private parseUnary(depth: number): number { + this.assertDepth(depth); + if (this.isOperator("+") || this.isOperator("-")) { + const operator = this.advance().text; + this.countOperation(); + const operand = this.parseUnary(depth + 1); + return ensureFinite( + operator === "+" ? operand : -operand, + `一元运算“${operator}”的结果`, + ); + } + return this.parsePower(depth); + } + + private parsePower(depth: number): number { + this.assertDepth(depth); + const base = this.parsePrimary(depth); + if (!this.isOperator("^") && !this.isOperator("**")) { + return base; + } + const operator = this.advance().text; + const exponent = this.parseUnary(depth + 1); + this.countOperation(); + return ensureFinite(Math.pow(base, exponent), `运算“${operator}”的结果`); + } + + private parsePrimary(depth: number): number { + this.assertDepth(depth); + const token = this.current(); + if (token.kind === "number") { + this.advance(); + return ensureFinite(token.value ?? Number.NaN, `数字“${token.text}”`); + } + + if (token.kind === "identifier") { + this.advance(); + const name = token.text.toLowerCase(); + if (this.current().kind === "leftParenthesis") { + return this.parseFunctionCall(name, token.text, depth + 1); + } + if (name === "pi") { + return Math.PI; + } + if (name === "e") { + return Math.E; + } + throw new ParameterExpressionError(`未知标识符“${token.text}”`); + } + + if (token.kind === "leftParenthesis") { + this.advance(); + const value = this.parseAdditive(depth + 1); + this.expect("rightParenthesis", "缺少右括号“)”"); + return value; + } + + if (token.kind === "end") { + throw new ParameterExpressionError("表达式结尾缺少数字、常量或函数"); + } + throw new ParameterExpressionError( + `第 ${token.position + 1} 个字符附近需要数字、常量或函数`, + ); + } + + private parseFunctionCall( + normalizedName: string, + sourceName: string, + depth: number, + ): number { + this.assertDepth(depth); + this.expect("leftParenthesis", `函数 ${sourceName} 后缺少左括号“(”`); + const arguments_: number[] = []; + if (this.current().kind !== "rightParenthesis") { + while (true) { + if (arguments_.length >= MAX_FUNCTION_ARGUMENTS) { + throw new ParameterExpressionError( + `函数 ${sourceName} 最多接受 ${MAX_FUNCTION_ARGUMENTS} 个参数`, + ); + } + arguments_.push(this.parseAdditive(depth)); + if (this.current().kind !== "comma") { + break; + } + this.advance(); + if (this.current().kind === "rightParenthesis") { + throw new ParameterExpressionError(`函数 ${sourceName} 的逗号后缺少参数`); + } + } + } + this.expect("rightParenthesis", `函数 ${sourceName} 缺少右括号“)”`); + this.countOperation(); + return evaluateFunction(normalizedName, sourceName, arguments_); + } + + private current() { + return this.tokens[this.index] ?? this.tokens[this.tokens.length - 1]; + } + + private advance() { + const token = this.current(); + if (token.kind !== "end") { + this.index += 1; + } + return token; + } + + private expect(kind: TokenKind, message: string) { + if (this.current().kind !== kind) { + throw new ParameterExpressionError(message); + } + return this.advance(); + } + + private isOperator(operator: string) { + const token = this.current(); + return token.kind === "operator" && token.text === operator; + } + + private assertDepth(depth: number) { + if (depth > MAX_NESTING_DEPTH) { + throw new ParameterExpressionError( + `表达式嵌套层级不能超过 ${MAX_NESTING_DEPTH} 层`, + ); + } + } + + private countOperation() { + this.operationCount += 1; + if (this.operationCount > MAX_OPERATION_COUNT) { + throw new ParameterExpressionError( + `表达式过于复杂,最多允许 ${MAX_OPERATION_COUNT} 次运算`, + ); + } + } +} + +function evaluateFunction( + normalizedName: string, + sourceName: string, + arguments_: readonly number[], +) { + const requireCount = (expected: number) => { + if (arguments_.length !== expected) { + throw new ParameterExpressionError( + `函数 ${sourceName} 需要 ${expected} 个参数,当前提供了 ${arguments_.length} 个`, + ); + } + }; + const requireAtLeastOne = () => { + if (arguments_.length === 0) { + throw new ParameterExpressionError(`函数 ${sourceName} 至少需要 1 个参数`); + } + }; + + let value: number; + switch (normalizedName) { + case "sqrt": + requireCount(1); + if (arguments_[0] < 0) { + throw new ParameterExpressionError("函数 sqrt 的参数不能小于 0"); + } + value = Math.sqrt(arguments_[0]); + break; + case "abs": + requireCount(1); + value = Math.abs(arguments_[0]); + break; + case "sin": + requireCount(1); + value = Math.sin(arguments_[0]); + break; + case "cos": + requireCount(1); + value = Math.cos(arguments_[0]); + break; + case "tan": + requireCount(1); + value = Math.tan(arguments_[0]); + break; + case "asin": + requireCount(1); + if (arguments_[0] < -1 || arguments_[0] > 1) { + throw new ParameterExpressionError("函数 asin 的参数必须位于 [-1, 1]"); + } + value = Math.asin(arguments_[0]); + break; + case "acos": + requireCount(1); + if (arguments_[0] < -1 || arguments_[0] > 1) { + throw new ParameterExpressionError("函数 acos 的参数必须位于 [-1, 1]"); + } + value = Math.acos(arguments_[0]); + break; + case "atan": + requireCount(1); + value = Math.atan(arguments_[0]); + break; + case "exp": + requireCount(1); + value = Math.exp(arguments_[0]); + break; + case "ln": + case "log": + requireCount(1); + if (arguments_[0] <= 0) { + throw new ParameterExpressionError(`函数 ${sourceName} 的参数必须大于 0`); + } + value = Math.log(arguments_[0]); + break; + case "log10": + requireCount(1); + if (arguments_[0] <= 0) { + throw new ParameterExpressionError("函数 log10 的参数必须大于 0"); + } + value = Math.log10(arguments_[0]); + break; + case "min": + requireAtLeastOne(); + value = Math.min(...arguments_); + break; + case "max": + requireAtLeastOne(); + value = Math.max(...arguments_); + break; + case "pow": + requireCount(2); + value = Math.pow(arguments_[0], arguments_[1]); + break; + default: + throw new ParameterExpressionError(`不支持函数“${sourceName}”`); + } + return ensureFinite(value, `函数 ${sourceName} 的计算结果`); +} + +function ensureFinite(value: number, context: string) { + if (!Number.isFinite(value)) { + throw new ParameterExpressionError(`${context}不是有限数值`); + } + return value; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f337bfe..3b885bc 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -964,6 +964,42 @@ textarea { width: 100%; height: 100%; overflow: visible; + shape-rendering: geometricPrecision; +} + +.component-symbol-svg[data-symbol-domain="neutral"] { + --component-symbol-stroke: #334155; + --component-symbol-fill: #f8fafc; + --component-symbol-accent: #2563eb; + --component-symbol-muted: #94a3b8; +} + +.component-symbol-svg[data-symbol-domain="pneumatic"] { + --component-symbol-stroke: #a12b70; + --component-symbol-fill: #fff8fc; + --component-symbol-accent: #bd2779; + --component-symbol-muted: #d69aba; +} + +.component-symbol-svg[data-symbol-domain="mechanical"] { + --component-symbol-stroke: #1f9d42; + --component-symbol-fill: #f4fff6; + --component-symbol-accent: #15803d; + --component-symbol-muted: #86c994; +} + +.component-symbol-svg[data-symbol-domain="signal"] { + --component-symbol-stroke: #ef4444; + --component-symbol-fill: #fff8f8; + --component-symbol-accent: #dc2626; + --component-symbol-muted: #f5a3a3; +} + +.component-symbol-svg[data-symbol-domain="medium"] { + --component-symbol-stroke: #c026d3; + --component-symbol-fill: #ffffff; + --component-symbol-accent: #a21caf; + --component-symbol-muted: #e879f9; } .component-symbol-medium-label { diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts new file mode 100644 index 0000000..c45bd46 --- /dev/null +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from "@playwright/test"; + +type SymbolCase = { + category: string; + domain: "medium" | "pneumatic" | "signal" | "mechanical"; + modelType: string; +}; + +const symbolCases: SymbolCase[] = [ + { category: "media", domain: "medium", modelType: "amesim_ideal_air_medium" }, + { category: "media", domain: "medium", modelType: "amesim_helium_medium" }, + { category: "storage", domain: "pneumatic", modelType: "amesim_pnch023" }, + { category: "storage", domain: "pneumatic", modelType: "amesim_pnch012" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnor001" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001_fixed" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnl00r" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0001" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0002" }, + { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0003" }, + { category: "junctions", domain: "pneumatic", modelType: "amesim_pn3node2" }, + { category: "junctions", domain: "pneumatic", modelType: "amesim_p4node2" }, + { category: "boundary", domain: "pneumatic", modelType: "amesim_pnpl01" }, + { category: "signals", domain: "signal", modelType: "amesim_step0" }, + { category: "signals", domain: "signal", modelType: "amesim_ud00" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_f000" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_forc" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_mecmas21" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_lstp00a" }, + { category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" }, +]; + +const categoryLabels: Record = { + boundary: "边界元件", + flow: "流动元件", + junctions: "连接元件", + mechanical: "机械元件", + media: "介质物性", + signals: "信号元件", + storage: "储能元件", +}; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const preparedKey = "system-simulation-flow:component-symbols-prepared"; + if (window.sessionStorage.getItem(preparedKey) !== "1") { + window.localStorage.clear(); + window.sessionStorage.clear(); + window.sessionStorage.setItem(preparedKey, "1"); + } + window.location.hash = "#/modeling"; + }); + await page.route("**/api/components/catalog", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + schemaVersion: 1, + libraries: [ + { + id: "amesim", + label: "AMESim 组件库", + version: "0.2.0", + sourcePackage: "tests.e2e.amesim-symbols", + temporary: true, + order: 1, + components: symbolCases.map((item, index) => ({ + type: item.modelType, + label: item.modelType, + modelType: item.modelType, + modelVersion: "0.1.0", + symbol: item.modelType, + order: index + 1, + category: { + id: item.category, + label: categoryLabels[item.category], + order: Object.keys(categoryLabels).indexOf(item.category) + 1, + }, + ports: [], + parameters: [], + })), + }, + ], + }), + }); + }); +}); + +test("全部已移植 AMESim 模型使用专属图标及正确领域配色", async ({ page }) => { + await page.goto("/"); + + const palette = page.locator("aside.palette"); + await expect(palette.locator('[data-component-symbol^="amesim_"]')).toHaveCount( + symbolCases.length, + ); + await expect(palette.locator('[data-component-symbol="generic"]')).toHaveCount(0); + + for (const item of symbolCases) { + const symbol = palette.locator(`[data-component-symbol="${item.modelType}"]`); + await expect(symbol).toHaveCount(1); + await expect(symbol.locator("svg")).toHaveAttribute("data-symbol-domain", item.domain); + await expect(symbol).toHaveAttribute( + "data-symbol-presentation", + item.domain === "medium" ? "bare" : "framed", + ); + } +}); + +test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => { + window.localStorage.setItem( + "system-simulation-flow:autosave", + JSON.stringify({ + name: "legacy-symbol-project", + nodes: [ + { + id: "amesim_pnl0001_1", + type: "simulationComponent", + position: { x: 200, y: 160 }, + data: { + label: "amesim_pnl0001_1", + componentType: "amesim_pnl0001", + modelType: "amesim_pnl0001", + symbol: "pipe", + ports: [], + parameters: {}, + parameterUnits: {}, + rotation: 0, + mirrored: false, + }, + }, + ], + edges: [], + simulation: { + t_start: 0, + t_stop: 1, + step: 0.1, + max_step: 0.05, + method: "BDF", + }, + }), + ); + }); + await page.reload(); + + await page.getByRole("button", { name: "恢复", exact: true }).click(); + const restoredNode = page.locator( + '.flow-canvas .react-flow__node[data-id="amesim_pnl0001_1"]', + ); + await expect(restoredNode).toBeVisible(); + await expect( + restoredNode.locator('[data-component-symbol="amesim_pnl0001"]'), + ).toBeVisible(); + await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0); +}); diff --git a/frontend/tests/e2e/parameter-expression.spec.ts b/frontend/tests/e2e/parameter-expression.spec.ts new file mode 100644 index 0000000..842e936 --- /dev/null +++ b/frontend/tests/e2e/parameter-expression.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +import { evaluateParameterExpression } from "../../src/parameterExpression"; + +function expectExpressionValue(expression: string, expected: number) { + const result = evaluateParameterExpression(expression); + expect(result.ok, result.ok ? undefined : result.message).toBe(true); + if (result.ok) { + expect(result.value).toBeCloseTo(expected, 10); + } +} + +test("安全解析 AMESim 风格的算术、幂与科学计数等式", () => { + expectExpressionValue("=3.14*10^2/4", 78.5); + expectExpressionValue("(2+3)*4", 20); + expectExpressionValue("2^3^2", 512); + expectExpressionValue("-2^2", -4); + expectExpressionValue("2.5E-3", 0.0025); +}); + +test("支持有限的数学常量与白名单函数", () => { + expectExpressionValue("sqrt(16)+abs(-2)", 6); + expectExpressionValue("sin(pi/2)+ln(e)", 2); + expectExpressionValue("max(1, 5, 3)+pow(2, 3)", 13); +}); + +test("拒绝除零、非法定义域、未知变量和任意代码", () => { + for (const expression of [ + "1/0", + "sqrt(-1)", + "unknown+1", + "window.alert(1)", + "1+*2", + "1e309", + ]) { + const result = evaluateParameterExpression(expression); + expect(result.ok, expression).toBe(false); + if (!result.ok) { + expect(result.message.length).toBeGreaterThan(0); + } + } +}); diff --git a/frontend/tests/e2e/parameter-table.spec.ts b/frontend/tests/e2e/parameter-table.spec.ts index 2d291c8..79d9c79 100644 --- a/frontend/tests/e2e/parameter-table.spec.ts +++ b/frontend/tests/e2e/parameter-table.spec.ts @@ -201,3 +201,108 @@ test("参数表列宽可调整并在刷新后保留", async ({ page }) => { await page.keyboard.press("ArrowRight"); await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120"); }); + +test("组件参数公式保留原式,并在说明中显示计算值且 XML 只写数值", async ({ + page, +}) => { + await page.goto("/"); + await addPneumaticTestComponent(page); + + const expression = "3.14*10^2/4"; + const componentTable = page.getByRole("table", { name: "组件参数" }); + const pressureRow = parameterRow(componentTable, "参考压力"); + const pressureInput = page.getByLabel("参考压力", { exact: true }); + + await page.getByLabel("参考压力单位").selectOption("kPa"); + await pressureInput.fill(expression); + await pressureInput.press("Enter"); + + await expect(pressureInput).toHaveValue(expression); + await expect(pressureRow).not.toContainText("78.5"); + + await pressureRow.hover(); + await page.waitForTimeout(600); + const preview = page.locator("body > #parameter-help-preview"); + await expect(preview).toBeVisible(); + await expect(preview.getByTestId("parameter-calculated-value")).toContainText( + "78.5", + ); + await expect(preview.getByTestId("parameter-calculated-value")).toContainText( + "kPa", + ); + + await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); + const consolePanel = page.getByRole("complementary", { + name: "仿真控制台", + exact: true, + }); + const xmlBlock = consolePanel.getByLabel("生成的系统 XML"); + await expect(xmlBlock).toContainText( + ' { + await page.goto("/"); + await addPneumaticTestComponent(page); + + const invalidExpression = "3.14*/4"; + const componentTable = page.getByRole("table", { name: "组件参数" }); + const pressureInput = page.getByLabel("参考压力", { exact: true }); + + await pressureInput.fill(invalidExpression); + await pressureInput.press("Enter"); + + await expect(pressureInput).toHaveValue(invalidExpression); + await expect(pressureInput).toHaveAttribute("aria-invalid", "true"); + await expect( + componentTable.locator(".parameter-table-message-row .field-error"), + ).toBeVisible(); + + await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); + const consolePanel = page.getByRole("complementary", { + name: "仿真控制台", + exact: true, + }); + await expect(consolePanel).toContainText("XML 生成失败"); + await expect(consolePanel).toContainText("参考压力"); + await expect(consolePanel.getByLabel("生成的系统 XML")).toHaveCount(0); +}); + +test("仿真数值设置同样保留公式,并在执行协议中使用计算值", async ({ + page, +}) => { + await page.goto("/"); + + const expression = "1+2*3"; + const simulationTable = page.getByRole("table", { name: "仿真设置" }); + const stopRow = parameterRow(simulationTable, "结束时间"); + const stopInput = page.getByLabel("结束时间", { exact: true }); + await stopInput.fill(expression); + await stopInput.press("Enter"); + + await expect(stopInput).toHaveValue(expression); + await expect(stopRow).not.toContainText("7"); + await stopRow.hover(); + await page.waitForTimeout(600); + await expect( + page.getByTestId("parameter-calculated-value"), + ).toContainText("7 s"); + + await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); + const xmlBlock = page.getByLabel("生成的系统 XML"); + await expect(xmlBlock).toContainText('tStop="7"'); + await expect(xmlBlock).not.toContainText(expression); +}); diff --git a/tests/test_amesim_helium_step_long_run.py b/tests/test_amesim_helium_step_long_run.py new file mode 100644 index 0000000..ffcacdf --- /dev/null +++ b/tests/test_amesim_helium_step_long_run.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import math +import unittest + +from app.main import ( + ReactFlowProjectPayload, + build_reactflow_system_xml, + compile_reactflow_network, + run_system_xml_simulation, +) +from app.system_xml import validate_system_xml_document +from tests.test_generic_system_xml_simulation import component_node, physical_edge +from tests.test_system_xml_protocol import physical_port + + +def _signal_port(name: str, role: str, side: str) -> dict[str, str | None]: + return { + "name": name, + "kind": "signal", + "domain": "signal", + "nominalRole": role, + "positiveFlowDirection": None, + "side": side, + } + + +def _signal_edge( + edge_id: str, + source: str, + source_port: str, + target: str, + target_port: str, +) -> dict[str, str]: + return { + "id": edge_id, + "source": source, + "sourceHandle": source_port, + "target": target, + "targetHandle": target_port, + } + + +def _plug(component_id: str) -> dict[str, object]: + return component_node( + component_id, + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + {}, + ) + + +def helium_step_equalization_project() -> ReactFlowProjectPayload: + """Programmatic form of the user XML that failed at the 3.04 s sample.""" + + return ReactFlowProjectPayload( + name="amesim-helium-step-equalization-regression", + nodes=[ + component_node( + "amesim_helium_medium_1", + "amesim_helium_medium", + [], + {"gi": 1.0, "property_model": 0.0}, + ), + component_node( + "amesim_pnch023_1", + "amesim_pnch023", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ], + { + "gi": 1.0, + "cvol": 0.057, + "kth": 0.0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15_300_000.0, + "T0": 293.15, + }, + ), + component_node( + "amesim_pnch012_1", + "amesim_pnch012", + [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + physical_port("port_3", "bidirectional", "left"), + physical_port("port_4", "bidirectional", "right"), + ], + { + "gi": 1.0, + "cvol0": 0.015, + "kth": 1500.0, + "sth": 0.7, + "extemp": 293.15, + "p0": 100_000.0, + "T0": 293.15, + "vol1": 0.0, + "vol2": 0.0, + "vol3": 0.0, + "vol4": 0.0, + "dvol1": 0.0, + "dvol2": 0.0, + "dvol3": 0.0, + "dvol4": 0.0, + }, + ), + component_node( + "amesim_pnvo001_1", + "amesim_pnvo001", + [ + _signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "gi": 1.0, + "cq": 0.45, + "area0": 7.85e-5, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1.0, + "opening0": 1.0, + }, + ), + component_node( + "amesim_step0_1", + "amesim_step0", + [_signal_port("out", "output", "right")], + {"initial": 0.0, "final": 1.0, "time": 0.04}, + ), + _plug("amesim_pnpl01_1"), + _plug("amesim_pnpl01_2"), + _plug("amesim_pnpl01_3"), + _plug("amesim_pnpl01_4"), + ], + edges=[ + physical_edge( + "edge-high-plug", + "amesim_pnpl01_1", + "port_1", + "amesim_pnch023_1", + "port_1", + ), + physical_edge( + "edge-high-valve", + "amesim_pnch023_1", + "port_2", + "amesim_pnvo001_1", + "port_2", + ), + physical_edge( + "edge-valve-low", + "amesim_pnvo001_1", + "port_3", + "amesim_pnch012_1", + "port_3", + ), + physical_edge( + "edge-low-port-4", + "amesim_pnch012_1", + "port_4", + "amesim_pnpl01_4", + "port_1", + ), + physical_edge( + "edge-low-port-2", + "amesim_pnch012_1", + "port_2", + "amesim_pnpl01_3", + "port_1", + ), + physical_edge( + "edge-low-port-1", + "amesim_pnpl01_2", + "port_1", + "amesim_pnch012_1", + "port_1", + ), + _signal_edge( + "edge-step-valve", + "amesim_step0_1", + "out", + "amesim_pnvo001_1", + "res", + ), + ], + simulation={ + "t_start": 0.0, + "t_stop": 10.0, + "step": 0.02, + "max_step": 0.002, + "method": "BDF", + }, + ) + + +class AmesimHeliumStepLongRunTests(unittest.TestCase): + def test_helium_step_equalization_completes_beyond_3_04_seconds(self) -> None: + project = helium_step_equalization_project() + network = compile_reactflow_network(project) + self.assertEqual( + network.components["amesim_pnch023_1"].medium.name, + "AMESimHeliumPengRobinson", + ) + self.assertEqual( + network.components["amesim_pnch012_1"].medium.name, + "AMESimHeliumPengRobinson", + ) + + xml = build_reactflow_system_xml(project) + report = validate_system_xml_document(xml) + self.assertTrue(report.valid, report.as_dict()) + + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["status"], "completed") + self.assertAlmostEqual(result["simulatedUntil"], 10.0) + self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04]) + self.assertLessEqual( + result["diagnostics"]["pressureFlow"]["maxEvaluationsPerSolve"], + 5, + ) + self.assertLessEqual( + result["diagnostics"]["pressureFlow"]["maxScaledResidual"], + 1.0e-7, + ) + + series = result["series"] + self.assertIn(3.04, series["time"]) + self.assertTrue( + all( + math.isfinite(value) + for values in series.values() + for value in values + ) + ) + + high_pressure = series["amesim_pnch023_1.p"] + low_pressure = series["amesim_pnch012_1.p"] + initial_pressure_gap = abs(high_pressure[0] - low_pressure[0]) + final_pressure_gap = abs(high_pressure[-1] - low_pressure[-1]) + self.assertLess(high_pressure[-1], high_pressure[0]) + self.assertGreater(low_pressure[-1], low_pressure[0]) + self.assertLess(final_pressure_gap, initial_pressure_gap) + self.assertLess(final_pressure_gap, 10.0) + self.assertLess( + abs(series["amesim_pnvo001_1.port_2.m_flow"][-1]), + 1.0e-4, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_amesim_pnvo001_signal_xml.py b/tests/test_amesim_pnvo001_signal_xml.py index 509b699..3eed253 100644 --- a/tests/test_amesim_pnvo001_signal_xml.py +++ b/tests/test_amesim_pnvo001_signal_xml.py @@ -84,6 +84,126 @@ def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload: ) +def high_pressure_helium_step_project() -> ReactFlowProjectPayload: + chamber_ports = [ + physical_port("port_1", "bidirectional", "left"), + physical_port("port_2", "bidirectional", "right"), + ] + return ReactFlowProjectPayload( + name="amesim-pnvo001-helium-step-regression", + nodes=[ + component_node( + "helium_1", + "amesim_helium_medium", + [], + {"gi": 1.0, "property_model": 0.0}, + ), + component_node( + "step_1", + "amesim_step0", + [signal_port("out", "output", "right")], + {"initial": 0.0, "final": 1.0, "time": 0.04}, + ), + component_node( + "high_chamber", + "amesim_pnch023", + chamber_ports, + { + "gi": 1.0, + "cvol": 0.057, + "kth": 0.0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15_300_000.0, + "T0": 293.15, + }, + ), + component_node( + "low_chamber", + "amesim_pnch023", + chamber_ports, + { + "gi": 1.0, + "cvol": 0.015, + "kth": 1500.0, + "sth": 0.7, + "extemp": 293.15, + "p0": 100_000.0, + "T0": 293.15, + }, + ), + component_node( + "valve_1", + "amesim_pnvo001", + [ + signal_port("res", "input", "left"), + physical_port("port_2", "bidirectional", "left"), + physical_port("port_3", "bidirectional", "right"), + ], + { + "gi": 1.0, + "cq": 0.45, + "area0": 7.85e-5, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1.0, + "opening0": 1.0, + }, + ), + component_node( + "high_plug", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + {}, + ), + component_node( + "low_plug", + "amesim_pnpl01", + [physical_port("port_1", "bidirectional", "left")], + {}, + ), + ], + edges=[ + signal_edge("signal-1", "step_1", "out", "valve_1", "res"), + physical_edge( + "edge-high-plug", + "high_plug", + "port_1", + "high_chamber", + "port_1", + ), + physical_edge( + "edge-high-valve", + "high_chamber", + "port_2", + "valve_1", + "port_2", + ), + physical_edge( + "edge-valve-low", + "valve_1", + "port_3", + "low_chamber", + "port_1", + ), + physical_edge( + "edge-low-plug", + "low_chamber", + "port_2", + "low_plug", + "port_1", + ), + ], + simulation={ + "t_start": 0.0, + "t_stop": 0.042, + "step": 0.002, + "max_step": 0.002, + "method": "BDF", + }, + ) + + class AmesimPnvo001SignalXmlTests(unittest.TestCase): def test_signal_project_compiles_with_signal_connection(self) -> None: network = compile_reactflow_network(amesim_pnvo001_signal_project()) @@ -107,6 +227,26 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase): self.assertIn("valve_1.xv", result["series"]) self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) + def test_high_pressure_helium_step_restarts_solver_at_event(self) -> None: + xml = build_reactflow_system_xml(high_pressure_helium_step_project()) + result = run_system_xml_simulation(xml) + + self.assertTrue(result["success"], result["message"]) + self.assertEqual(result["simulatedUntil"], 0.042) + self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04]) + + times = result["series"]["time"] + before_event = times.index(0.038) + at_event = times.index(0.04) + self.assertEqual(result["series"]["step_1.out.signal"][before_event], 0.0) + self.assertEqual(result["series"]["step_1.out.signal"][at_event], 1.0) + self.assertEqual(result["series"]["valve_1.xv"][before_event], 0.0) + self.assertEqual(result["series"]["valve_1.xv"][at_event], 1.0) + self.assertGreater( + result["series"]["valve_1.port_2.m_flow"][at_event], + 0.45, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_amesim_signal_components.py b/tests/test_amesim_signal_components.py index 93b3110..bf293b8 100644 --- a/tests/test_amesim_signal_components.py +++ b/tests/test_amesim_signal_components.py @@ -20,6 +20,8 @@ class AmesimSignalComponentTests(unittest.TestCase): self.assertEqual(step.output_at(0.49), 0.2) self.assertEqual(step.output_at(0.5), 0.8) self.assertEqual(step.signal_output_values(0.5), {"out": 0.8}) + self.assertEqual(step.signal_event_times(0.0, 1.0), (0.5,)) + self.assertEqual(step.signal_event_times(0.5, 1.0), ()) def test_ud00_output_interpolates_piecewise_signal(self) -> None: @@ -38,6 +40,7 @@ class AmesimSignalComponentTests(unittest.TestCase): self.assertAlmostEqual(signal.output_at(2.5), 15.0) self.assertAlmostEqual(signal.output_at(5.0), 35.0) self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0}) + self.assertEqual(signal.signal_event_times(0.0, 5.0), (0.5, 1.5, 3.5)) def test_ud00_can_cycle_active_stages(self) -> None: signal = AmesimUd00( @@ -53,6 +56,7 @@ class AmesimSignalComponentTests(unittest.TestCase): self.assertAlmostEqual(signal.output_at(0.25), 2.5) self.assertAlmostEqual(signal.output_at(1.25), 12.5) self.assertAlmostEqual(signal.output_at(2.25), 2.5) + self.assertEqual(signal.signal_event_times(0.0, 5.0), (1.0, 2.0, 3.0, 4.0)) def test_ud00_registry_rejects_fractional_stage_controls(self) -> None: with self.assertRaisesRegex(ValueError, "nstages must be an integer"): @@ -91,6 +95,24 @@ class AmesimSignalComponentTests(unittest.TestCase): self.assertAlmostEqual(step.out.signal, 0.75) self.assertAlmostEqual(valve.res.signal, 0.75) self.assertAlmostEqual(valve.opening, 0.75) + self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,)) + + def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None: + network = SimulationNetwork("signal-events") + network.add_component( + AmesimStep0("step_2", self.medium, initial=0.0, final=1.0, time=0.2) + ) + network.add_component( + AmesimStep0("step_1", self.medium, initial=1.0, final=0.0, time=0.1) + ) + network.add_component( + AmesimStep0("step_3", self.medium, initial=0.0, final=1.0, time=0.2) + ) + + resolver = SignalResolver(network) + + self.assertEqual(resolver.event_times(0.0, 0.3), (0.1, 0.2)) + self.assertEqual(resolver.event_times(0.2, 0.3), ()) if __name__ == "__main__": diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index 070d4a9..67407e3 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -50,6 +50,9 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(library["label"], "AMESim 组件库") self.assertEqual([category["id"] for category in library["categories"]], ["media", "storage", "flow", "junctions", "boundary", "signals", "mechanical"]) self.assertEqual(set(components), {"amesim_ideal_air_medium", "amesim_helium_medium", "amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2"}) + for model_type, component in components.items(): + with self.subTest(model_type=model_type): + self.assertEqual(component["symbol"], model_type) self.assertEqual( components["amesim_ideal_air_medium"]["role"], "amesimGasMediumDefinition", diff --git a/tests/test_core_solver.py b/tests/test_core_solver.py index 31903e7..4cbbf76 100644 --- a/tests/test_core_solver.py +++ b/tests/test_core_solver.py @@ -1,3 +1,4 @@ +import math import sys import types import unittest @@ -78,6 +79,124 @@ class IntegrateOdeTests(unittest.TestCase): self.assertEqual(result.status, "cancelled") self.assertEqual(result.t, [0.0]) + def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None: + import scipy.integrate + + event_time = 0.5 + actual_bdf = scipy.integrate.BDF + starts: list[float] = [] + bounds: list[float] = [] + call_times: list[list[float]] = [] + + class RecordingBDF(actual_bdf): + def __init__(self, fun, t0, y0, t_bound, **kwargs): + starts.append(float(t0)) + bounds.append(float(t_bound)) + segment_calls: list[float] = [] + call_times.append(segment_calls) + + def recording_fun(time, state): + segment_calls.append(float(time)) + return fun(time, state) + + super().__init__(recording_fun, t0, y0, t_bound, **kwargs) + + with patch.object(scipy.integrate, "BDF", RecordingBDF): + result = integrate_ode( + rhs=lambda time, _state: [1.0 if time < event_time else 2.0], + initial_state=[0.0], + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0, + method="BDF", + max_step=0.1, + first_step=0.8, + ), + t_eval=[0.0, event_time, event_time, 1.0], + breakpoints=[event_time], + ) + + self.assertTrue(result.success, result.message) + self.assertEqual(starts, [0.0, event_time]) + self.assertEqual(bounds[0], math.nextafter(event_time, -math.inf)) + self.assertEqual(bounds[1], 1.0) + self.assertTrue(call_times[0]) + self.assertTrue(all(time < event_time for time in call_times[0])) + self.assertTrue(any(time >= event_time for time in call_times[1])) + self.assertEqual(result.t, [0.0, event_time, 1.0]) + self.assertAlmostEqual(result.y[0][-1], 1.5, places=5) + + def test_segmented_implicit_solvers_merge_samples_and_report_progress(self) -> None: + event_time = 0.4 + + for method in ("BDF", "Radau"): + with self.subTest(method=method): + callback_times: list[float] = [] + result = integrate_ode( + rhs=lambda time, _state: [1.0 if time < event_time else 3.0], + initial_state=[0.0], + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0, + method=method, + max_step=0.05, + first_step=0.9, + ), + t_eval=[0.0, event_time, event_time, 0.7, 1.0], + accepted_step_callback=callback_times.append, + breakpoints=[event_time, event_time], + ) + + self.assertTrue(result.success, result.message) + self.assertEqual(result.t, [0.0, event_time, 0.7, 1.0]) + self.assertAlmostEqual(result.y[0][-1], 2.2, places=5) + self.assertEqual(callback_times.count(event_time), 1) + self.assertTrue( + all( + earlier < later + for earlier, later in zip( + callback_times, + callback_times[1:], + ) + ) + ) + + def test_segmented_solver_can_cancel_after_crossing_a_breakpoint(self) -> None: + callback_times: list[float] = [] + cancellation_requested = False + + def record_progress(time: float) -> None: + nonlocal cancellation_requested + callback_times.append(time) + cancellation_requested = time >= 0.55 + + result = integrate_ode( + rhs=lambda _time, _state: [1.0], + initial_state=[0.0], + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0, + method="BDF", + max_step=0.05, + ), + t_eval=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0], + cancel_check=lambda: cancellation_requested, + accepted_step_callback=record_progress, + breakpoints=[0.4, 0.8], + ) + + self.assertFalse(result.success) + self.assertEqual(result.status, "cancelled") + self.assertIn(0.4, callback_times) + self.assertGreater(callback_times[-1], 0.4) + self.assertTrue( + all( + earlier < later + for earlier, later in zip(callback_times, callback_times[1:]) + ) + ) + self.assertEqual(result.t, sorted(set(result.t))) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pressure_flow_solver_initialization.py b/tests/test_pressure_flow_solver_initialization.py new file mode 100644 index 0000000..4a7cd69 --- /dev/null +++ b/tests/test_pressure_flow_solver_initialization.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 +from app.simulation.components.amesim.flow.orifices import ( + AmesimPnvo001SignalOpening, +) +from app.simulation.components.amesim.media.mediums import ( + AmesimHeliumPengRobinsonMedium, +) +from app.simulation.components.experimental.storage.cylinder import Cylinder +from app.simulation.components.experimental.storage.tank import Tank +from app.simulation.core.medium import IdealGasMedium +from app.simulation.core.state import VolumeState +from app.simulation.solvers.algebraic import AlgebraicSolveError, PressureFlowSolver +from app.simulation.systems.network import SimulationNetwork + + +class PressureFlowSolverInitializationTests(unittest.TestCase): + @staticmethod + def _near_equal_pressure_network() -> tuple[ + SimulationNetwork, + AmesimHeliumPengRobinsonMedium, + Cylinder, + Tank, + AmesimPnvo001SignalOpening, + ]: + medium = AmesimHeliumPengRobinsonMedium() + high = Cylinder("high", medium, V=0.057, p0=1.0e5, T0=256.1) + low = Tank("low", medium, V=0.015, p0=1.0e5, T0=298.2) + valve = AmesimPnvo001SignalOpening( + "valve", + medium, + cq=0.45, + area0=7.85e-5, + gi=1.0, + flowset=1.0, + opening0=1.0, + ) + valve.res.signal = 1.0 + + network = SimulationNetwork("near-equal-pressure") + for component in (high, low, valve): + network.add_component(component) + network.connect("high", "port_b", "valve", "port_2") + network.connect("valve", "port_3", "low", "port_a") + return network, medium, high, low, valve + + @staticmethod + def _set_pressure_temperature( + component: Cylinder | Tank, + medium: AmesimHeliumPengRobinsonMedium, + pressure: float, + temperature: float, + ) -> None: + mass = medium.density(pressure, temperature) * component.V + component.state = VolumeState( + m=mass, + U=mass * medium.specific_internal_energy(temperature), + ) + component.refresh_thermodynamic_ports() + + def test_current_storage_pressure_reseeds_stale_orifice_ports_and_flow(self) -> None: + network, medium, high, low, valve = self._near_equal_pressure_network() + solver = PressureFlowSolver(network, max_evaluations=10) + high_pressure = 10_790_000.0 + + self._set_pressure_temperature(high, medium, high_pressure, 256.1) + self._set_pressure_temperature(low, medium, high_pressure - 300.0, 298.2) + initial = solver.solve() + self.assertTrue(initial.success) + + stale_low_pressure = valve.port_3.p + stale_flow = valve.port_2.m_flow + self._set_pressure_temperature(low, medium, high_pressure - 100.0, 298.2) + self.assertNotAlmostEqual(low.port_a.p, stale_low_pressure, places=3) + + updated = solver.solve() + + self.assertTrue(updated.success) + self.assertEqual(updated.evaluations, 0) + self.assertAlmostEqual(valve.port_2.p, high.port_b.p, places=6) + self.assertAlmostEqual(valve.port_3.p, low.port_a.p, places=6) + self.assertNotAlmostEqual(valve.port_2.m_flow, stale_flow, places=8) + self.assertAlmostEqual( + valve.port_2.m_flow, + valve.mass_flow(valve.port_2.p, valve.port_3.p), + places=10, + ) + self.assertAlmostEqual(high.port_b.m_flow, -valve.port_2.m_flow, places=10) + self.assertAlmostEqual(low.port_a.m_flow, -valve.port_3.m_flow, places=10) + + @staticmethod + def _closed_boundary_solver() -> PressureFlowSolver: + boundary = AmesimPnpl01("closed") + boundary.port_1.p = 100_000.0 + boundary.port_1.m_flow = 1.0 + network = SimulationNetwork("closed-boundary") + network.add_component(boundary) + return PressureFlowSolver(network) + + @staticmethod + def _least_squares_result(x, *, status: int): + return SimpleNamespace( + x=x, + success=status > 0, + status=status, + message="test optimizer result", + nfev=1, + ) + + def test_status_zero_is_accepted_only_for_finite_converged_residuals(self) -> None: + exact_solver = self._closed_boundary_solver() + + def exact_status_zero(_fun, x0, **_kwargs): + values = x0.copy() + flow_index = next( + index + for index, unknown in enumerate(exact_solver.unknowns) + if unknown.variable == "m_flow" + ) + values[flow_index] = 0.0 + return self._least_squares_result(values, status=0) + + with patch.object( + PressureFlowSolver, + "_seed_explicit_mass_flows", + return_value=None, + ), patch("scipy.optimize.least_squares", side_effect=exact_status_zero): + diagnostics = exact_solver.solve() + self.assertTrue(diagnostics.success) + self.assertEqual(diagnostics.max_scaled_residual, 0.0) + + inaccurate_solver = self._closed_boundary_solver() + + def inaccurate_status_zero(_fun, x0, **_kwargs): + values = x0.copy() + flow_index = next( + index + for index, unknown in enumerate(inaccurate_solver.unknowns) + if unknown.variable == "m_flow" + ) + values[flow_index] = 1.0 + return self._least_squares_result(values, status=0) + + with patch.object( + PressureFlowSolver, + "_seed_explicit_mass_flows", + return_value=None, + ), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero): + with self.assertRaises(AlgebraicSolveError): + inaccurate_solver.solve() + + invalid_status_solver = self._closed_boundary_solver() + + def exact_invalid_status(_fun, x0, **_kwargs): + values = x0.copy() + flow_index = next( + index + for index, unknown in enumerate(invalid_status_solver.unknowns) + if unknown.variable == "m_flow" + ) + values[flow_index] = 0.0 + return self._least_squares_result(values, status=-1) + + with patch.object( + PressureFlowSolver, + "_seed_explicit_mass_flows", + return_value=None, + ), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status): + with self.assertRaises(AlgebraicSolveError): + invalid_status_solver.solve() + + def test_zero_residual_seed_does_not_bypass_positive_pressure_bound(self) -> None: + medium = IdealGasMedium() + tank = Tank("tank", medium, V=1.0) + plug = AmesimPnpl01("plug") + network = SimulationNetwork("invalid-negative-pressure") + network.add_component(tank) + network.add_component(plug) + network.connect("tank", "port_a", "plug", "port_1") + + tank.state = VolumeState(m=1.0, U=-1000.0) + tank.refresh_thermodynamic_ports() + self.assertLess(tank.port_a.p, 0.0) + + solver = PressureFlowSolver(network, max_evaluations=10) + with self.assertRaises(AlgebraicSolveError): + solver.solve() + self.assertIsNotNone(solver.last_diagnostics) + self.assertFalse(solver.last_diagnostics.success) + + +if __name__ == "__main__": + unittest.main()