完善AMESim组件界面与仿真求解稳定性

This commit is contained in:
ljz committed 2026-08-02 00:57:48 +08:00
1 parent e7177ab03e
commit 410ef535e8
34 files changed
+3340 -251

No files matched your search

+5
View File
@@ -641,6 +641,11 @@ def run_system_xml_simulation(
t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop,
method=project.simulation.method,
# The pressure-flow closure is solved to a scaled 1e-7
# residual. Asking the outer adaptive integrator for 1e-6
# relative accuracy makes its finite-difference Jacobian chase
# algebraic solver noise after discontinuous signal events.
rtol=1.0e-5,
max_step=project.simulation.max_step,
),
sample_step=project.simulation.step,
@@ -26,7 +26,7 @@ class AmesimPnpl01(AlgebraicComponent):
label="PNPL01 零气动流边界",
library_id="amesim",
category_id="boundary",
symbol="generic",
symbol="amesim_pnpl01",
ports=(PortDisplaySpec("port_1", "left", order=10),),
order=10,
)
@@ -105,7 +105,7 @@ class AmesimPnor001(AlgebraicComponent):
label="PNOR001 常系数气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnor001",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -412,7 +412,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
label="PNVO001 固定开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnvo001_fixed",
ports=(
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
@@ -633,7 +633,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
label="PNVO001 信号开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
symbol="amesim_pnvo001",
ports=(
PortDisplaySpec("res", "left", order=5),
PortDisplaySpec("port_2", "left", order=10),
@@ -105,7 +105,7 @@ class AmesimPnl00r(AlgebraicComponent):
label="PNL00R 气动管路阻力",
library_id="amesim",
category_id="flow",
symbol="pipe",
symbol="amesim_pnl00r",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -440,7 +440,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
label="PNL0001 C-R 动态管路",
library_id="amesim",
category_id="flow",
symbol="pipe",
symbol="amesim_pnl0001",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -720,7 +720,7 @@ class AmesimPnl0002(AmesimPnl0001):
label="PNL0002 R-C-R 动态管路",
library_id="amesim",
category_id="flow",
symbol="pipe",
symbol="amesim_pnl0002",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -920,7 +920,7 @@ class AmesimPnl0003(DynamicComponent):
label="PNL0003 C-R-C 动态管路",
library_id="amesim",
category_id="flow",
symbol="pipe",
symbol="amesim_pnl0003",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -91,7 +91,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
label="PN3NODE2 三端气动节点",
library_id="amesim",
category_id="junctions",
symbol="tee",
symbol="amesim_pn3node2",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -128,7 +128,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
label="P4NODE2 四端气动节点",
library_id="amesim",
category_id="junctions",
symbol="generic",
symbol="amesim_p4node2",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -22,7 +22,7 @@ class AmesimF000(AlgebraicComponent):
label="F000 零力源",
library_id="amesim",
category_id="mechanical",
symbol="generic",
symbol="amesim_f000",
ports=(PortDisplaySpec("port_1", "right", order=10),),
order=10,
)
@@ -73,7 +73,7 @@ class AmesimForc(AlgebraicComponent):
label="FORC 信号转力",
library_id="amesim",
category_id="mechanical",
symbol="signal",
symbol="amesim_forc",
ports=(
PortDisplaySpec("res", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -167,7 +167,7 @@ class AmesimMecmas21(DynamicComponent):
label="MECMAS21 一维质量",
library_id="amesim",
category_id="mechanical",
symbol="generic",
symbol="amesim_mecmas21",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -323,7 +323,7 @@ class AmesimLstp00a(AlgebraicComponent):
label="LSTP00A 弹性接触",
library_id="amesim",
category_id="mechanical",
symbol="generic",
symbol="amesim_lstp00a",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -425,7 +425,7 @@ class AmesimLmechn1(AlgebraicComponent):
label="LMECHN1 线性机械节点",
library_id="amesim",
category_id="mechanical",
symbol="junction",
symbol="amesim_lmechn1",
ports=tuple(
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
+ [PortDisplaySpec("port_9", "right", order=90)]
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from math import floor
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
@@ -27,7 +28,7 @@ class AmesimStep0(AlgebraicComponent):
label="STEP0 阶跃信号",
library_id="amesim",
category_id="signals",
symbol="signal",
symbol="amesim_step0",
ports=(PortDisplaySpec("out", "right", order=10),),
order=10,
)
@@ -71,6 +72,15 @@ class AmesimStep0(AlgebraicComponent):
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def signal_event_times(
self,
start_time: float,
stop_time: float,
) -> tuple[float, ...]:
"""Expose the exact STEP0 switch time as an integration split point."""
return (self.time,) if start_time < self.time < stop_time else ()
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
@@ -117,7 +127,7 @@ class AmesimUd00(AlgebraicComponent):
label="UD00 分段线性信号",
library_id="amesim",
category_id="signals",
symbol="signal",
symbol="amesim_ud00",
ports=(PortDisplaySpec("out", "right", order=10),),
order=20,
)
@@ -200,5 +210,58 @@ class AmesimUd00(AlgebraicComponent):
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def signal_event_times(
self,
start_time: float,
stop_time: float,
) -> tuple[float, ...]:
"""Return UD00 start, stage, and repeated cycle boundaries.
The final non-cyclic stage is intentionally not given an end event:
``output_at`` continues that stage's slope after its configured duration.
"""
if stop_time <= start_time:
return ()
active_durations = self.durations[: self.nstages]
stage_offsets = [0.0]
elapsed = 0.0
for duration in active_durations[:-1]:
elapsed += duration
stage_offsets.append(elapsed)
if not self.iscyclic:
return tuple(
sorted(
{
event_time
for offset in stage_offsets
if start_time
< (event_time := self.tstart + offset)
< stop_time
}
)
)
cycle_duration = sum(active_durations)
if cycle_duration <= 0.0:
return ()
events: set[float] = set()
for offset in stage_offsets:
first_boundary = self.tstart + offset
cycle_index = max(
0,
floor((start_time - first_boundary) / cycle_duration) + 1,
)
event_time = first_boundary + cycle_index * cycle_duration
while event_time < stop_time:
if event_time > start_time:
events.add(event_time)
cycle_index += 1
event_time = first_boundary + cycle_index * cycle_duration
return tuple(sorted(events))
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
@@ -100,7 +100,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
label="PNCH023 固定容积气室",
library_id="amesim",
category_id="storage",
symbol="tank",
symbol="amesim_pnch023",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
@@ -340,7 +340,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
label="PNCH012 变容气室",
library_id="amesim",
category_id="storage",
symbol="tank",
symbol="amesim_pnch012",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
+257 -43
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from math import sqrt
from math import isfinite, sqrt
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.systems.network import SimulationNetwork
@@ -68,6 +68,7 @@ class PressureFlowSolver:
self.residual_tolerance = residual_tolerance
self.max_evaluations = max_evaluations
self.unknowns = self._build_unknowns()
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
@@ -91,48 +92,220 @@ class PressureFlowSolver:
)
return tuple(unknowns)
def _seed_equal_pressures(self) -> None:
for _ in range(max(2, len(self.network.connections))):
changed = False
for connection in self.network.connections:
if connection.kind != "physical":
continue
first = self.network.components[
connection.endpoint_a.component
].get_port(connection.endpoint_a.port)
second = self.network.components[
connection.endpoint_b.component
].get_port(connection.endpoint_b.port)
if first.p > 0.0 and second.p <= 0.0:
second.p = first.p
changed = True
elif second.p > 0.0 and first.p <= 0.0:
first.p = second.p
changed = True
@staticmethod
def _port_key(variable: str, expected_variable: str) -> tuple[str, str] | None:
try:
component_name, port_name, variable_name = variable.rsplit(".", 2)
except ValueError:
return None
if variable_name != expected_variable:
return None
return component_name, port_name
for component in self.network.components.values():
equal_pressure_equations = [
equation
for equation in component.pressure_flow_equation_residuals()
if equation.relation == "equal" and equation.role == "effort"
def _seed_equal_pressures(self) -> None:
"""Lift current state pressures across their complete equality groups.
Dynamic components refresh their own pressure ports before each closure,
while connected algebraic ports retain values from the preceding RHS
evaluation. Merely filling non-positive pressures therefore leaves a
stale, and sometimes badly conditioned, nonlinear initial guess. State
equations expose the current pressure as ``port.p - target``; use that
target as the authoritative anchor for every connected/equal port.
"""
pressure_unknowns = {
(unknown.component, unknown.port): unknown
for unknown in self.unknowns
if unknown.variable == "p"
}
if not pressure_unknowns:
return
parent = {key: key for key in pressure_unknowns}
def find(key: tuple[str, str]) -> tuple[str, str]:
root = key
while parent[root] != root:
root = parent[root]
while parent[key] != key:
next_key = parent[key]
parent[key] = root
key = next_key
return root
def union(first: tuple[str, str], second: tuple[str, str]) -> None:
first_root = find(first)
second_root = find(second)
if first_root != second_root:
parent[second_root] = first_root
for connection in self.network.connections:
if connection.kind != "physical":
continue
first = connection.endpoint_a.key
second = connection.endpoint_b.key
if first in pressure_unknowns and second in pressure_unknowns:
union(first, second)
component_equations = {
component.name: component.pressure_flow_equation_residuals()
for component in self.network.components.values()
}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "equal" or equation.role != "effort":
continue
endpoints = [
endpoint
for variable in equation.variables
if (
(endpoint := self._port_key(variable, "p"))
in pressure_unknowns
)
]
for equation in equal_pressure_equations:
states = []
for variable in equation.variables:
_, port_name, variable_name = variable.rsplit(".", 2)
if variable_name == "p":
states.append(component.get_port(port_name))
if len(states) != 2:
continue
first, second = states
if first.p > 0.0 and second.p <= 0.0:
second.p = first.p
changed = True
elif second.p > 0.0 and first.p <= 0.0:
first.p = second.p
changed = True
if not changed:
break
for endpoint in endpoints[1:]:
union(endpoints[0], endpoint)
members_by_root: dict[tuple[str, str], list[tuple[str, str]]] = {}
for endpoint in pressure_unknowns:
members_by_root.setdefault(find(endpoint), []).append(endpoint)
anchors_by_root: dict[tuple[str, str], list[float]] = {}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "state" or equation.role != "effort":
continue
endpoints = [
endpoint
for variable in equation.variables
if (
(endpoint := self._port_key(variable, "p"))
in pressure_unknowns
)
]
if len(endpoints) != 1:
continue
endpoint = endpoints[0]
unknown = pressure_unknowns[endpoint]
target_pressure = unknown.read() - float(equation.value)
if not isfinite(target_pressure):
continue
# Keep the state-owned port current even when an invalid model
# has conflicting storage anchors in one equality group.
unknown.write(target_pressure)
anchors_by_root.setdefault(find(endpoint), []).append(target_pressure)
for root, members in members_by_root.items():
anchors = anchors_by_root.get(root, [])
if anchors:
pressure_scale = max([abs(value) for value in anchors] + [1.0])
if max(anchors) - min(anchors) > 1.0e-9 * pressure_scale:
# A conflicting multi-storage group is structurally invalid;
# leave it for the residual solver/preparation diagnostics.
continue
target_pressure = sum(anchors) / len(anchors)
for endpoint in members:
pressure_unknowns[endpoint].write(target_pressure)
continue
positive_seed = next(
(
pressure_unknowns[endpoint].read()
for endpoint in members
if pressure_unknowns[endpoint].read() > 0.0
),
None,
)
if positive_seed is None:
continue
for endpoint in members:
unknown = pressure_unknowns[endpoint]
if unknown.read() <= 0.0:
unknown.write(positive_seed)
def _seed_explicit_mass_flows(self) -> None:
"""Initialize explicit ``m_flow - f(...)`` constitutive relations.
AMESim orifices and quasi-steady pneumatic lines expose one mass-flow
unknown with unit coefficient. Once pressure anchors are current, a
residual correction places that flow directly on its constitutive
surface and avoids asking the nonlinear optimizer to discover the
square-root branch from a stale preceding-step value.
"""
seeded_ids: set[str] = set()
for component in self.network.components.values():
for equation in component.pressure_flow_equation_residuals():
if equation.relation != "constitutive" or equation.role != "flow":
continue
mass_flow_unknowns = [
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].variable == "m_flow"
]
if len(mass_flow_unknowns) != 1:
continue
unknown = mass_flow_unknowns[0]
target_flow = unknown.read() - float(equation.value)
if not isfinite(target_flow):
continue
unknown.write(target_flow)
seeded_ids.add(unknown.id)
# Complete local two-port balances for explicit elements. Connection
# flow equations remain available to align the adjacent component port.
for component in self.network.components.values():
for equation in component.pressure_flow_equation_residuals():
if equation.relation != "sumToZero" or equation.role != "flow":
continue
mass_flow_unknowns = [
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].variable == "m_flow"
]
if len(mass_flow_unknowns) != 2:
continue
seeded = [
unknown for unknown in mass_flow_unknowns if unknown.id in seeded_ids
]
if len(seeded) != 1:
continue
other = next(
unknown for unknown in mass_flow_unknowns if unknown.id not in seeded_ids
)
other.write(-seeded[0].read())
seeded_ids.add(other.id)
# A physical connector imposes the same sum-to-zero flow rule as a
# two-port component. Once an explicit component flow is known, carry
# that guess to the connected storage/boundary port as well. For the
# common volume-orifice-volume topology this makes the seeded state an
# exact algebraic solution and avoids an unnecessary nonlinear solve on
# every ODE/Jacobian evaluation.
for connection in self.network.connections:
if connection.kind != "physical":
continue
endpoint_unknowns = []
for endpoint in connection.endpoints:
unknown = self._unknowns_by_id.get(
f"{endpoint.component}.{endpoint.port}.m_flow"
)
if unknown is not None:
endpoint_unknowns.append(unknown)
if len(endpoint_unknowns) != 2:
continue
seeded = [
unknown for unknown in endpoint_unknowns if unknown.id in seeded_ids
]
if len(seeded) != 1:
continue
other = next(
unknown for unknown in endpoint_unknowns if unknown.id not in seeded_ids
)
other.write(-seeded[0].read())
seeded_ids.add(other.id)
def _scales(self) -> dict[str, float]:
pressure_scale = max(
@@ -184,6 +357,7 @@ class PressureFlowSolver:
) from exc
self._seed_equal_pressures()
self._seed_explicit_mass_flows()
scales = self._scales()
pressure_scale = scales["p"]
flow_scale = scales["m_flow"]
@@ -216,6 +390,40 @@ class PressureFlowSolver:
return pressure_scale
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
seeded_equations = self.network.pressure_flow_equation_residuals()
seeded_scaled = [
abs(equation.value / equation_scale(equation))
for equation in seeded_equations
]
seeded_max_scaled_residual = max(seeded_scaled, default=0.0)
seeded_unknown_values = [
(unknown, unknown.read()) for unknown in self.unknowns
]
seeded_unknowns_are_feasible = all(
isfinite(value)
and (unknown.variable != "p" or value >= 1.0)
for unknown, value in seeded_unknown_values
)
if (
seeded_unknowns_are_feasible
and all(isfinite(value) for value in seeded_scaled)
and seeded_max_scaled_residual <= self.residual_tolerance
):
diagnostics = AlgebraicSolveDiagnostics(
success=True,
message="Seeded pressure-flow state satisfies the residual tolerance.",
evaluations=0,
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=seeded_max_scaled_residual,
max_raw_residual=max(
(abs(item.value) for item in seeded_equations),
default=0.0,
),
)
self.last_diagnostics = diagnostics
return diagnostics
x0 = np.asarray(
[
(
@@ -269,14 +477,20 @@ class PressureFlowSolver:
)
for equation in equations
]
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
max_scaled_residual = max(scaled, default=0.0)
residuals_converged = (
all(isfinite(value) for value in scaled)
and max_scaled_residual <= self.residual_tolerance
)
optimizer_status_is_acceptable = bool(result.success) or int(result.status) == 0
success = residuals_converged and optimizer_status_is_acceptable
diagnostics = AlgebraicSolveDiagnostics(
success=success,
message=str(result.message),
evaluations=int(result.nfev),
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=max(scaled, default=0.0),
max_scaled_residual=max_scaled_residual,
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
)
self.last_diagnostics = diagnostics
+47
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Protocol
from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -13,6 +14,21 @@ class SignalOutputComponent(Protocol):
...
class SignalEventSource(Protocol):
"""Optional contract for signal sources with known time discontinuities."""
name: str
def signal_event_times(
self,
start_time: float,
stop_time: float,
) -> tuple[float, ...]:
"""Return event times strictly inside ``(start_time, stop_time)``."""
...
@dataclass(frozen=True)
class SignalSolveDiagnostics:
propagated: int
@@ -51,6 +67,37 @@ class SignalResolver:
self.last_diagnostics = diagnostics
return diagnostics
def event_times(self, start_time: float, stop_time: float) -> tuple[float, ...]:
"""Collect optional source events that can be used as integration splits.
Event discovery is deliberately duck typed so existing signal-output
components remain valid without implementing ``signal_event_times``.
"""
start = float(start_time)
stop = float(stop_time)
if not isfinite(start) or not isfinite(stop):
raise ValueError("Signal event interval must be finite.")
if stop < start:
raise ValueError("Signal event interval stop must not precede start.")
if stop == start:
return ()
events: set[float] = set()
for component in self.network.components.values():
source_event_times = getattr(component, "signal_event_times", None)
if source_event_times is None:
continue
for raw_time in source_event_times(start, stop):
event_time = float(raw_time)
if not isfinite(event_time):
raise ValueError(
f"Signal event time from component '{component.name}' must be finite."
)
if start < event_time < stop:
events.add(event_time)
return tuple(sorted(events))
def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]:
first, second = endpoints
first_port = self.network.components[first.component].get_port(first.port)
+331 -88
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Callable, Literal
from typing import Callable, Literal, Sequence
CancellationCheck = Callable[[], bool]
@@ -51,6 +52,33 @@ def _append_solution_sample(
states[index].append(float(value))
def _normalize_breakpoints(
config: SolveIVPConfig,
breakpoints: Sequence[float] | None,
) -> list[float]:
"""Return sorted, unique breakpoints strictly inside the integration span."""
if breakpoints is None or len(breakpoints) == 0:
return []
if config.t_stop < config.t_start:
raise ValueError("Segmented integration requires t_stop to follow t_start.")
normalized: list[float] = []
for raw_breakpoint in breakpoints:
breakpoint = float(raw_breakpoint)
if not math.isfinite(breakpoint):
raise ValueError("Integration breakpoints must be finite numbers.")
if config.t_start < breakpoint < config.t_stop:
normalized.append(breakpoint)
normalized.sort()
return [
breakpoint
for index, breakpoint in enumerate(normalized)
if index == 0 or breakpoint != normalized[index - 1]
]
def _runge_kutta_4(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
@@ -114,6 +142,137 @@ def _runge_kutta_4(
)
def _runge_kutta_4_segmented(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
config: SolveIVPConfig,
t_eval: list[float] | None,
breakpoints: Sequence[float],
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
) -> ODESolution:
"""RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint."""
if t_eval is None:
point_count = max(
2,
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
)
sample_step = (config.t_stop - config.t_start) / (point_count - 1)
sample_times = [
config.t_start + index * sample_step for index in range(point_count)
]
else:
sample_times = [float(time) for time in t_eval]
state = [float(value) for value in initial_state]
states = [[value] for value in state]
times = [float(config.t_start)]
current_time = float(config.t_start)
sample_index = 0
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= config.t_start + 1e-12
):
sample_index += 1
status: IntegrationStatus = "completed"
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
error: Exception | None = None
last_reported_step: float | None = None
def report_step(time: float) -> None:
nonlocal last_reported_step
if accepted_step_callback is None:
return
if last_reported_step is not None and time <= last_reported_step:
return
accepted_step_callback(float(time))
last_reported_step = float(time)
def advance_to(
target_time: float, reported_terminal_time: float | None = None
) -> None:
nonlocal current_time, state
while current_time < target_time - 1e-15:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(
current_time + 0.5 * dt,
_vector_add(state, k1, 0.5 * dt),
)
k3 = rhs(
current_time + 0.5 * dt,
_vector_add(state, k2, 0.5 * dt),
)
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
state = [
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
]
current_time += dt
report_time = current_time
if (
reported_terminal_time is not None
and current_time >= target_time - 1e-15
):
report_time = reported_terminal_time
report_step(report_time)
try:
segment_ends = [*breakpoints, float(config.t_stop)]
for segment_index, segment_end in enumerate(segment_ends):
is_breakpoint = segment_index < len(breakpoints)
integration_end = (
math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end
)
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= integration_end
):
sample_time = float(sample_times[sample_index])
advance_to(sample_time)
_append_solution_sample(times, states, sample_time, state)
sample_index += 1
advance_to(
integration_end,
segment_end if is_breakpoint else None,
)
if is_breakpoint:
current_time = float(segment_end)
report_step(current_time)
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= segment_end
):
sample_time = float(sample_times[sample_index])
_append_solution_sample(times, states, sample_time, state)
sample_index += 1
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
_append_solution_sample(times, states, current_time, state)
return ODESolution(
t=times,
y=states,
success=status == "completed",
message=message,
status=status,
error=error,
)
def _integrate_scipy_stepwise(
rhs: Callable[[float, list[float]], list[float]],
initial_state: list[float],
@@ -121,6 +280,7 @@ def _integrate_scipy_stepwise(
t_eval: list[float] | None,
cancel_check: CancellationCheck,
accepted_step_callback: AcceptedStepCallback | None,
breakpoints: Sequence[float] = (),
) -> ODESolution:
import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
@@ -141,7 +301,7 @@ def _integrate_scipy_stepwise(
states = [[float(value)] for value in initial_state]
last_accepted_time = float(config.t_start)
last_accepted_state = [float(value) for value in initial_state]
sample_times = list(t_eval or [])
sample_times = [float(time) for time in (t_eval or [])]
sample_index = 0
while (
sample_index < len(sample_times)
@@ -154,96 +314,160 @@ def _integrate_scipy_stepwise(
raise _IntegrationCancelled
return rhs(float(time), [float(value) for value in state])
if cancel_check():
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
solver_options = {
"rtol": config.rtol,
"atol": config.atol,
"max_step": config.max_step,
}
if config.first_step is not None:
solver_options["first_step"] = config.first_step
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
**solver_options,
)
except _IntegrationCancelled:
return ODESolution(
t=times,
y=states,
success=False,
message="Simulation was stopped before integration started.",
status="cancelled",
)
except Exception as exc:
return ODESolution(
t=times,
y=states,
success=False,
message=str(exc),
status="failed",
error=exc,
)
status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval."
error: Exception | None = None
last_reported_step: float | None = None
while solver.status == "running":
def report_step(time: float) -> None:
nonlocal last_reported_step
if accepted_step_callback is None:
return
if last_reported_step is not None and time <= last_reported_step:
return
accepted_step_callback(float(time))
last_reported_step = float(time)
segment_ends = [*breakpoints, float(config.t_stop)]
for segment_index, segment_end in enumerate(segment_ends):
if cancel_check():
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
try:
step_message = solver.step()
except _IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
if solver.status == "failed":
status = "failed"
message = str(step_message or "Integration step failed.")
break
last_accepted_time = float(solver.t)
last_accepted_state = [float(value) for value in solver.y]
if sample_times:
dense_output = solver.dense_output()
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time + 1e-12
):
sample_time = float(sample_times[sample_index])
sample_state = [float(value) for value in dense_output(sample_time)]
_append_solution_sample(times, states, sample_time, sample_state)
sample_index += 1
else:
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
message = (
"Simulation was stopped before integration started."
if segment_index == 0
else "Simulation was stopped before reaching the requested end time."
)
if accepted_step_callback is not None:
accepted_step_callback(last_accepted_time)
break
is_breakpoint = segment_index < len(breakpoints)
integration_end = (
math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end
)
has_integration_interval = integration_end > last_accepted_time
if has_integration_interval:
solver_options = {
"rtol": config.rtol,
"atol": config.atol,
"max_step": config.max_step,
}
if config.first_step is not None:
solver_options["first_step"] = min(
config.first_step,
integration_end - last_accepted_time,
)
try:
solver = solver_type(
cancellable_rhs,
last_accepted_time,
np.asarray(last_accepted_state, dtype=float),
integration_end,
**solver_options,
)
except _IntegrationCancelled:
status = "cancelled"
message = (
"Simulation was stopped before integration started."
if segment_index == 0
else "Simulation was stopped before reaching the requested end time."
)
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
while solver.status == "running":
if cancel_check():
status = "cancelled"
message = (
"Simulation was stopped before reaching the requested end time."
)
break
try:
step_message = solver.step()
except _IntegrationCancelled:
status = "cancelled"
message = (
"Simulation was stopped before reaching the requested end time."
)
break
except Exception as exc:
status = "failed"
message = str(exc)
error = exc
break
if solver.status == "failed":
status = "failed"
message = str(step_message or "Integration step failed.")
break
last_accepted_time = float(solver.t)
last_accepted_state = [float(value) for value in solver.y]
reported_time = (
float(segment_end)
if is_breakpoint and solver.status == "finished"
else last_accepted_time
)
if sample_times:
dense_output = solver.dense_output()
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= last_accepted_time
):
sample_time = float(sample_times[sample_index])
sample_state = [
float(value) for value in dense_output(sample_time)
]
_append_solution_sample(
times,
states,
sample_time,
sample_state,
)
sample_index += 1
else:
_append_solution_sample(
times,
states,
reported_time,
last_accepted_state,
)
report_step(reported_time)
if status != "completed":
break
if is_breakpoint:
# The old equation is integrated only to the representable point just
# left of the event. The continuous state is then lifted to the exact
# event time, where the freshly constructed next solver sees the new
# equation immediately.
last_accepted_time = float(segment_end)
if sample_times:
while (
sample_index < len(sample_times)
and sample_times[sample_index] <= segment_end
):
sample_time = float(sample_times[sample_index])
_append_solution_sample(
times,
states,
sample_time,
last_accepted_state,
)
sample_index += 1
elif not has_integration_interval:
_append_solution_sample(
times,
states,
last_accepted_time,
last_accepted_state,
)
report_step(last_accepted_time)
if status != "completed":
_append_solution_sample(
@@ -270,8 +494,14 @@ def integrate_ode(
t_eval: list[float] | None = None,
cancel_check: CancellationCheck | None = None,
accepted_step_callback: AcceptedStepCallback | None = None,
breakpoints: Sequence[float] | None = None,
):
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
"""Integrate an ODE, optionally restarting at equation discontinuities.
Breakpoints are interpreted as right-continuous equation changes: the old
equation is integrated to the floating-point left limit, then a fresh solver
starts at the exact breakpoint with the unchanged continuous state.
"""
if abs(config.t_stop - config.t_start) <= 1e-15:
return ODESolution(
@@ -281,9 +511,21 @@ def integrate_ode(
message="Skipped integration because t_start equals t_stop.",
)
normalized_breakpoints = _normalize_breakpoints(config, breakpoints)
try:
from scipy.integrate import solve_ivp
except ImportError:
if normalized_breakpoints:
return _runge_kutta_4_segmented(
rhs,
initial_state,
config,
t_eval,
normalized_breakpoints,
cancel_check,
accepted_step_callback,
)
return _runge_kutta_4(
rhs,
initial_state,
@@ -293,14 +535,15 @@ def integrate_ode(
accepted_step_callback,
)
if cancel_check is not None:
if cancel_check is not None or normalized_breakpoints:
return _integrate_scipy_stepwise(
rhs,
initial_state,
config,
t_eval,
cancel_check,
cancel_check or (lambda: False),
accepted_step_callback,
normalized_breakpoints,
)
solve_options = {
+6
View File
@@ -327,6 +327,10 @@ class GenericFluidSystem:
report_progress(0.0, "initializing", force=True)
t_eval = simulation_sample_times(config, sample_step)
signal_event_times = self.signal_resolver.event_times(
config.t_start,
config.t_stop,
)
initial_state = self.consistent_initial_state_vector()
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
@@ -356,6 +360,7 @@ class GenericFluidSystem:
accepted_step_callback=(
report_solver_time if cancel_check is not None else None
),
breakpoints=signal_event_times,
)
if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status
@@ -428,6 +433,7 @@ class GenericFluidSystem:
},
"signal": {
"propagations": self.signal_propagation_count,
"eventTimes": list(signal_event_times),
"last": (
self.signal_resolver.last_diagnostics.as_dict()
if self.signal_resolver.last_diagnostics is not None
+316 -47
View File
@@ -89,6 +89,10 @@ import {
type ParameterColumnLayout,
type ParameterTableRow,
} from "./ParameterTable";
import {
evaluateParameterExpression,
isParameterExpressionValue,
} from "./parameterExpression";
import {
WorkspaceViewTabs,
type WorkspaceView,
@@ -219,7 +223,17 @@ type SimulationNodeData = {
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
type SimulationEdge = Edge;
type SimulationNumericKey = "t_start" | "t_stop" | "step" | "max_step";
type SimulationConfig = {
t_start: ParameterValue;
t_stop: ParameterValue;
step: ParameterValue;
max_step: ParameterValue;
method: string;
};
type ResolvedSimulationConfig = {
t_start: number;
t_stop: number;
step: number;
@@ -255,6 +269,10 @@ type ReactFlowProjectPayload = {
simulation: SimulationConfig;
};
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation"> & {
simulation: ResolvedSimulationConfig;
};
type SimulationResult = {
success: boolean;
status: "completed" | "stopped" | "stalled" | "failed";
@@ -534,7 +552,7 @@ const unitOptions: Record<UnitQuantity, UnitOption[]> = {
],
};
const defaultSimulationConfig: SimulationConfig = {
const defaultSimulationConfig: ResolvedSimulationConfig = {
t_start: 0,
t_stop: 2,
step: 0.1,
@@ -625,6 +643,12 @@ type SimulationStreamEvent =
totalTime?: number;
};
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
t_start: "起始时间",
t_stop: "结束时间",
step: "采样步长",
max_step: "最大积分步长",
};
function physicalPort(
name: string,
@@ -1527,7 +1551,7 @@ type NormalizedSimulationProgress = {
function normalizeSimulationProgressEvent(
event: Extract<SimulationStreamEvent, { event: "progress" }>,
config: SimulationConfig,
config: ResolvedSimulationConfig,
): NormalizedSimulationProgress {
const totalTime = Number.isFinite(event.totalTime)
? Number(event.totalTime)
@@ -2184,7 +2208,7 @@ function DeferredNumberInput({
<input
aria-invalid={invalid}
aria-label={ariaLabel}
inputMode="decimal"
inputMode="text"
onBlur={() => {
editingRef.current = false;
if (cancelCommitRef.current) {
@@ -3013,10 +3037,11 @@ function FlowWorkbench() {
const selectedUnit =
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
const unit = findUnitOption(definition, selectedUnit);
const numericValue = Number(displayValue);
const normalizedDisplayValue = displayValue.trim();
const numericValue = Number(normalizedDisplayValue);
const baseValue =
displayValue.trim() === "" || !Number.isFinite(numericValue)
? displayValue
normalizedDisplayValue === "" || !Number.isFinite(numericValue)
? normalizedDisplayValue
: unit.toBase(numericValue);
updateSelectedNode((node) => ({
@@ -3216,15 +3241,22 @@ function FlowWorkbench() {
]);
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
const normalizedValue = value.trim();
const numericValue = Number(normalizedValue);
setSimulationConfig((current) => ({
...current,
[key]: key === "method" ? value : Number(value),
[key]:
key === "method"
? value
: normalizedValue !== "" && Number.isFinite(numericValue)
? numericValue
: normalizedValue,
}));
};
const generateXml = () => {
try {
const xml = buildSystemXml(buildCurrentProject());
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
changeConsoleMode("normal");
appendConsoleEntry(
"success",
@@ -3239,7 +3271,7 @@ function FlowWorkbench() {
const downloadXml = () => {
try {
const xml = buildSystemXml(buildCurrentProject());
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions);
downloadText(`${safeFilename(projectName)}.xml`, xml);
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
} catch (error) {
@@ -3402,7 +3434,7 @@ function FlowWorkbench() {
const publishSimulationResult = (
result: SimulationResult,
project: ReactFlowProjectPayload,
project: ExecutableProjectPayload,
) => {
const resultStatus = result.status ?? (result.success ? "completed" : "failed");
const sampleCount = Math.min(
@@ -3510,34 +3542,48 @@ function FlowWorkbench() {
const runSimulation = async () => {
const issues = checkModel();
const modelErrors = issues.filter((issue) => issue.severity === "error");
const simulationResolution = resolveSimulationConfig(
simulationConfigRef.current,
);
const progressConfig = simulationResolution.ok
? simulationResolution.value
: defaultSimulationConfig;
if (modelErrors.length > 0) {
changeConsoleMode("normal");
setSimulationProgress({
state: "error",
percent: 0,
message: "模型检查未通过",
startTime: simulationConfigRef.current.t_start,
simulatedTime: simulationConfigRef.current.t_start,
totalTime: simulationConfigRef.current.t_stop,
startTime: progressConfig.t_start,
simulatedTime: progressConfig.t_start,
totalTime: progressConfig.t_stop,
});
return;
}
if (!simulationResolution.ok) {
changeConsoleMode("normal");
appendConsoleEntry("error", simulationResolution.message);
return;
}
const project: ExecutableProjectPayload = {
...buildCurrentProject(),
simulation: simulationResolution.value,
};
changeConsoleMode("normal");
setSimulationProgress({
state: "running",
percent: 0,
message: "正在提交仿真任务",
startTime: simulationConfigRef.current.t_start,
simulatedTime: simulationConfigRef.current.t_start,
totalTime: simulationConfigRef.current.t_stop,
startTime: project.simulation.t_start,
simulatedTime: project.simulation.t_start,
totalTime: project.simulation.t_stop,
});
appendConsoleEntry(
"info",
`开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`,
`开始仿真:${projectNameRef.current || "untitled"},时间范围 ${project.simulation.t_start} - ${project.simulation.t_stop} s`,
);
await runBusyAction("正在编译模型并运行仿真...", async () => {
const project = buildCurrentProject();
const xml = buildSystemXml(project);
const xml = buildSystemXml(project, componentDefinitions);
const simulationId = createSimulationId();
activeSimulationIdRef.current = simulationId;
setSimulationStopPending(false);
@@ -3988,7 +4034,11 @@ function FlowWorkbench() {
const error =
gasReference && !gasReferenceAvailable
? "引用的介质定义不存在"
: parameterValidationMessage(definition, baseValue);
: parameterValidationMessage(
definition,
baseValue,
selectedUnit,
);
let valueControl: ReactNode;
if (mediumDefinitionIndex) {
valueControl = (
@@ -4103,12 +4153,20 @@ function FlowWorkbench() {
definition,
selectedUnit,
),
calculatedValue: parameterCalculatedValueMetadata(
baseValue,
definition,
selectedUnit,
),
};
},
),
]
: [];
const simulationSettingErrors = simulationConfigValidationMessages(
simulationConfig,
);
const simulationSettingRows: ParameterTableRow[] = [
{
id: "simulation-t-start",
@@ -4116,6 +4174,7 @@ function FlowWorkbench() {
value: (
<DeferredNumberInput
ariaLabel="起始时间"
invalid={Boolean(simulationSettingErrors.t_start)}
value={simulationConfig.t_start}
onCommit={(value) => updateSimulationConfig("t_start", value)}
onEditEnd={finishHistoryEdit}
@@ -4127,6 +4186,11 @@ function FlowWorkbench() {
defaultValue: `${defaultSimulationConfig.t_start} s`,
range: "有限数值",
currentUnit: "s",
error: simulationSettingErrors.t_start,
calculatedValue: simulationCalculatedValueMetadata(
simulationConfig.t_start,
"s",
),
},
{
id: "simulation-t-stop",
@@ -4134,6 +4198,7 @@ function FlowWorkbench() {
value: (
<DeferredNumberInput
ariaLabel="结束时间"
invalid={Boolean(simulationSettingErrors.t_stop)}
value={simulationConfig.t_stop}
onCommit={(value) => updateSimulationConfig("t_stop", value)}
onEditEnd={finishHistoryEdit}
@@ -4145,6 +4210,11 @@ function FlowWorkbench() {
defaultValue: `${defaultSimulationConfig.t_stop} s`,
range: "> 起始时间",
currentUnit: "s",
error: simulationSettingErrors.t_stop,
calculatedValue: simulationCalculatedValueMetadata(
simulationConfig.t_stop,
"s",
),
},
{
id: "simulation-step",
@@ -4152,6 +4222,7 @@ function FlowWorkbench() {
value: (
<DeferredNumberInput
ariaLabel="采样步长"
invalid={Boolean(simulationSettingErrors.step)}
value={simulationConfig.step}
onCommit={(value) => updateSimulationConfig("step", value)}
onEditEnd={finishHistoryEdit}
@@ -4163,6 +4234,11 @@ function FlowWorkbench() {
defaultValue: `${defaultSimulationConfig.step} s`,
range: "> 0",
currentUnit: "s",
error: simulationSettingErrors.step,
calculatedValue: simulationCalculatedValueMetadata(
simulationConfig.step,
"s",
),
},
{
id: "simulation-max-step",
@@ -4170,6 +4246,7 @@ function FlowWorkbench() {
value: (
<DeferredNumberInput
ariaLabel="最大积分步长"
invalid={Boolean(simulationSettingErrors.max_step)}
value={simulationConfig.max_step}
onCommit={(value) => updateSimulationConfig("max_step", value)}
onEditEnd={finishHistoryEdit}
@@ -4181,6 +4258,11 @@ function FlowWorkbench() {
defaultValue: `${defaultSimulationConfig.max_step} s`,
range: "> 0",
currentUnit: "s",
error: simulationSettingErrors.max_step,
calculatedValue: simulationCalculatedValueMetadata(
simulationConfig.max_step,
"s",
),
},
{
id: "simulation-method",
@@ -4838,7 +4920,9 @@ function normalizeLoadedNode(
label: node.data.label || definition?.label || node.id,
componentType: node.data.componentType || definition?.type || "component",
modelType: node.data.modelType || definition?.modelType || node.data.componentType,
symbol: node.data.symbol || definition?.symbol || node.data.componentType,
// Catalog symbols are versioned presentation metadata. Prefer the current
// definition so projects saved with an old generic icon are upgraded on load.
symbol: definition?.symbol || node.data.symbol || node.data.componentType,
ports: normalizeLoadedPorts(node.data.ports, definition),
parameters,
parameterUnits,
@@ -5122,17 +5206,95 @@ function parameterCurrentUnitMetadata(
: undefined;
}
type NumericInputResolution =
| { ok: true; value: number; expression: boolean }
| { ok: false; message: string; expression: boolean };
function resolveNumericInput(value: ParameterValue): NumericInputResolution {
if (typeof value === "number") {
return Number.isFinite(value)
? { ok: true, value, expression: false }
: { ok: false, message: "请输入有限数值", expression: false };
}
const normalizedValue = value.trim();
if (!normalizedValue) {
return { ok: false, message: "参数不能为空", expression: false };
}
const numericValue = Number(normalizedValue);
if (Number.isFinite(numericValue)) {
return { ok: true, value: numericValue, expression: false };
}
const evaluated = evaluateParameterExpression(normalizedValue);
return evaluated.ok
? { ok: true, value: evaluated.value, expression: true }
: {
ok: false,
message: `等式无法计算:${evaluated.message}`,
expression: true,
};
}
function resolveParameterValue(
value: ParameterValue,
definition: ParameterDefinition,
selectedUnit: string,
): NumericInputResolution {
const resolved = resolveNumericInput(value);
if (!resolved.ok) {
return resolved;
}
if (resolved.expression && definition.editor) {
return {
ok: false,
message: "该离散参数只能通过下拉栏选择",
expression: true,
};
}
if (!resolved.expression) {
return resolved;
}
const baseValue = findUnitOption(definition, selectedUnit).toBase(
resolved.value,
);
return Number.isFinite(baseValue)
? { ...resolved, value: baseValue }
: {
ok: false,
message: "等式换算到基础单位后不是有限数值",
expression: true,
};
}
function parameterCalculatedValueMetadata(
value: ParameterValue,
definition: ParameterDefinition,
selectedUnit: string,
) {
if (!isParameterExpressionValue(value)) {
return undefined;
}
const resolved = resolveParameterValue(value, definition, selectedUnit);
if (!resolved.ok || !resolved.expression) {
return undefined;
}
const unit = findUnitOption(definition, selectedUnit);
const displayValue = unit.fromBase(resolved.value);
return `${formatEditableNumber(displayValue)}${unit.label ? ` ${unit.label}` : ""}`;
}
function parameterValidationMessage(
definition: ParameterDefinition,
value: ParameterValue,
selectedUnit: string,
) {
if (typeof value === "string" && value.trim() === "") {
return "参数不能为空";
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return "请输入有效数字";
const resolved = resolveParameterValue(value, definition, selectedUnit);
if (!resolved.ok) {
return resolved.message;
}
const numericValue = resolved.value;
if (
definition.options &&
!definition.options.some((option) => option.value === numericValue)
@@ -5153,6 +5315,85 @@ function parameterValidationMessage(
return "";
}
function simulationCalculatedValueMetadata(
value: ParameterValue,
unit: string,
) {
if (!isParameterExpressionValue(value)) {
return undefined;
}
const resolved = resolveNumericInput(value);
if (!resolved.ok || !resolved.expression) {
return undefined;
}
return `${formatEditableNumber(resolved.value)}${unit ? ` ${unit}` : ""}`;
}
function simulationConfigValidationMessages(
simulation: SimulationConfig,
): Partial<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[]) {
const counts = new Map<string, number>();
nodes.forEach((node) => {
@@ -5236,9 +5477,16 @@ function validateModel(
return;
}
Object.entries(definition.parameters).forEach(([key, parameter]) => {
const message = parameterValidationMessage(parameter, node.data.parameters[key] ?? "");
const selectedUnit =
node.data.parameterUnits[key] ?? parameter.unit ?? "";
const message = parameterValidationMessage(
parameter,
node.data.parameters[key] ?? "",
selectedUnit,
);
if (message) {
addIssue("error", `${node.data.label}:${parameter.label}${message}`, {
const messageSeparator = message.startsWith("等式") ? ":" : "";
addIssue("error", `${node.data.label}:${parameter.label}${messageSeparator}${message}`, {
nodeId: node.id,
});
}
@@ -5416,17 +5664,13 @@ function validateModel(
});
});
if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) {
addIssue("error", "仿真起止时间必须是有效数字");
} else if (simulation.t_stop <= simulation.t_start) {
addIssue("error", "仿真结束时间必须大于起始时间");
}
if (!Number.isFinite(simulation.step) || simulation.step <= 0) {
addIssue("error", "采样步长必须大于 0");
}
if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) {
addIssue("error", "最大积分步长必须大于 0");
}
const simulationErrors = simulationConfigValidationMessages(simulation);
(Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => {
const message = simulationErrors[key];
if (message) {
addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`);
}
});
if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) {
addIssue("error", "求解器不受支持");
}
@@ -5548,7 +5792,15 @@ function formatTime(date: Date) {
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
function buildSystemXml(project: ReactFlowProjectPayload) {
function buildSystemXml(
project: ReactFlowProjectPayload,
componentDefinitions: ComponentDefinition[],
) {
const simulationResolution = resolveSimulationConfig(project.simulation);
if (!simulationResolution.ok) {
throw new Error(simulationResolution.message);
}
const resolvedSimulation = simulationResolution.value;
const xmlDocument = document.implementation.createDocument("", "System", null);
const system = xmlDocument.documentElement;
system.setAttribute("name", project.name);
@@ -5557,16 +5809,22 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
system.setAttribute("unitSystem", "SI");
const simulation = xmlDocument.createElement("Simulation");
simulation.setAttribute("tStart", String(project.simulation.t_start));
simulation.setAttribute("tStop", String(project.simulation.t_stop));
simulation.setAttribute("step", String(project.simulation.step));
simulation.setAttribute("maxStep", String(project.simulation.max_step));
simulation.setAttribute("method", project.simulation.method);
simulation.setAttribute("tStart", String(resolvedSimulation.t_start));
simulation.setAttribute("tStop", String(resolvedSimulation.t_stop));
simulation.setAttribute("step", String(resolvedSimulation.step));
simulation.setAttribute("maxStep", String(resolvedSimulation.max_step));
simulation.setAttribute("method", resolvedSimulation.method);
const components = xmlDocument.createElement("Components");
const connections = xmlDocument.createElement("Connections");
system.append(simulation, components, connections);
project.nodes.forEach((node) => {
const definition = componentDefinitions.find(
(candidate) => candidate.type === node.data.componentType,
);
if (!definition) {
throw new Error(`组件 ${node.data.label || node.id} 的类型不受支持`);
}
const component = xmlDocument.createElement("Component");
component.setAttribute("id", node.id);
component.setAttribute("name", node.data.label);
@@ -5590,9 +5848,20 @@ function buildSystemXml(project: ReactFlowProjectPayload) {
component.appendChild(port);
});
Object.entries(node.data.parameters).forEach(([name, value]) => {
const parameterDefinition = definition.parameters[name];
const selectedUnit =
node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? "";
const resolved = parameterDefinition
? resolveParameterValue(value, parameterDefinition, selectedUnit)
: resolveNumericInput(value);
if (!resolved.ok) {
throw new Error(
`组件“${node.data.label || node.id}”的参数“${parameterDefinition?.label ?? name}”:${resolved.message}`,
);
}
const parameter = xmlDocument.createElement("Parameter");
parameter.setAttribute("name", name);
parameter.setAttribute("value", String(value));
parameter.setAttribute("value", formatEditableNumber(resolved.value));
component.appendChild(parameter);
});
components.appendChild(component);
+59 -52
View File
@@ -1,39 +1,43 @@
import type { ReactNode } from "react";
import { AmesimPnpl01Symbol } from "./componentSymbols/boundary";
import {
AmesimF000Symbol,
AmesimForcSymbol,
AmesimLmechn1Symbol,
AmesimLstp00aSymbol,
AmesimMecmas21Symbol,
} from "./componentSymbols/mechanical";
import {
AmesimHeliumMediumSymbol,
AmesimIdealAirMediumSymbol,
} from "./componentSymbols/media";
import {
AmesimPn3Node2Symbol,
AmesimP4Node2Symbol,
AmesimPnl0001Symbol,
AmesimPnl0002Symbol,
AmesimPnl0003Symbol,
AmesimPnl00rSymbol,
AmesimPnor001Symbol,
AmesimPnvo001FixedSymbol,
AmesimPnvo001SignalSymbol,
} from "./componentSymbols/pneumatic";
import { SymbolSvg } from "./componentSymbols/primitives";
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
import type {
ComponentSymbolPresentation,
SymbolDefinition,
} from "./componentSymbols/types";
export type { ComponentSymbolPresentation } from "./componentSymbols/types";
export type ComponentSymbolSurface = "palette" | "canvas";
export type ComponentSymbolPresentation = "framed" | "bare";
type ComponentSymbolProps = {
symbol: string;
surface: ComponentSymbolSurface;
className?: string;
};
type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: () => ReactNode;
};
type SymbolSvgProps = {
children: ReactNode;
className?: string;
};
function SymbolSvg({ children, className }: SymbolSvgProps) {
return (
<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() {
return (
<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 = {
cylinder: { presentation: "framed", render: CylinderSymbol },
tank: { presentation: "framed", render: TankSymbol },
@@ -176,13 +158,38 @@ const symbolRegistry = {
generic: { presentation: "framed", render: GenericSymbol },
signal: { presentation: "framed", render: SignalSymbol },
junction: { presentation: "framed", render: JunctionSymbol },
amesim_pnpl01: { presentation: "framed", render: AmesimPnpl01Symbol },
amesim_pnch023: { presentation: "framed", render: AmesimPnch023Symbol },
amesim_pnch012: { presentation: "framed", render: AmesimPnch012Symbol },
amesim_pnor001: { presentation: "framed", render: AmesimPnor001Symbol },
amesim_pnvo001_fixed: {
presentation: "framed",
render: AmesimPnvo001FixedSymbol,
},
amesim_pnvo001: {
presentation: "framed",
render: AmesimPnvo001SignalSymbol,
},
amesim_pnl00r: { presentation: "framed", render: AmesimPnl00rSymbol },
amesim_pnl0001: { presentation: "framed", render: AmesimPnl0001Symbol },
amesim_pnl0002: { presentation: "framed", render: AmesimPnl0002Symbol },
amesim_pnl0003: { presentation: "framed", render: AmesimPnl0003Symbol },
amesim_pn3node2: { presentation: "framed", render: AmesimPn3Node2Symbol },
amesim_p4node2: { presentation: "framed", render: AmesimP4Node2Symbol },
amesim_step0: { presentation: "framed", render: AmesimStep0Symbol },
amesim_ud00: { presentation: "framed", render: AmesimUd00Symbol },
amesim_f000: { presentation: "framed", render: AmesimF000Symbol },
amesim_forc: { presentation: "framed", render: AmesimForcSymbol },
amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol },
amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol },
amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol },
amesim_ideal_air_medium: {
presentation: "bare",
render: () => <MediumSymbol label="Air" />,
render: AmesimIdealAirMediumSymbol,
},
amesim_helium_medium: {
presentation: "bare",
render: () => <MediumSymbol label="He" />,
render: AmesimHeliumMediumSymbol,
},
} satisfies Record<string, SymbolDefinition>;
+10 -2
View File
@@ -32,6 +32,7 @@ export type ParameterTableRow = {
defaultValue?: string;
range?: string;
currentUnit?: string;
calculatedValue?: string;
};
type ParameterTableProps = {
@@ -144,7 +145,8 @@ function rowHasHelpPreview(row: ParameterTableRow) {
(typeof row.help === "string" && row.help) ||
row.defaultValue ||
row.range ||
row.currentUnit,
row.currentUnit ||
row.calculatedValue,
);
}
@@ -462,10 +464,16 @@ export function ParameterTable({
{previewDescription}
</span>
) : null}
{previewRow.defaultValue ||
{previewRow.calculatedValue ||
previewRow.defaultValue ||
previewRow.range ||
previewRow.currentUnit ? (
<span className="parameter-help-preview-meta">
{previewRow.calculatedValue ? (
<span data-testid="parameter-calculated-value">
计算值:{previewRow.calculatedValue}
</span>
) : null}
{previewRow.defaultValue ? (
<span>默认值:{previewRow.defaultValue}</span>
) : 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>
);
}
+38
View File
@@ -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" />;
}
+233
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
export type ComponentSymbolPresentation = "framed" | "bare";
export type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: () => ReactNode;
};
+458
View File
@@ -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;
}
+36
View File
@@ -964,6 +964,42 @@ textarea {
width: 100%;
height: 100%;
overflow: visible;
shape-rendering: geometricPrecision;
}
.component-symbol-svg[data-symbol-domain="neutral"] {
--component-symbol-stroke: #334155;
--component-symbol-fill: #f8fafc;
--component-symbol-accent: #2563eb;
--component-symbol-muted: #94a3b8;
}
.component-symbol-svg[data-symbol-domain="pneumatic"] {
--component-symbol-stroke: #a12b70;
--component-symbol-fill: #fff8fc;
--component-symbol-accent: #bd2779;
--component-symbol-muted: #d69aba;
}
.component-symbol-svg[data-symbol-domain="mechanical"] {
--component-symbol-stroke: #1f9d42;
--component-symbol-fill: #f4fff6;
--component-symbol-accent: #15803d;
--component-symbol-muted: #86c994;
}
.component-symbol-svg[data-symbol-domain="signal"] {
--component-symbol-stroke: #ef4444;
--component-symbol-fill: #fff8f8;
--component-symbol-accent: #dc2626;
--component-symbol-muted: #f5a3a3;
}
.component-symbol-svg[data-symbol-domain="medium"] {
--component-symbol-stroke: #c026d3;
--component-symbol-fill: #ffffff;
--component-symbol-accent: #a21caf;
--component-symbol-muted: #e879f9;
}
.component-symbol-medium-label {
@@ -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);
}
}
});
+105
View File
@@ -201,3 +201,108 @@ test("参数表列宽可调整并在刷新后保留", async ({ page }) => {
await page.keyboard.press("ArrowRight");
await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120");
});
test("组件参数公式保留原式,并在说明中显示计算值且 XML 只写数值", async ({
page,
}) => {
await page.goto("/");
await addPneumaticTestComponent(page);
const expression = "3.14*10^2/4";
const componentTable = page.getByRole("table", { name: "组件参数" });
const pressureRow = parameterRow(componentTable, "参考压力");
const pressureInput = page.getByLabel("参考压力", { exact: true });
await page.getByLabel("参考压力单位").selectOption("kPa");
await pressureInput.fill(expression);
await pressureInput.press("Enter");
await expect(pressureInput).toHaveValue(expression);
await expect(pressureRow).not.toContainText("78.5");
await pressureRow.hover();
await page.waitForTimeout(600);
const preview = page.locator("body > #parameter-help-preview");
await expect(preview).toBeVisible();
await expect(preview.getByTestId("parameter-calculated-value")).toContainText(
"78.5",
);
await expect(preview.getByTestId("parameter-calculated-value")).toContainText(
"kPa",
);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText(
'<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);
});
+256
View File
@@ -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()
+140
View File
@@ -84,6 +84,126 @@ def amesim_pnvo001_signal_project() -> ReactFlowProjectPayload:
)
def high_pressure_helium_step_project() -> ReactFlowProjectPayload:
chamber_ports = [
physical_port("port_1", "bidirectional", "left"),
physical_port("port_2", "bidirectional", "right"),
]
return ReactFlowProjectPayload(
name="amesim-pnvo001-helium-step-regression",
nodes=[
component_node(
"helium_1",
"amesim_helium_medium",
[],
{"gi": 1.0, "property_model": 0.0},
),
component_node(
"step_1",
"amesim_step0",
[signal_port("out", "output", "right")],
{"initial": 0.0, "final": 1.0, "time": 0.04},
),
component_node(
"high_chamber",
"amesim_pnch023",
chamber_ports,
{
"gi": 1.0,
"cvol": 0.057,
"kth": 0.0,
"sth": 0.1,
"extemp": 293.15,
"p0": 15_300_000.0,
"T0": 293.15,
},
),
component_node(
"low_chamber",
"amesim_pnch023",
chamber_ports,
{
"gi": 1.0,
"cvol": 0.015,
"kth": 1500.0,
"sth": 0.7,
"extemp": 293.15,
"p0": 100_000.0,
"T0": 293.15,
},
),
component_node(
"valve_1",
"amesim_pnvo001",
[
signal_port("res", "input", "left"),
physical_port("port_2", "bidirectional", "left"),
physical_port("port_3", "bidirectional", "right"),
],
{
"gi": 1.0,
"cq": 0.45,
"area0": 7.85e-5,
"Cv": 0.5,
"Kv": 0.4,
"flowset": 1.0,
"opening0": 1.0,
},
),
component_node(
"high_plug",
"amesim_pnpl01",
[physical_port("port_1", "bidirectional", "left")],
{},
),
component_node(
"low_plug",
"amesim_pnpl01",
[physical_port("port_1", "bidirectional", "left")],
{},
),
],
edges=[
signal_edge("signal-1", "step_1", "out", "valve_1", "res"),
physical_edge(
"edge-high-plug",
"high_plug",
"port_1",
"high_chamber",
"port_1",
),
physical_edge(
"edge-high-valve",
"high_chamber",
"port_2",
"valve_1",
"port_2",
),
physical_edge(
"edge-valve-low",
"valve_1",
"port_3",
"low_chamber",
"port_1",
),
physical_edge(
"edge-low-plug",
"low_chamber",
"port_2",
"low_plug",
"port_1",
),
],
simulation={
"t_start": 0.0,
"t_stop": 0.042,
"step": 0.002,
"max_step": 0.002,
"method": "BDF",
},
)
class AmesimPnvo001SignalXmlTests(unittest.TestCase):
def test_signal_project_compiles_with_signal_connection(self) -> None:
network = compile_reactflow_network(amesim_pnvo001_signal_project())
@@ -107,6 +227,26 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase):
self.assertIn("valve_1.xv", result["series"])
self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0)
def test_high_pressure_helium_step_restarts_solver_at_event(self) -> None:
xml = build_reactflow_system_xml(high_pressure_helium_step_project())
result = run_system_xml_simulation(xml)
self.assertTrue(result["success"], result["message"])
self.assertEqual(result["simulatedUntil"], 0.042)
self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04])
times = result["series"]["time"]
before_event = times.index(0.038)
at_event = times.index(0.04)
self.assertEqual(result["series"]["step_1.out.signal"][before_event], 0.0)
self.assertEqual(result["series"]["step_1.out.signal"][at_event], 1.0)
self.assertEqual(result["series"]["valve_1.xv"][before_event], 0.0)
self.assertEqual(result["series"]["valve_1.xv"][at_event], 1.0)
self.assertGreater(
result["series"]["valve_1.port_2.m_flow"][at_event],
0.45,
)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -20,6 +20,8 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertEqual(step.output_at(0.49), 0.2)
self.assertEqual(step.output_at(0.5), 0.8)
self.assertEqual(step.signal_output_values(0.5), {"out": 0.8})
self.assertEqual(step.signal_event_times(0.0, 1.0), (0.5,))
self.assertEqual(step.signal_event_times(0.5, 1.0), ())
def test_ud00_output_interpolates_piecewise_signal(self) -> None:
@@ -38,6 +40,7 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertAlmostEqual(signal.output_at(2.5), 15.0)
self.assertAlmostEqual(signal.output_at(5.0), 35.0)
self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0})
self.assertEqual(signal.signal_event_times(0.0, 5.0), (0.5, 1.5, 3.5))
def test_ud00_can_cycle_active_stages(self) -> None:
signal = AmesimUd00(
@@ -53,6 +56,7 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertAlmostEqual(signal.output_at(0.25), 2.5)
self.assertAlmostEqual(signal.output_at(1.25), 12.5)
self.assertAlmostEqual(signal.output_at(2.25), 2.5)
self.assertEqual(signal.signal_event_times(0.0, 5.0), (1.0, 2.0, 3.0, 4.0))
def test_ud00_registry_rejects_fractional_stage_controls(self) -> None:
with self.assertRaisesRegex(ValueError, "nstages must be an integer"):
@@ -91,6 +95,24 @@ class AmesimSignalComponentTests(unittest.TestCase):
self.assertAlmostEqual(step.out.signal, 0.75)
self.assertAlmostEqual(valve.res.signal, 0.75)
self.assertAlmostEqual(valve.opening, 0.75)
self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,))
def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None:
network = SimulationNetwork("signal-events")
network.add_component(
AmesimStep0("step_2", self.medium, initial=0.0, final=1.0, time=0.2)
)
network.add_component(
AmesimStep0("step_1", self.medium, initial=1.0, final=0.0, time=0.1)
)
network.add_component(
AmesimStep0("step_3", self.medium, initial=0.0, final=1.0, time=0.2)
)
resolver = SignalResolver(network)
self.assertEqual(resolver.event_times(0.0, 0.3), (0.1, 0.2))
self.assertEqual(resolver.event_times(0.2, 0.3), ())
if __name__ == "__main__":
+3
View File
@@ -50,6 +50,9 @@ class ComponentCatalogTests(unittest.TestCase):
self.assertEqual(library["label"], "AMESim 组件库")
self.assertEqual([category["id"] for category in library["categories"]], ["media", "storage", "flow", "junctions", "boundary", "signals", "mechanical"])
self.assertEqual(set(components), {"amesim_ideal_air_medium", "amesim_helium_medium", "amesim_pnpl01", "amesim_step0", "amesim_ud00", "amesim_f000", "amesim_forc", "amesim_mecmas21", "amesim_lstp00a", "amesim_lmechn1", "amesim_pnch023", "amesim_pnch012", "amesim_pnor001", "amesim_pnvo001_fixed", "amesim_pnvo001", "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", "amesim_pn3node2", "amesim_p4node2"})
for model_type, component in components.items():
with self.subTest(model_type=model_type):
self.assertEqual(component["symbol"], model_type)
self.assertEqual(
components["amesim_ideal_air_medium"]["role"],
"amesimGasMediumDefinition",
+119
View File
@@ -1,3 +1,4 @@
import math
import sys
import types
import unittest
@@ -78,6 +79,124 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(result.status, "cancelled")
self.assertEqual(result.t, [0.0])
def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None:
import scipy.integrate
event_time = 0.5
actual_bdf = scipy.integrate.BDF
starts: list[float] = []
bounds: list[float] = []
call_times: list[list[float]] = []
class RecordingBDF(actual_bdf):
def __init__(self, fun, t0, y0, t_bound, **kwargs):
starts.append(float(t0))
bounds.append(float(t_bound))
segment_calls: list[float] = []
call_times.append(segment_calls)
def recording_fun(time, state):
segment_calls.append(float(time))
return fun(time, state)
super().__init__(recording_fun, t0, y0, t_bound, **kwargs)
with patch.object(scipy.integrate, "BDF", RecordingBDF):
result = integrate_ode(
rhs=lambda time, _state: [1.0 if time < event_time else 2.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=0.1,
first_step=0.8,
),
t_eval=[0.0, event_time, event_time, 1.0],
breakpoints=[event_time],
)
self.assertTrue(result.success, result.message)
self.assertEqual(starts, [0.0, event_time])
self.assertEqual(bounds[0], math.nextafter(event_time, -math.inf))
self.assertEqual(bounds[1], 1.0)
self.assertTrue(call_times[0])
self.assertTrue(all(time < event_time for time in call_times[0]))
self.assertTrue(any(time >= event_time for time in call_times[1]))
self.assertEqual(result.t, [0.0, event_time, 1.0])
self.assertAlmostEqual(result.y[0][-1], 1.5, places=5)
def test_segmented_implicit_solvers_merge_samples_and_report_progress(self) -> None:
event_time = 0.4
for method in ("BDF", "Radau"):
with self.subTest(method=method):
callback_times: list[float] = []
result = integrate_ode(
rhs=lambda time, _state: [1.0 if time < event_time else 3.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method=method,
max_step=0.05,
first_step=0.9,
),
t_eval=[0.0, event_time, event_time, 0.7, 1.0],
accepted_step_callback=callback_times.append,
breakpoints=[event_time, event_time],
)
self.assertTrue(result.success, result.message)
self.assertEqual(result.t, [0.0, event_time, 0.7, 1.0])
self.assertAlmostEqual(result.y[0][-1], 2.2, places=5)
self.assertEqual(callback_times.count(event_time), 1)
self.assertTrue(
all(
earlier < later
for earlier, later in zip(
callback_times,
callback_times[1:],
)
)
)
def test_segmented_solver_can_cancel_after_crossing_a_breakpoint(self) -> None:
callback_times: list[float] = []
cancellation_requested = False
def record_progress(time: float) -> None:
nonlocal cancellation_requested
callback_times.append(time)
cancellation_requested = time >= 0.55
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=0.05,
),
t_eval=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
cancel_check=lambda: cancellation_requested,
accepted_step_callback=record_progress,
breakpoints=[0.4, 0.8],
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertIn(0.4, callback_times)
self.assertGreater(callback_times[-1], 0.4)
self.assertTrue(
all(
earlier < later
for earlier, later in zip(callback_times, callback_times[1:])
)
)
self.assertEqual(result.t, sorted(set(result.t)))
if __name__ == "__main__":
unittest.main()
@@ -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()