完善AMESim组件界面与仿真求解稳定性
This commit is contained in:
1 parent
e7177ab03e
commit
410ef535e8
34 files changed
+3289
-200
No files matched your search
@@ -641,6 +641,11 @@ def run_system_xml_simulation(
|
|||||||
t_start=project.simulation.t_start,
|
t_start=project.simulation.t_start,
|
||||||
t_stop=project.simulation.t_stop,
|
t_stop=project.simulation.t_stop,
|
||||||
method=project.simulation.method,
|
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,
|
max_step=project.simulation.max_step,
|
||||||
),
|
),
|
||||||
sample_step=project.simulation.step,
|
sample_step=project.simulation.step,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class AmesimPnpl01(AlgebraicComponent):
|
|||||||
label="PNPL01 零气动流边界",
|
label="PNPL01 零气动流边界",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="boundary",
|
category_id="boundary",
|
||||||
symbol="generic",
|
symbol="amesim_pnpl01",
|
||||||
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
ports=(PortDisplaySpec("port_1", "left", order=10),),
|
||||||
order=10,
|
order=10,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
label="PNOR001 常系数气动孔口",
|
label="PNOR001 常系数气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnor001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -412,7 +412,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
label="PNVO001 固定开度气动孔口",
|
label="PNVO001 固定开度气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnvo001_fixed",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
PortDisplaySpec("port_2", "left", order=10),
|
||||||
PortDisplaySpec("port_3", "right", order=20),
|
PortDisplaySpec("port_3", "right", order=20),
|
||||||
@@ -633,7 +633,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
label="PNVO001 信号开度气动孔口",
|
label="PNVO001 信号开度气动孔口",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="orifice",
|
symbol="amesim_pnvo001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("res", "left", order=5),
|
PortDisplaySpec("res", "left", order=5),
|
||||||
PortDisplaySpec("port_2", "left", order=10),
|
PortDisplaySpec("port_2", "left", order=10),
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
label="PNL00R 气动管路阻力",
|
label="PNL00R 气动管路阻力",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="pipe",
|
symbol="amesim_pnl00r",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -440,7 +440,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
label="PNL0001 C-R 动态管路",
|
label="PNL0001 C-R 动态管路",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="pipe",
|
symbol="amesim_pnl0001",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -720,7 +720,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
|||||||
label="PNL0002 R-C-R 动态管路",
|
label="PNL0002 R-C-R 动态管路",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="pipe",
|
symbol="amesim_pnl0002",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -920,7 +920,7 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
label="PNL0003 C-R-C 动态管路",
|
label="PNL0003 C-R-C 动态管路",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="flow",
|
category_id="flow",
|
||||||
symbol="pipe",
|
symbol="amesim_pnl0003",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
|||||||
label="PN3NODE2 三端气动节点",
|
label="PN3NODE2 三端气动节点",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="junctions",
|
category_id="junctions",
|
||||||
symbol="tee",
|
symbol="amesim_pn3node2",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -128,7 +128,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
label="P4NODE2 四端气动节点",
|
label="P4NODE2 四端气动节点",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="junctions",
|
category_id="junctions",
|
||||||
symbol="generic",
|
symbol="amesim_p4node2",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class AmesimF000(AlgebraicComponent):
|
|||||||
label="F000 零力源",
|
label="F000 零力源",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="mechanical",
|
category_id="mechanical",
|
||||||
symbol="generic",
|
symbol="amesim_f000",
|
||||||
ports=(PortDisplaySpec("port_1", "right", order=10),),
|
ports=(PortDisplaySpec("port_1", "right", order=10),),
|
||||||
order=10,
|
order=10,
|
||||||
)
|
)
|
||||||
@@ -73,7 +73,7 @@ class AmesimForc(AlgebraicComponent):
|
|||||||
label="FORC 信号转力",
|
label="FORC 信号转力",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="mechanical",
|
category_id="mechanical",
|
||||||
symbol="signal",
|
symbol="amesim_forc",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("res", "left", order=10),
|
PortDisplaySpec("res", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -167,7 +167,7 @@ class AmesimMecmas21(DynamicComponent):
|
|||||||
label="MECMAS21 一维质量",
|
label="MECMAS21 一维质量",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="mechanical",
|
category_id="mechanical",
|
||||||
symbol="generic",
|
symbol="amesim_mecmas21",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -323,7 +323,7 @@ class AmesimLstp00a(AlgebraicComponent):
|
|||||||
label="LSTP00A 弹性接触",
|
label="LSTP00A 弹性接触",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="mechanical",
|
category_id="mechanical",
|
||||||
symbol="generic",
|
symbol="amesim_lstp00a",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -425,7 +425,7 @@ class AmesimLmechn1(AlgebraicComponent):
|
|||||||
label="LMECHN1 线性机械节点",
|
label="LMECHN1 线性机械节点",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="mechanical",
|
category_id="mechanical",
|
||||||
symbol="junction",
|
symbol="amesim_lmechn1",
|
||||||
ports=tuple(
|
ports=tuple(
|
||||||
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
|
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
|
||||||
+ [PortDisplaySpec("port_9", "right", order=90)]
|
+ [PortDisplaySpec("port_9", "right", order=90)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from math import floor
|
||||||
|
|
||||||
from app.simulation.core.base import AlgebraicComponent
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
@@ -27,7 +28,7 @@ class AmesimStep0(AlgebraicComponent):
|
|||||||
label="STEP0 阶跃信号",
|
label="STEP0 阶跃信号",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="signals",
|
category_id="signals",
|
||||||
symbol="signal",
|
symbol="amesim_step0",
|
||||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||||
order=10,
|
order=10,
|
||||||
)
|
)
|
||||||
@@ -71,6 +72,15 @@ class AmesimStep0(AlgebraicComponent):
|
|||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||||
return {"out": self.output_at(time)}
|
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]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {"y": self.out.signal}
|
return {"y": self.out.signal}
|
||||||
|
|
||||||
@@ -117,7 +127,7 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
label="UD00 分段线性信号",
|
label="UD00 分段线性信号",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="signals",
|
category_id="signals",
|
||||||
symbol="signal",
|
symbol="amesim_ud00",
|
||||||
ports=(PortDisplaySpec("out", "right", order=10),),
|
ports=(PortDisplaySpec("out", "right", order=10),),
|
||||||
order=20,
|
order=20,
|
||||||
)
|
)
|
||||||
@@ -200,5 +210,58 @@ class AmesimUd00(AlgebraicComponent):
|
|||||||
def signal_output_values(self, time: float) -> dict[str, float]:
|
def signal_output_values(self, time: float) -> dict[str, float]:
|
||||||
return {"out": self.output_at(time)}
|
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]:
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
return {"y": self.out.signal}
|
return {"y": self.out.signal}
|
||||||
@@ -100,7 +100,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
|
|||||||
label="PNCH023 固定容积气室",
|
label="PNCH023 固定容积气室",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="storage",
|
category_id="storage",
|
||||||
symbol="tank",
|
symbol="amesim_pnch023",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
@@ -340,7 +340,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
|
|||||||
label="PNCH012 变容气室",
|
label="PNCH012 变容气室",
|
||||||
library_id="amesim",
|
library_id="amesim",
|
||||||
category_id="storage",
|
category_id="storage",
|
||||||
symbol="tank",
|
symbol="amesim_pnch012",
|
||||||
ports=(
|
ports=(
|
||||||
PortDisplaySpec("port_1", "left", order=10),
|
PortDisplaySpec("port_1", "left", order=10),
|
||||||
PortDisplaySpec("port_2", "right", order=20),
|
PortDisplaySpec("port_2", "right", order=20),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import sqrt
|
from math import isfinite, sqrt
|
||||||
|
|
||||||
from app.simulation.core.ports import PortState, VariableRole
|
from app.simulation.core.ports import PortState, VariableRole
|
||||||
from app.simulation.systems.network import SimulationNetwork
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
@@ -68,6 +68,7 @@ class PressureFlowSolver:
|
|||||||
self.residual_tolerance = residual_tolerance
|
self.residual_tolerance = residual_tolerance
|
||||||
self.max_evaluations = max_evaluations
|
self.max_evaluations = max_evaluations
|
||||||
self.unknowns = self._build_unknowns()
|
self.unknowns = self._build_unknowns()
|
||||||
|
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
||||||
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
|
||||||
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||||
@@ -91,48 +92,220 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
return tuple(unknowns)
|
return tuple(unknowns)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
def _seed_equal_pressures(self) -> None:
|
def _seed_equal_pressures(self) -> None:
|
||||||
for _ in range(max(2, len(self.network.connections))):
|
"""Lift current state pressures across their complete equality groups.
|
||||||
changed = False
|
|
||||||
|
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:
|
for connection in self.network.connections:
|
||||||
if connection.kind != "physical":
|
if connection.kind != "physical":
|
||||||
continue
|
continue
|
||||||
first = self.network.components[
|
first = connection.endpoint_a.key
|
||||||
connection.endpoint_a.component
|
second = connection.endpoint_b.key
|
||||||
].get_port(connection.endpoint_a.port)
|
if first in pressure_unknowns and second in pressure_unknowns:
|
||||||
second = self.network.components[
|
union(first, second)
|
||||||
connection.endpoint_b.component
|
|
||||||
].get_port(connection.endpoint_b.port)
|
|
||||||
if first.p > 0.0 and second.p <= 0.0:
|
|
||||||
second.p = first.p
|
|
||||||
changed = True
|
|
||||||
elif second.p > 0.0 and first.p <= 0.0:
|
|
||||||
first.p = second.p
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
for component in self.network.components.values():
|
component_equations = {
|
||||||
equal_pressure_equations = [
|
component.name: component.pressure_flow_equation_residuals()
|
||||||
equation
|
for component in self.network.components.values()
|
||||||
for equation in component.pressure_flow_equation_residuals()
|
}
|
||||||
if equation.relation == "equal" and equation.role == "effort"
|
for equations in component_equations.values():
|
||||||
]
|
for equation in equations:
|
||||||
for equation in equal_pressure_equations:
|
if equation.relation != "equal" or equation.role != "effort":
|
||||||
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
|
continue
|
||||||
first, second = states
|
endpoints = [
|
||||||
if first.p > 0.0 and second.p <= 0.0:
|
endpoint
|
||||||
second.p = first.p
|
for variable in equation.variables
|
||||||
changed = True
|
if (
|
||||||
elif second.p > 0.0 and first.p <= 0.0:
|
(endpoint := self._port_key(variable, "p"))
|
||||||
first.p = second.p
|
in pressure_unknowns
|
||||||
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]:
|
def _scales(self) -> dict[str, float]:
|
||||||
pressure_scale = max(
|
pressure_scale = max(
|
||||||
@@ -184,6 +357,7 @@ class PressureFlowSolver:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._seed_equal_pressures()
|
self._seed_equal_pressures()
|
||||||
|
self._seed_explicit_mass_flows()
|
||||||
scales = self._scales()
|
scales = self._scales()
|
||||||
pressure_scale = scales["p"]
|
pressure_scale = scales["p"]
|
||||||
flow_scale = scales["m_flow"]
|
flow_scale = scales["m_flow"]
|
||||||
@@ -216,6 +390,40 @@ class PressureFlowSolver:
|
|||||||
return pressure_scale
|
return pressure_scale
|
||||||
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
|
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(
|
x0 = np.asarray(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
@@ -269,14 +477,20 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
for equation in equations
|
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(
|
diagnostics = AlgebraicSolveDiagnostics(
|
||||||
success=success,
|
success=success,
|
||||||
message=str(result.message),
|
message=str(result.message),
|
||||||
evaluations=int(result.nfev),
|
evaluations=int(result.nfev),
|
||||||
pressure_scale=pressure_scale,
|
pressure_scale=pressure_scale,
|
||||||
flow_scale=flow_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),
|
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||||
)
|
)
|
||||||
self.last_diagnostics = diagnostics
|
self.last_diagnostics = diagnostics
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
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)
|
@dataclass(frozen=True)
|
||||||
class SignalSolveDiagnostics:
|
class SignalSolveDiagnostics:
|
||||||
propagated: int
|
propagated: int
|
||||||
@@ -51,6 +67,37 @@ class SignalResolver:
|
|||||||
self.last_diagnostics = diagnostics
|
self.last_diagnostics = diagnostics
|
||||||
return 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]:
|
def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]:
|
||||||
first, second = endpoints
|
first, second = endpoints
|
||||||
first_port = self.network.components[first.component].get_port(first.port)
|
first_port = self.network.components[first.component].get_port(first.port)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Literal
|
from typing import Callable, Literal, Sequence
|
||||||
|
|
||||||
|
|
||||||
CancellationCheck = Callable[[], bool]
|
CancellationCheck = Callable[[], bool]
|
||||||
@@ -51,6 +52,33 @@ def _append_solution_sample(
|
|||||||
states[index].append(float(value))
|
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(
|
def _runge_kutta_4(
|
||||||
rhs: Callable[[float, list[float]], list[float]],
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
initial_state: 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(
|
def _integrate_scipy_stepwise(
|
||||||
rhs: Callable[[float, list[float]], list[float]],
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
initial_state: list[float],
|
initial_state: list[float],
|
||||||
@@ -121,6 +280,7 @@ def _integrate_scipy_stepwise(
|
|||||||
t_eval: list[float] | None,
|
t_eval: list[float] | None,
|
||||||
cancel_check: CancellationCheck,
|
cancel_check: CancellationCheck,
|
||||||
accepted_step_callback: AcceptedStepCallback | None,
|
accepted_step_callback: AcceptedStepCallback | None,
|
||||||
|
breakpoints: Sequence[float] = (),
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
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]
|
states = [[float(value)] for value in initial_state]
|
||||||
last_accepted_time = float(config.t_start)
|
last_accepted_time = float(config.t_start)
|
||||||
last_accepted_state = [float(value) for value in initial_state]
|
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
|
sample_index = 0
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
@@ -154,63 +314,85 @@ def _integrate_scipy_stepwise(
|
|||||||
raise _IntegrationCancelled
|
raise _IntegrationCancelled
|
||||||
return rhs(float(time), [float(value) for value in state])
|
return rhs(float(time), [float(value) for value in state])
|
||||||
|
|
||||||
if cancel_check():
|
status: IntegrationStatus = "completed"
|
||||||
return ODESolution(
|
message = "The solver successfully reached the end of the integration interval."
|
||||||
t=times,
|
error: Exception | None = None
|
||||||
y=states,
|
last_reported_step: float | None = None
|
||||||
success=False,
|
|
||||||
message="Simulation was stopped before integration started.",
|
|
||||||
status="cancelled",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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 integration started."
|
||||||
|
if segment_index == 0
|
||||||
|
else "Simulation was stopped before reaching the requested end 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 = {
|
solver_options = {
|
||||||
"rtol": config.rtol,
|
"rtol": config.rtol,
|
||||||
"atol": config.atol,
|
"atol": config.atol,
|
||||||
"max_step": config.max_step,
|
"max_step": config.max_step,
|
||||||
}
|
}
|
||||||
if config.first_step is not None:
|
if config.first_step is not None:
|
||||||
solver_options["first_step"] = config.first_step
|
solver_options["first_step"] = min(
|
||||||
|
config.first_step,
|
||||||
|
integration_end - last_accepted_time,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
solver = solver_type(
|
solver = solver_type(
|
||||||
cancellable_rhs,
|
cancellable_rhs,
|
||||||
config.t_start,
|
last_accepted_time,
|
||||||
np.asarray(initial_state, dtype=float),
|
np.asarray(last_accepted_state, dtype=float),
|
||||||
config.t_stop,
|
integration_end,
|
||||||
**solver_options,
|
**solver_options,
|
||||||
)
|
)
|
||||||
except _IntegrationCancelled:
|
except _IntegrationCancelled:
|
||||||
return ODESolution(
|
status = "cancelled"
|
||||||
t=times,
|
message = (
|
||||||
y=states,
|
"Simulation was stopped before integration started."
|
||||||
success=False,
|
if segment_index == 0
|
||||||
message="Simulation was stopped before integration started.",
|
else "Simulation was stopped before reaching the requested end time."
|
||||||
status="cancelled",
|
|
||||||
)
|
)
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ODESolution(
|
status = "failed"
|
||||||
t=times,
|
message = str(exc)
|
||||||
y=states,
|
error = exc
|
||||||
success=False,
|
break
|
||||||
message=str(exc),
|
|
||||||
status="failed",
|
|
||||||
error=exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
status: IntegrationStatus = "completed"
|
|
||||||
message = "The solver successfully reached the end of the integration interval."
|
|
||||||
error: Exception | None = None
|
|
||||||
|
|
||||||
while solver.status == "running":
|
while solver.status == "running":
|
||||||
if cancel_check():
|
if cancel_check():
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = "Simulation was stopped before reaching the requested end time."
|
message = (
|
||||||
|
"Simulation was stopped before reaching the requested end time."
|
||||||
|
)
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
step_message = solver.step()
|
step_message = solver.step()
|
||||||
except _IntegrationCancelled:
|
except _IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
message = "Simulation was stopped before reaching the requested end time."
|
message = (
|
||||||
|
"Simulation was stopped before reaching the requested end time."
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
@@ -225,25 +407,67 @@ def _integrate_scipy_stepwise(
|
|||||||
|
|
||||||
last_accepted_time = float(solver.t)
|
last_accepted_time = float(solver.t)
|
||||||
last_accepted_state = [float(value) for value in solver.y]
|
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:
|
if sample_times:
|
||||||
dense_output = solver.dense_output()
|
dense_output = solver.dense_output()
|
||||||
while (
|
while (
|
||||||
sample_index < len(sample_times)
|
sample_index < len(sample_times)
|
||||||
and sample_times[sample_index] <= last_accepted_time + 1e-12
|
and sample_times[sample_index] <= last_accepted_time
|
||||||
):
|
):
|
||||||
sample_time = float(sample_times[sample_index])
|
sample_time = float(sample_times[sample_index])
|
||||||
sample_state = [float(value) for value in dense_output(sample_time)]
|
sample_state = [
|
||||||
_append_solution_sample(times, states, sample_time, sample_state)
|
float(value) for value in dense_output(sample_time)
|
||||||
|
]
|
||||||
|
_append_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
sample_time,
|
||||||
|
sample_state,
|
||||||
|
)
|
||||||
sample_index += 1
|
sample_index += 1
|
||||||
else:
|
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(
|
_append_solution_sample(
|
||||||
times,
|
times,
|
||||||
states,
|
states,
|
||||||
last_accepted_time,
|
last_accepted_time,
|
||||||
last_accepted_state,
|
last_accepted_state,
|
||||||
)
|
)
|
||||||
if accepted_step_callback is not None:
|
report_step(last_accepted_time)
|
||||||
accepted_step_callback(last_accepted_time)
|
|
||||||
|
|
||||||
if status != "completed":
|
if status != "completed":
|
||||||
_append_solution_sample(
|
_append_solution_sample(
|
||||||
@@ -270,8 +494,14 @@ def integrate_ode(
|
|||||||
t_eval: list[float] | None = None,
|
t_eval: list[float] | None = None,
|
||||||
cancel_check: CancellationCheck | None = None,
|
cancel_check: CancellationCheck | None = None,
|
||||||
accepted_step_callback: AcceptedStepCallback | 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:
|
if abs(config.t_stop - config.t_start) <= 1e-15:
|
||||||
return ODESolution(
|
return ODESolution(
|
||||||
@@ -281,9 +511,21 @@ def integrate_ode(
|
|||||||
message="Skipped integration because t_start equals t_stop.",
|
message="Skipped integration because t_start equals t_stop.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
normalized_breakpoints = _normalize_breakpoints(config, breakpoints)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from scipy.integrate import solve_ivp
|
from scipy.integrate import solve_ivp
|
||||||
except ImportError:
|
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(
|
return _runge_kutta_4(
|
||||||
rhs,
|
rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
@@ -293,14 +535,15 @@ def integrate_ode(
|
|||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cancel_check is not None:
|
if cancel_check is not None or normalized_breakpoints:
|
||||||
return _integrate_scipy_stepwise(
|
return _integrate_scipy_stepwise(
|
||||||
rhs,
|
rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
cancel_check,
|
cancel_check or (lambda: False),
|
||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
|
normalized_breakpoints,
|
||||||
)
|
)
|
||||||
|
|
||||||
solve_options = {
|
solve_options = {
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ class GenericFluidSystem:
|
|||||||
|
|
||||||
report_progress(0.0, "initializing", force=True)
|
report_progress(0.0, "initializing", force=True)
|
||||||
t_eval = simulation_sample_times(config, sample_step)
|
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()
|
initial_state = self.consistent_initial_state_vector()
|
||||||
report_progress(0.0, "integrating", force=True)
|
report_progress(0.0, "integrating", force=True)
|
||||||
duration = config.t_stop - config.t_start
|
duration = config.t_stop - config.t_start
|
||||||
@@ -356,6 +360,7 @@ class GenericFluidSystem:
|
|||||||
accepted_step_callback=(
|
accepted_step_callback=(
|
||||||
report_solver_time if cancel_check is not None else None
|
report_solver_time if cancel_check is not None else None
|
||||||
),
|
),
|
||||||
|
breakpoints=signal_event_times,
|
||||||
)
|
)
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
run_status: SimulationRunStatus = solution.status
|
run_status: SimulationRunStatus = solution.status
|
||||||
@@ -428,6 +433,7 @@ class GenericFluidSystem:
|
|||||||
},
|
},
|
||||||
"signal": {
|
"signal": {
|
||||||
"propagations": self.signal_propagation_count,
|
"propagations": self.signal_propagation_count,
|
||||||
|
"eventTimes": list(signal_event_times),
|
||||||
"last": (
|
"last": (
|
||||||
self.signal_resolver.last_diagnostics.as_dict()
|
self.signal_resolver.last_diagnostics.as_dict()
|
||||||
if self.signal_resolver.last_diagnostics is not None
|
if self.signal_resolver.last_diagnostics is not None
|
||||||
|
|||||||
+315
-46
@@ -89,6 +89,10 @@ import {
|
|||||||
type ParameterColumnLayout,
|
type ParameterColumnLayout,
|
||||||
type ParameterTableRow,
|
type ParameterTableRow,
|
||||||
} from "./ParameterTable";
|
} from "./ParameterTable";
|
||||||
|
import {
|
||||||
|
evaluateParameterExpression,
|
||||||
|
isParameterExpressionValue,
|
||||||
|
} from "./parameterExpression";
|
||||||
import {
|
import {
|
||||||
WorkspaceViewTabs,
|
WorkspaceViewTabs,
|
||||||
type WorkspaceView,
|
type WorkspaceView,
|
||||||
@@ -219,7 +223,17 @@ type SimulationNodeData = {
|
|||||||
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
|
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
|
||||||
type SimulationEdge = Edge;
|
type SimulationEdge = Edge;
|
||||||
|
|
||||||
|
type SimulationNumericKey = "t_start" | "t_stop" | "step" | "max_step";
|
||||||
|
|
||||||
type SimulationConfig = {
|
type SimulationConfig = {
|
||||||
|
t_start: ParameterValue;
|
||||||
|
t_stop: ParameterValue;
|
||||||
|
step: ParameterValue;
|
||||||
|
max_step: ParameterValue;
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedSimulationConfig = {
|
||||||
t_start: number;
|
t_start: number;
|
||||||
t_stop: number;
|
t_stop: number;
|
||||||
step: number;
|
step: number;
|
||||||
@@ -255,6 +269,10 @@ type ReactFlowProjectPayload = {
|
|||||||
simulation: SimulationConfig;
|
simulation: SimulationConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation"> & {
|
||||||
|
simulation: ResolvedSimulationConfig;
|
||||||
|
};
|
||||||
|
|
||||||
type SimulationResult = {
|
type SimulationResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status: "completed" | "stopped" | "stalled" | "failed";
|
status: "completed" | "stopped" | "stalled" | "failed";
|
||||||
@@ -534,7 +552,7 @@ const unitOptions: Record<UnitQuantity, UnitOption[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSimulationConfig: SimulationConfig = {
|
const defaultSimulationConfig: ResolvedSimulationConfig = {
|
||||||
t_start: 0,
|
t_start: 0,
|
||||||
t_stop: 2,
|
t_stop: 2,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
@@ -625,6 +643,12 @@ type SimulationStreamEvent =
|
|||||||
totalTime?: number;
|
totalTime?: number;
|
||||||
};
|
};
|
||||||
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
|
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
|
||||||
|
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
|
||||||
|
t_start: "起始时间",
|
||||||
|
t_stop: "结束时间",
|
||||||
|
step: "采样步长",
|
||||||
|
max_step: "最大积分步长",
|
||||||
|
};
|
||||||
|
|
||||||
function physicalPort(
|
function physicalPort(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -1527,7 +1551,7 @@ type NormalizedSimulationProgress = {
|
|||||||
|
|
||||||
function normalizeSimulationProgressEvent(
|
function normalizeSimulationProgressEvent(
|
||||||
event: Extract<SimulationStreamEvent, { event: "progress" }>,
|
event: Extract<SimulationStreamEvent, { event: "progress" }>,
|
||||||
config: SimulationConfig,
|
config: ResolvedSimulationConfig,
|
||||||
): NormalizedSimulationProgress {
|
): NormalizedSimulationProgress {
|
||||||
const totalTime = Number.isFinite(event.totalTime)
|
const totalTime = Number.isFinite(event.totalTime)
|
||||||
? Number(event.totalTime)
|
? Number(event.totalTime)
|
||||||
@@ -2184,7 +2208,7 @@ function DeferredNumberInput({
|
|||||||
<input
|
<input
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
inputMode="decimal"
|
inputMode="text"
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
editingRef.current = false;
|
editingRef.current = false;
|
||||||
if (cancelCommitRef.current) {
|
if (cancelCommitRef.current) {
|
||||||
@@ -3013,10 +3037,11 @@ function FlowWorkbench() {
|
|||||||
const selectedUnit =
|
const selectedUnit =
|
||||||
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
|
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
|
||||||
const unit = findUnitOption(definition, selectedUnit);
|
const unit = findUnitOption(definition, selectedUnit);
|
||||||
const numericValue = Number(displayValue);
|
const normalizedDisplayValue = displayValue.trim();
|
||||||
|
const numericValue = Number(normalizedDisplayValue);
|
||||||
const baseValue =
|
const baseValue =
|
||||||
displayValue.trim() === "" || !Number.isFinite(numericValue)
|
normalizedDisplayValue === "" || !Number.isFinite(numericValue)
|
||||||
? displayValue
|
? normalizedDisplayValue
|
||||||
: unit.toBase(numericValue);
|
: unit.toBase(numericValue);
|
||||||
|
|
||||||
updateSelectedNode((node) => ({
|
updateSelectedNode((node) => ({
|
||||||
@@ -3216,15 +3241,22 @@ function FlowWorkbench() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
|
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
const numericValue = Number(normalizedValue);
|
||||||
setSimulationConfig((current) => ({
|
setSimulationConfig((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[key]: key === "method" ? value : Number(value),
|
[key]:
|
||||||
|
key === "method"
|
||||||
|
? value
|
||||||
|
: normalizedValue !== "" && Number.isFinite(numericValue)
|
||||||
|
? numericValue
|
||||||
|
: normalizedValue,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateXml = () => {
|
const generateXml = () => {
|
||||||
try {
|
try {
|
||||||
const xml = buildSystemXml(buildCurrentProject());
|
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
|
||||||
changeConsoleMode("normal");
|
changeConsoleMode("normal");
|
||||||
appendConsoleEntry(
|
appendConsoleEntry(
|
||||||
"success",
|
"success",
|
||||||
@@ -3239,7 +3271,7 @@ function FlowWorkbench() {
|
|||||||
|
|
||||||
const downloadXml = () => {
|
const downloadXml = () => {
|
||||||
try {
|
try {
|
||||||
const xml = buildSystemXml(buildCurrentProject());
|
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
|
||||||
downloadText(`${safeFilename(projectName)}.xml`, xml);
|
downloadText(`${safeFilename(projectName)}.xml`, xml);
|
||||||
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
|
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3402,7 +3434,7 @@ function FlowWorkbench() {
|
|||||||
|
|
||||||
const publishSimulationResult = (
|
const publishSimulationResult = (
|
||||||
result: SimulationResult,
|
result: SimulationResult,
|
||||||
project: ReactFlowProjectPayload,
|
project: ExecutableProjectPayload,
|
||||||
) => {
|
) => {
|
||||||
const resultStatus = result.status ?? (result.success ? "completed" : "failed");
|
const resultStatus = result.status ?? (result.success ? "completed" : "failed");
|
||||||
const sampleCount = Math.min(
|
const sampleCount = Math.min(
|
||||||
@@ -3510,34 +3542,48 @@ function FlowWorkbench() {
|
|||||||
const runSimulation = async () => {
|
const runSimulation = async () => {
|
||||||
const issues = checkModel();
|
const issues = checkModel();
|
||||||
const modelErrors = issues.filter((issue) => issue.severity === "error");
|
const modelErrors = issues.filter((issue) => issue.severity === "error");
|
||||||
|
const simulationResolution = resolveSimulationConfig(
|
||||||
|
simulationConfigRef.current,
|
||||||
|
);
|
||||||
|
const progressConfig = simulationResolution.ok
|
||||||
|
? simulationResolution.value
|
||||||
|
: defaultSimulationConfig;
|
||||||
if (modelErrors.length > 0) {
|
if (modelErrors.length > 0) {
|
||||||
changeConsoleMode("normal");
|
changeConsoleMode("normal");
|
||||||
setSimulationProgress({
|
setSimulationProgress({
|
||||||
state: "error",
|
state: "error",
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: "模型检查未通过",
|
message: "模型检查未通过",
|
||||||
startTime: simulationConfigRef.current.t_start,
|
startTime: progressConfig.t_start,
|
||||||
simulatedTime: simulationConfigRef.current.t_start,
|
simulatedTime: progressConfig.t_start,
|
||||||
totalTime: simulationConfigRef.current.t_stop,
|
totalTime: progressConfig.t_stop,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!simulationResolution.ok) {
|
||||||
|
changeConsoleMode("normal");
|
||||||
|
appendConsoleEntry("error", simulationResolution.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project: ExecutableProjectPayload = {
|
||||||
|
...buildCurrentProject(),
|
||||||
|
simulation: simulationResolution.value,
|
||||||
|
};
|
||||||
changeConsoleMode("normal");
|
changeConsoleMode("normal");
|
||||||
setSimulationProgress({
|
setSimulationProgress({
|
||||||
state: "running",
|
state: "running",
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: "正在提交仿真任务",
|
message: "正在提交仿真任务",
|
||||||
startTime: simulationConfigRef.current.t_start,
|
startTime: project.simulation.t_start,
|
||||||
simulatedTime: simulationConfigRef.current.t_start,
|
simulatedTime: project.simulation.t_start,
|
||||||
totalTime: simulationConfigRef.current.t_stop,
|
totalTime: project.simulation.t_stop,
|
||||||
});
|
});
|
||||||
appendConsoleEntry(
|
appendConsoleEntry(
|
||||||
"info",
|
"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 () => {
|
await runBusyAction("正在编译模型并运行仿真...", async () => {
|
||||||
const project = buildCurrentProject();
|
const xml = buildSystemXml(project, componentDefinitions);
|
||||||
const xml = buildSystemXml(project);
|
|
||||||
const simulationId = createSimulationId();
|
const simulationId = createSimulationId();
|
||||||
activeSimulationIdRef.current = simulationId;
|
activeSimulationIdRef.current = simulationId;
|
||||||
setSimulationStopPending(false);
|
setSimulationStopPending(false);
|
||||||
@@ -3988,7 +4034,11 @@ function FlowWorkbench() {
|
|||||||
const error =
|
const error =
|
||||||
gasReference && !gasReferenceAvailable
|
gasReference && !gasReferenceAvailable
|
||||||
? "引用的介质定义不存在"
|
? "引用的介质定义不存在"
|
||||||
: parameterValidationMessage(definition, baseValue);
|
: parameterValidationMessage(
|
||||||
|
definition,
|
||||||
|
baseValue,
|
||||||
|
selectedUnit,
|
||||||
|
);
|
||||||
let valueControl: ReactNode;
|
let valueControl: ReactNode;
|
||||||
if (mediumDefinitionIndex) {
|
if (mediumDefinitionIndex) {
|
||||||
valueControl = (
|
valueControl = (
|
||||||
@@ -4103,12 +4153,20 @@ function FlowWorkbench() {
|
|||||||
definition,
|
definition,
|
||||||
selectedUnit,
|
selectedUnit,
|
||||||
),
|
),
|
||||||
|
calculatedValue: parameterCalculatedValueMetadata(
|
||||||
|
baseValue,
|
||||||
|
definition,
|
||||||
|
selectedUnit,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const simulationSettingErrors = simulationConfigValidationMessages(
|
||||||
|
simulationConfig,
|
||||||
|
);
|
||||||
const simulationSettingRows: ParameterTableRow[] = [
|
const simulationSettingRows: ParameterTableRow[] = [
|
||||||
{
|
{
|
||||||
id: "simulation-t-start",
|
id: "simulation-t-start",
|
||||||
@@ -4116,6 +4174,7 @@ function FlowWorkbench() {
|
|||||||
value: (
|
value: (
|
||||||
<DeferredNumberInput
|
<DeferredNumberInput
|
||||||
ariaLabel="起始时间"
|
ariaLabel="起始时间"
|
||||||
|
invalid={Boolean(simulationSettingErrors.t_start)}
|
||||||
value={simulationConfig.t_start}
|
value={simulationConfig.t_start}
|
||||||
onCommit={(value) => updateSimulationConfig("t_start", value)}
|
onCommit={(value) => updateSimulationConfig("t_start", value)}
|
||||||
onEditEnd={finishHistoryEdit}
|
onEditEnd={finishHistoryEdit}
|
||||||
@@ -4127,6 +4186,11 @@ function FlowWorkbench() {
|
|||||||
defaultValue: `${defaultSimulationConfig.t_start} s`,
|
defaultValue: `${defaultSimulationConfig.t_start} s`,
|
||||||
range: "有限数值",
|
range: "有限数值",
|
||||||
currentUnit: "s",
|
currentUnit: "s",
|
||||||
|
error: simulationSettingErrors.t_start,
|
||||||
|
calculatedValue: simulationCalculatedValueMetadata(
|
||||||
|
simulationConfig.t_start,
|
||||||
|
"s",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "simulation-t-stop",
|
id: "simulation-t-stop",
|
||||||
@@ -4134,6 +4198,7 @@ function FlowWorkbench() {
|
|||||||
value: (
|
value: (
|
||||||
<DeferredNumberInput
|
<DeferredNumberInput
|
||||||
ariaLabel="结束时间"
|
ariaLabel="结束时间"
|
||||||
|
invalid={Boolean(simulationSettingErrors.t_stop)}
|
||||||
value={simulationConfig.t_stop}
|
value={simulationConfig.t_stop}
|
||||||
onCommit={(value) => updateSimulationConfig("t_stop", value)}
|
onCommit={(value) => updateSimulationConfig("t_stop", value)}
|
||||||
onEditEnd={finishHistoryEdit}
|
onEditEnd={finishHistoryEdit}
|
||||||
@@ -4145,6 +4210,11 @@ function FlowWorkbench() {
|
|||||||
defaultValue: `${defaultSimulationConfig.t_stop} s`,
|
defaultValue: `${defaultSimulationConfig.t_stop} s`,
|
||||||
range: "> 起始时间",
|
range: "> 起始时间",
|
||||||
currentUnit: "s",
|
currentUnit: "s",
|
||||||
|
error: simulationSettingErrors.t_stop,
|
||||||
|
calculatedValue: simulationCalculatedValueMetadata(
|
||||||
|
simulationConfig.t_stop,
|
||||||
|
"s",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "simulation-step",
|
id: "simulation-step",
|
||||||
@@ -4152,6 +4222,7 @@ function FlowWorkbench() {
|
|||||||
value: (
|
value: (
|
||||||
<DeferredNumberInput
|
<DeferredNumberInput
|
||||||
ariaLabel="采样步长"
|
ariaLabel="采样步长"
|
||||||
|
invalid={Boolean(simulationSettingErrors.step)}
|
||||||
value={simulationConfig.step}
|
value={simulationConfig.step}
|
||||||
onCommit={(value) => updateSimulationConfig("step", value)}
|
onCommit={(value) => updateSimulationConfig("step", value)}
|
||||||
onEditEnd={finishHistoryEdit}
|
onEditEnd={finishHistoryEdit}
|
||||||
@@ -4163,6 +4234,11 @@ function FlowWorkbench() {
|
|||||||
defaultValue: `${defaultSimulationConfig.step} s`,
|
defaultValue: `${defaultSimulationConfig.step} s`,
|
||||||
range: "> 0",
|
range: "> 0",
|
||||||
currentUnit: "s",
|
currentUnit: "s",
|
||||||
|
error: simulationSettingErrors.step,
|
||||||
|
calculatedValue: simulationCalculatedValueMetadata(
|
||||||
|
simulationConfig.step,
|
||||||
|
"s",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "simulation-max-step",
|
id: "simulation-max-step",
|
||||||
@@ -4170,6 +4246,7 @@ function FlowWorkbench() {
|
|||||||
value: (
|
value: (
|
||||||
<DeferredNumberInput
|
<DeferredNumberInput
|
||||||
ariaLabel="最大积分步长"
|
ariaLabel="最大积分步长"
|
||||||
|
invalid={Boolean(simulationSettingErrors.max_step)}
|
||||||
value={simulationConfig.max_step}
|
value={simulationConfig.max_step}
|
||||||
onCommit={(value) => updateSimulationConfig("max_step", value)}
|
onCommit={(value) => updateSimulationConfig("max_step", value)}
|
||||||
onEditEnd={finishHistoryEdit}
|
onEditEnd={finishHistoryEdit}
|
||||||
@@ -4181,6 +4258,11 @@ function FlowWorkbench() {
|
|||||||
defaultValue: `${defaultSimulationConfig.max_step} s`,
|
defaultValue: `${defaultSimulationConfig.max_step} s`,
|
||||||
range: "> 0",
|
range: "> 0",
|
||||||
currentUnit: "s",
|
currentUnit: "s",
|
||||||
|
error: simulationSettingErrors.max_step,
|
||||||
|
calculatedValue: simulationCalculatedValueMetadata(
|
||||||
|
simulationConfig.max_step,
|
||||||
|
"s",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "simulation-method",
|
id: "simulation-method",
|
||||||
@@ -4838,7 +4920,9 @@ function normalizeLoadedNode(
|
|||||||
label: node.data.label || definition?.label || node.id,
|
label: node.data.label || definition?.label || node.id,
|
||||||
componentType: node.data.componentType || definition?.type || "component",
|
componentType: node.data.componentType || definition?.type || "component",
|
||||||
modelType: node.data.modelType || definition?.modelType || node.data.componentType,
|
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),
|
ports: normalizeLoadedPorts(node.data.ports, definition),
|
||||||
parameters,
|
parameters,
|
||||||
parameterUnits,
|
parameterUnits,
|
||||||
@@ -5122,17 +5206,95 @@ function parameterCurrentUnitMetadata(
|
|||||||
: undefined;
|
: 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(
|
function parameterValidationMessage(
|
||||||
definition: ParameterDefinition,
|
definition: ParameterDefinition,
|
||||||
value: ParameterValue,
|
value: ParameterValue,
|
||||||
|
selectedUnit: string,
|
||||||
) {
|
) {
|
||||||
if (typeof value === "string" && value.trim() === "") {
|
const resolved = resolveParameterValue(value, definition, selectedUnit);
|
||||||
return "参数不能为空";
|
if (!resolved.ok) {
|
||||||
}
|
return resolved.message;
|
||||||
const numericValue = Number(value);
|
|
||||||
if (!Number.isFinite(numericValue)) {
|
|
||||||
return "请输入有效数字";
|
|
||||||
}
|
}
|
||||||
|
const numericValue = resolved.value;
|
||||||
if (
|
if (
|
||||||
definition.options &&
|
definition.options &&
|
||||||
!definition.options.some((option) => option.value === numericValue)
|
!definition.options.some((option) => option.value === numericValue)
|
||||||
@@ -5153,6 +5315,85 @@ function parameterValidationMessage(
|
|||||||
return "";
|
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<Record<SimulationNumericKey, string>> {
|
||||||
|
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<SimulationNumericKey, NumericInputResolution>;
|
||||||
|
const messages: Partial<Record<SimulationNumericKey, string>> = {};
|
||||||
|
|
||||||
|
(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[]) {
|
function findDuplicateNames(nodes: SimulationNode[]) {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@@ -5236,9 +5477,16 @@ function validateModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.entries(definition.parameters).forEach(([key, parameter]) => {
|
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) {
|
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,
|
nodeId: node.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5416,17 +5664,13 @@ function validateModel(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) {
|
const simulationErrors = simulationConfigValidationMessages(simulation);
|
||||||
addIssue("error", "仿真起止时间必须是有效数字");
|
(Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => {
|
||||||
} else if (simulation.t_stop <= simulation.t_start) {
|
const message = simulationErrors[key];
|
||||||
addIssue("error", "仿真结束时间必须大于起始时间");
|
if (message) {
|
||||||
}
|
addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`);
|
||||||
if (!Number.isFinite(simulation.step) || simulation.step <= 0) {
|
|
||||||
addIssue("error", "采样步长必须大于 0");
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) {
|
|
||||||
addIssue("error", "最大积分步长必须大于 0");
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) {
|
if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) {
|
||||||
addIssue("error", "求解器不受支持");
|
addIssue("error", "求解器不受支持");
|
||||||
}
|
}
|
||||||
@@ -5548,7 +5792,15 @@ function formatTime(date: Date) {
|
|||||||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
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 xmlDocument = document.implementation.createDocument("", "System", null);
|
||||||
const system = xmlDocument.documentElement;
|
const system = xmlDocument.documentElement;
|
||||||
system.setAttribute("name", project.name);
|
system.setAttribute("name", project.name);
|
||||||
@@ -5557,16 +5809,22 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
|
|||||||
system.setAttribute("unitSystem", "SI");
|
system.setAttribute("unitSystem", "SI");
|
||||||
|
|
||||||
const simulation = xmlDocument.createElement("Simulation");
|
const simulation = xmlDocument.createElement("Simulation");
|
||||||
simulation.setAttribute("tStart", String(project.simulation.t_start));
|
simulation.setAttribute("tStart", String(resolvedSimulation.t_start));
|
||||||
simulation.setAttribute("tStop", String(project.simulation.t_stop));
|
simulation.setAttribute("tStop", String(resolvedSimulation.t_stop));
|
||||||
simulation.setAttribute("step", String(project.simulation.step));
|
simulation.setAttribute("step", String(resolvedSimulation.step));
|
||||||
simulation.setAttribute("maxStep", String(project.simulation.max_step));
|
simulation.setAttribute("maxStep", String(resolvedSimulation.max_step));
|
||||||
simulation.setAttribute("method", project.simulation.method);
|
simulation.setAttribute("method", resolvedSimulation.method);
|
||||||
const components = xmlDocument.createElement("Components");
|
const components = xmlDocument.createElement("Components");
|
||||||
const connections = xmlDocument.createElement("Connections");
|
const connections = xmlDocument.createElement("Connections");
|
||||||
system.append(simulation, components, connections);
|
system.append(simulation, components, connections);
|
||||||
|
|
||||||
project.nodes.forEach((node) => {
|
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");
|
const component = xmlDocument.createElement("Component");
|
||||||
component.setAttribute("id", node.id);
|
component.setAttribute("id", node.id);
|
||||||
component.setAttribute("name", node.data.label);
|
component.setAttribute("name", node.data.label);
|
||||||
@@ -5590,9 +5848,20 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
|
|||||||
component.appendChild(port);
|
component.appendChild(port);
|
||||||
});
|
});
|
||||||
Object.entries(node.data.parameters).forEach(([name, value]) => {
|
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");
|
const parameter = xmlDocument.createElement("Parameter");
|
||||||
parameter.setAttribute("name", name);
|
parameter.setAttribute("name", name);
|
||||||
parameter.setAttribute("value", String(value));
|
parameter.setAttribute("value", formatEditableNumber(resolved.value));
|
||||||
component.appendChild(parameter);
|
component.appendChild(parameter);
|
||||||
});
|
});
|
||||||
components.appendChild(component);
|
components.appendChild(component);
|
||||||
|
|||||||
@@ -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 ComponentSymbolSurface = "palette" | "canvas";
|
||||||
export type ComponentSymbolPresentation = "framed" | "bare";
|
|
||||||
|
|
||||||
type ComponentSymbolProps = {
|
type ComponentSymbolProps = {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
surface: ComponentSymbolSurface;
|
surface: ComponentSymbolSurface;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SymbolDefinition = {
|
|
||||||
presentation: ComponentSymbolPresentation;
|
|
||||||
render: () => ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SymbolSvgProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SymbolSvg({ children, className }: SymbolSvgProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className={["component-symbol-svg", className].filter(Boolean).join(" ")}
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
viewBox="0 0 64 48"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CylinderSymbol() {
|
function CylinderSymbol() {
|
||||||
return (
|
return (
|
||||||
<SymbolSvg>
|
<SymbolSvg>
|
||||||
@@ -145,28 +149,6 @@ function JunctionSymbol() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediumSymbol({ label }: { label: "Air" | "He" }) {
|
|
||||||
return (
|
|
||||||
<SymbolSvg className="component-symbol-medium-svg">
|
|
||||||
<circle cx="32" cy="24" fill="#ffffff" r="22" stroke="#c026d3" strokeWidth="2" />
|
|
||||||
<text
|
|
||||||
className="component-symbol-medium-label"
|
|
||||||
data-symbol-label={label}
|
|
||||||
dominantBaseline="central"
|
|
||||||
fill="#a21caf"
|
|
||||||
fontFamily="Arial, sans-serif"
|
|
||||||
fontSize={label === "Air" ? 12 : 16}
|
|
||||||
fontWeight="500"
|
|
||||||
textAnchor="middle"
|
|
||||||
x="32"
|
|
||||||
y="24"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
</SymbolSvg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const symbolRegistry = {
|
const symbolRegistry = {
|
||||||
cylinder: { presentation: "framed", render: CylinderSymbol },
|
cylinder: { presentation: "framed", render: CylinderSymbol },
|
||||||
tank: { presentation: "framed", render: TankSymbol },
|
tank: { presentation: "framed", render: TankSymbol },
|
||||||
@@ -176,13 +158,38 @@ const symbolRegistry = {
|
|||||||
generic: { presentation: "framed", render: GenericSymbol },
|
generic: { presentation: "framed", render: GenericSymbol },
|
||||||
signal: { presentation: "framed", render: SignalSymbol },
|
signal: { presentation: "framed", render: SignalSymbol },
|
||||||
junction: { presentation: "framed", render: JunctionSymbol },
|
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: {
|
amesim_ideal_air_medium: {
|
||||||
presentation: "bare",
|
presentation: "bare",
|
||||||
render: () => <MediumSymbol label="Air" />,
|
render: AmesimIdealAirMediumSymbol,
|
||||||
},
|
},
|
||||||
amesim_helium_medium: {
|
amesim_helium_medium: {
|
||||||
presentation: "bare",
|
presentation: "bare",
|
||||||
render: () => <MediumSymbol label="He" />,
|
render: AmesimHeliumMediumSymbol,
|
||||||
},
|
},
|
||||||
} satisfies Record<string, SymbolDefinition>;
|
} satisfies Record<string, SymbolDefinition>;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type ParameterTableRow = {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
range?: string;
|
range?: string;
|
||||||
currentUnit?: string;
|
currentUnit?: string;
|
||||||
|
calculatedValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParameterTableProps = {
|
type ParameterTableProps = {
|
||||||
@@ -144,7 +145,8 @@ function rowHasHelpPreview(row: ParameterTableRow) {
|
|||||||
(typeof row.help === "string" && row.help) ||
|
(typeof row.help === "string" && row.help) ||
|
||||||
row.defaultValue ||
|
row.defaultValue ||
|
||||||
row.range ||
|
row.range ||
|
||||||
row.currentUnit,
|
row.currentUnit ||
|
||||||
|
row.calculatedValue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,10 +464,16 @@ export function ParameterTable({
|
|||||||
{previewDescription}
|
{previewDescription}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{previewRow.defaultValue ||
|
{previewRow.calculatedValue ||
|
||||||
|
previewRow.defaultValue ||
|
||||||
previewRow.range ||
|
previewRow.range ||
|
||||||
previewRow.currentUnit ? (
|
previewRow.currentUnit ? (
|
||||||
<span className="parameter-help-preview-meta">
|
<span className="parameter-help-preview-meta">
|
||||||
|
{previewRow.calculatedValue ? (
|
||||||
|
<span data-testid="parameter-calculated-value">
|
||||||
|
计算值:{previewRow.calculatedValue}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{previewRow.defaultValue ? (
|
{previewRow.defaultValue ? (
|
||||||
<span>默认值:{previewRow.defaultValue}</span>
|
<span>默认值:{previewRow.defaultValue}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SYMBOL_ACCENT, SYMBOL_STROKE, SymbolSvg } from "./primitives";
|
||||||
|
|
||||||
|
export function AmesimPnpl01Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg domain="pneumatic">
|
||||||
|
<path d="M2 24h34" stroke={SYMBOL_STROKE} strokeWidth="2" />
|
||||||
|
<path
|
||||||
|
d="m35 17 14 14m0-14L35 31"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<g {...mechanicalStroke}>
|
||||||
|
<path d={facingLeft ? "M2 24h7" : "M55 24h7"} />
|
||||||
|
<path d={facingLeft ? "M15 19H9v10h6" : "M49 19h6v10h-6"} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideHatching({ y, upward }: { y: number; upward: boolean }) {
|
||||||
|
const direction = upward ? -1 : 1;
|
||||||
|
return (
|
||||||
|
<g stroke={SYMBOL_MUTED} strokeWidth="1">
|
||||||
|
{[14, 22, 30, 38, 46].map((x) => (
|
||||||
|
<path
|
||||||
|
d={`M${x - 3} ${y + direction * 3}L${x + 3} ${y - direction * 3}`}
|
||||||
|
key={x}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimF000Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-f000" domain="mechanical">
|
||||||
|
<path d="M41 24h21" {...mechanicalStroke} />
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="28"
|
||||||
|
rx="1"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2.2"
|
||||||
|
width="18"
|
||||||
|
x="23"
|
||||||
|
y="12"
|
||||||
|
/>
|
||||||
|
<path d="M27 26h10" stroke={SYMBOL_ACCENT} strokeWidth="2" />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimForcSymbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-forc" domain="mechanical">
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
>
|
||||||
|
<path d="M2 24h15" />
|
||||||
|
<path d="m11 19 6 5-6 5" />
|
||||||
|
</g>
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="24"
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="12"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={SYMBOL_STROKE}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
textAnchor="middle"
|
||||||
|
x="32"
|
||||||
|
y="24"
|
||||||
|
>
|
||||||
|
F
|
||||||
|
</text>
|
||||||
|
<path d="M44 24h6M50 18v12M50 18h8M50 30h8M58 18v12M58 24h4" {...mechanicalStroke} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimMecmas21Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-mecmas21" domain="mechanical">
|
||||||
|
<GuideHatching upward y={5} />
|
||||||
|
<GuideHatching upward={false} y={43} />
|
||||||
|
<path d="M10 8h44M10 40h44" stroke={SYMBOL_MUTED} strokeWidth="1.3" />
|
||||||
|
<MechanicalSocket side="left" />
|
||||||
|
<MechanicalSocket side="right" />
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="26"
|
||||||
|
rx="1"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2"
|
||||||
|
width="34"
|
||||||
|
x="15"
|
||||||
|
y="11"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={SYMBOL_STROKE}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize="12"
|
||||||
|
fontWeight="600"
|
||||||
|
textAnchor="middle"
|
||||||
|
x="31"
|
||||||
|
y="28"
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</text>
|
||||||
|
<path
|
||||||
|
d="M22 18h15m-5-4 5 4-5 4"
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimLstp00aSymbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical">
|
||||||
|
<path d="M2 24h8M54 24h8" {...mechanicalStroke} />
|
||||||
|
<g transform="rotate(90 32 24)">
|
||||||
|
<path d="M32 2v7M32 39v7" {...mechanicalStroke} />
|
||||||
|
<path d="M27 9V5h10v4M27 39v4h10v-4" {...mechanicalStroke} />
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="3"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="1.6"
|
||||||
|
width="46"
|
||||||
|
x="9"
|
||||||
|
y="10"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="3"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="1.6"
|
||||||
|
width="46"
|
||||||
|
x="9"
|
||||||
|
y="31"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="3"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="1.6"
|
||||||
|
width="46"
|
||||||
|
x="9"
|
||||||
|
y="39"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 13v4c0 2 12 2 12 4s-12 2-12 4 12 2 12 6"
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<path d="M36 18v-5M36 18h14v6H36v7M40 22h6" {...mechanicalStroke} />
|
||||||
|
</g>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimLmechn1Symbol() {
|
||||||
|
const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42];
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical">
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height="44"
|
||||||
|
rx="1"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="1.8"
|
||||||
|
width="12"
|
||||||
|
x="27"
|
||||||
|
y="2"
|
||||||
|
/>
|
||||||
|
{leftPortRows.map((y) => (
|
||||||
|
<path
|
||||||
|
d={`M5 ${y}h22`}
|
||||||
|
key={y}
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<path d="M39 24h20" stroke={SYMBOL_STROKE} strokeWidth="1.8" />
|
||||||
|
<circle cx="33" cy="24" fill={SYMBOL_ACCENT} r="2.2" />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { SYMBOL_ACCENT, SYMBOL_FILL, SYMBOL_STROKE, SymbolSvg } from "./primitives";
|
||||||
|
|
||||||
|
function MediumSymbol({ label }: { label: "Air" | "He" }) {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-medium-svg" domain="medium">
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="24"
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="21"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
className="component-symbol-medium-label"
|
||||||
|
data-symbol-label={label}
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={SYMBOL_ACCENT}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize={label === "Air" ? 12 : 16}
|
||||||
|
fontWeight="500"
|
||||||
|
textAnchor="middle"
|
||||||
|
x="32"
|
||||||
|
y="24"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimIdealAirMediumSymbol() {
|
||||||
|
return <MediumSymbol label="Air" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimHeliumMediumSymbol() {
|
||||||
|
return <MediumSymbol label="He" />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d={`${leftConnector}M44 ${centerY}h18M20 ${centerY}h24`}
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={`M20 ${top}c5 6 9 7 12 7s7-1 12-7M20 ${bottom}c5-6 9-7 12-7s7 1 12 7`}
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-coefficient pneumatic orifice (AMESim PNOR001). */
|
||||||
|
export function AmesimPnor001Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pnor001-svg" domain="pneumatic">
|
||||||
|
<OrificeBody centerY={24} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNVO001 convenience variant whose opening is fixed by a parameter. */
|
||||||
|
export function AmesimPnvo001FixedSymbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg
|
||||||
|
className="component-symbol-amesim-pnvo001-fixed-svg"
|
||||||
|
domain="pneumatic"
|
||||||
|
>
|
||||||
|
<OrificeBody centerY={28} />
|
||||||
|
<path
|
||||||
|
d="M43 4 34 20m9-16-2 7-5-3"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signal-modulated pneumatic orifice (AMESim PNVO001 / pn_morifice). */
|
||||||
|
export function AmesimPnvo001SignalSymbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg
|
||||||
|
className="component-symbol-amesim-pnvo001-signal-svg"
|
||||||
|
domain="pneumatic"
|
||||||
|
>
|
||||||
|
<OrificeBody centerY={25} leftPortY={32} />
|
||||||
|
<path
|
||||||
|
d="M2 16h11l21 5m0 0-7-1m7 1-5 5"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M2 24h7M55 24h7"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
height={bottom - top}
|
||||||
|
rx="2"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
width={right - left}
|
||||||
|
x={left}
|
||||||
|
y={top}
|
||||||
|
/>
|
||||||
|
{sections.slice(1).map((_, index) => {
|
||||||
|
const x = left + sectionWidth * (index + 1);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M${x} ${top + 2}v${bottom - top - 4}`}
|
||||||
|
key={x}
|
||||||
|
stroke={SYMBOL_MUTED}
|
||||||
|
strokeWidth="1.1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<text
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={section === "R" ? SYMBOL_ACCENT : SYMBOL_MUTED}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize="9"
|
||||||
|
fontWeight="700"
|
||||||
|
key={`${section}-${index}`}
|
||||||
|
textAnchor="middle"
|
||||||
|
x={left + sectionWidth * (index + 0.5)}
|
||||||
|
y="24"
|
||||||
|
>
|
||||||
|
{section}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure pneumatic resistance line (AMESim PNL00R). */
|
||||||
|
export function AmesimPnl00rSymbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pnl00r-svg" domain="pneumatic">
|
||||||
|
<PipeNetworkBody sections={["R"]} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C-R dynamic pneumatic line (AMESim PNL0001). */
|
||||||
|
export function AmesimPnl0001Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pnl0001-svg" domain="pneumatic">
|
||||||
|
<PipeNetworkBody sections={["C", "R"]} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** R-C-R dynamic pneumatic line (AMESim PNL0002). */
|
||||||
|
export function AmesimPnl0002Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pnl0002-svg" domain="pneumatic">
|
||||||
|
<PipeNetworkBody sections={["R", "C", "R"]} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C-R-C dynamic pneumatic line (AMESim PNL0003). */
|
||||||
|
export function AmesimPnl0003Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pnl0003-svg" domain="pneumatic">
|
||||||
|
<PipeNetworkBody sections={["C", "R", "C"]} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JunctionTerminal({ x, y }: { x: number; y: number }) {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="2"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="1.3"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
|
||||||
|
export function AmesimPn3Node2Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic">
|
||||||
|
<path
|
||||||
|
d="M4 24h25M29 24C41 24 42 12 60 12M29 24c12 0 13 12 31 12"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<circle cx="29" cy="24" fill={SYMBOL_ACCENT} r="3" />
|
||||||
|
<JunctionTerminal x={3} y={24} />
|
||||||
|
<JunctionTerminal x={61} y={12} />
|
||||||
|
<JunctionTerminal x={61} y={36} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four-port pneumatic junction with one inlet-side and three outlet-side ports. */
|
||||||
|
export function AmesimP4Node2Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic">
|
||||||
|
<path
|
||||||
|
d="M4 24h24M28 24C40 24 42 8 60 8M28 24h32M28 24c12 0 14 16 32 16"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={PNEUMATIC_STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<circle cx="28" cy="24" fill={SYMBOL_ACCENT} r="3" />
|
||||||
|
<JunctionTerminal x={3} y={24} />
|
||||||
|
<JunctionTerminal x={61} y={8} />
|
||||||
|
<JunctionTerminal x={61} y={24} />
|
||||||
|
<JunctionTerminal x={61} y={40} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={["component-symbol-svg", className].filter(Boolean).join(" ")}
|
||||||
|
data-symbol-domain={domain}
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
viewBox={viewBox}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
SYMBOL_ACCENT,
|
||||||
|
SYMBOL_FILL,
|
||||||
|
SYMBOL_STROKE,
|
||||||
|
SymbolSvg,
|
||||||
|
} from "./primitives";
|
||||||
|
|
||||||
|
const SIGNAL_LINE_WIDTH = 1.8;
|
||||||
|
|
||||||
|
function SignalOutput() {
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||||
|
>
|
||||||
|
<path d="M46 24h14" />
|
||||||
|
<path d="m54 20 6 4-6 4" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimStep0Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-step0" domain="signal">
|
||||||
|
<circle
|
||||||
|
cx="27"
|
||||||
|
cy="24"
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="18"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 30h8V17h18"
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="square"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
/>
|
||||||
|
<SignalOutput />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimUd00Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg className="component-symbol-amesim-ud00" domain="signal">
|
||||||
|
<circle
|
||||||
|
cx="27"
|
||||||
|
cy="24"
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="18"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth={SIGNAL_LINE_WIDTH}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13 30h5l5-10h5l5-9 5 19h3"
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
/>
|
||||||
|
<SignalOutput />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
SYMBOL_ACCENT,
|
||||||
|
SYMBOL_FILL,
|
||||||
|
SYMBOL_STROKE,
|
||||||
|
SymbolSvg,
|
||||||
|
} from "./primitives";
|
||||||
|
|
||||||
|
function ChamberCore({ variable }: { variable: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
cx="32"
|
||||||
|
cy="24"
|
||||||
|
fill={SYMBOL_FILL}
|
||||||
|
r="13"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill={SYMBOL_ACCENT}
|
||||||
|
fontFamily="Arial, sans-serif"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
x="32"
|
||||||
|
y="24"
|
||||||
|
>
|
||||||
|
Cp
|
||||||
|
</text>
|
||||||
|
{variable ? (
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke={SYMBOL_ACCENT}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="m20 13-5-5m0 0h4m-4 0v4" />
|
||||||
|
<path d="m44 13 5-5m0 0h-4m4 0v4" />
|
||||||
|
<path d="m20 35-5 5m0 0h4m-4 0v-4" />
|
||||||
|
<path d="m44 35 5 5m0 0h-4m4 0v-4" />
|
||||||
|
</g>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimPnch023Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg domain="pneumatic">
|
||||||
|
<path d="M2 24h17M45 24h17" stroke={SYMBOL_STROKE} strokeWidth="2" />
|
||||||
|
<ChamberCore variable={false} />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmesimPnch012Symbol() {
|
||||||
|
return (
|
||||||
|
<SymbolSvg domain="pneumatic">
|
||||||
|
<path
|
||||||
|
d="M2 16h17M2 32h17M45 16h17M45 32h17"
|
||||||
|
stroke={SYMBOL_STROKE}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<ChamberCore variable />
|
||||||
|
</SymbolSvg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ComponentSymbolPresentation = "framed" | "bare";
|
||||||
|
|
||||||
|
export type SymbolDefinition = {
|
||||||
|
presentation: ComponentSymbolPresentation;
|
||||||
|
render: () => ReactNode;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -964,6 +964,42 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: visible;
|
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 {
|
.component-symbol-medium-label {
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -201,3 +201,108 @@ test("参数表列宽可调整并在刷新后保留", async ({ page }) => {
|
|||||||
await page.keyboard.press("ArrowRight");
|
await page.keyboard.press("ArrowRight");
|
||||||
await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120");
|
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(
|
||||||
|
'<Parameter name="reference_pressure" value="78500"',
|
||||||
|
);
|
||||||
|
await expect(xmlBlock).not.toContainText(expression);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
await pressureInput.fill("120");
|
||||||
|
await pressureInput.press("Enter");
|
||||||
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.locator('.flow-canvas .react-flow__node[data-id="amesim_pneumatic_test_1"]')
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue(
|
||||||
|
expression,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非法组件参数公式显示错误并阻止 XML 生成", async ({ page }) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
@@ -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):
|
class AmesimPnvo001SignalXmlTests(unittest.TestCase):
|
||||||
def test_signal_project_compiles_with_signal_connection(self) -> None:
|
def test_signal_project_compiles_with_signal_connection(self) -> None:
|
||||||
network = compile_reactflow_network(amesim_pnvo001_signal_project())
|
network = compile_reactflow_network(amesim_pnvo001_signal_project())
|
||||||
@@ -107,6 +227,26 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase):
|
|||||||
self.assertIn("valve_1.xv", result["series"])
|
self.assertIn("valve_1.xv", result["series"])
|
||||||
self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -20,6 +20,8 @@ class AmesimSignalComponentTests(unittest.TestCase):
|
|||||||
self.assertEqual(step.output_at(0.49), 0.2)
|
self.assertEqual(step.output_at(0.49), 0.2)
|
||||||
self.assertEqual(step.output_at(0.5), 0.8)
|
self.assertEqual(step.output_at(0.5), 0.8)
|
||||||
self.assertEqual(step.signal_output_values(0.5), {"out": 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:
|
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(2.5), 15.0)
|
||||||
self.assertAlmostEqual(signal.output_at(5.0), 35.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_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:
|
def test_ud00_can_cycle_active_stages(self) -> None:
|
||||||
signal = AmesimUd00(
|
signal = AmesimUd00(
|
||||||
@@ -53,6 +56,7 @@ class AmesimSignalComponentTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(signal.output_at(0.25), 2.5)
|
self.assertAlmostEqual(signal.output_at(0.25), 2.5)
|
||||||
self.assertAlmostEqual(signal.output_at(1.25), 12.5)
|
self.assertAlmostEqual(signal.output_at(1.25), 12.5)
|
||||||
self.assertAlmostEqual(signal.output_at(2.25), 2.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:
|
def test_ud00_registry_rejects_fractional_stage_controls(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "nstages must be an integer"):
|
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(step.out.signal, 0.75)
|
||||||
self.assertAlmostEqual(valve.res.signal, 0.75)
|
self.assertAlmostEqual(valve.res.signal, 0.75)
|
||||||
self.assertAlmostEqual(valve.opening, 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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ class ComponentCatalogTests(unittest.TestCase):
|
|||||||
self.assertEqual(library["label"], "AMESim 组件库")
|
self.assertEqual(library["label"], "AMESim 组件库")
|
||||||
self.assertEqual([category["id"] for category in library["categories"]], ["media", "storage", "flow", "junctions", "boundary", "signals", "mechanical"])
|
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"})
|
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(
|
self.assertEqual(
|
||||||
components["amesim_ideal_air_medium"]["role"],
|
components["amesim_ideal_air_medium"]["role"],
|
||||||
"amesimGasMediumDefinition",
|
"amesimGasMediumDefinition",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import math
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
@@ -78,6 +79,124 @@ class IntegrateOdeTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.status, "cancelled")
|
self.assertEqual(result.status, "cancelled")
|
||||||
self.assertEqual(result.t, [0.0])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user