优化求解器重试并校正AMESim机械端口

This commit is contained in:
huojiarong committed 2026-08-03 09:54:46 +00:00
1 parent 971e8f2336
commit 18d9802f03
15 files changed
+383 -166

No files matched your search

+6
View File
@@ -1217,6 +1217,12 @@ def compile_reactflow_network(
media_by_component_id[node.id], media_by_component_id[node.id],
parameter_values, parameter_values,
) )
apply_layout_transform = getattr(component, "apply_layout_transform", None)
if apply_layout_transform is not None:
apply_layout_transform(
rotation=node.data.rotation,
mirrored=node.data.mirrored,
)
validate_component_port_interface(node, component.port_definitions) validate_component_port_interface(node, component.port_definitions)
network.add_component(component) network.add_component(component)
@@ -93,8 +93,8 @@ class AmesimPnrp17(AlgebraicComponent):
ports=( ports=(
PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_3", "left", order=20), PortDisplaySpec("port_3", "left", order=20),
PortDisplaySpec("port_4", "left", order=30), PortDisplaySpec("port_2", "left", order=30),
PortDisplaySpec("port_2", "right", order=40), PortDisplaySpec("port_4", "right", order=40),
PortDisplaySpec("port_5", "right", order=50), PortDisplaySpec("port_5", "right", order=50),
), ),
order=60, order=60,
@@ -87,6 +87,7 @@ class AmesimForc(AlgebraicComponent):
self.set_parameter_values({}) self.set_parameter_values({})
self.res = self.register_declared_port("res") self.res = self.register_declared_port("res")
self.port_2 = self.register_declared_port("port_2") self.port_2 = self.register_declared_port("port_2")
self._orientation_sign = 1.0
@classmethod @classmethod
def create( def create(
@@ -98,6 +99,15 @@ class AmesimForc(AlgebraicComponent):
) -> "AmesimForc": ) -> "AmesimForc":
return cls(name=name) return cls(name=name)
def apply_layout_transform(self, *, rotation: int, mirrored: bool) -> None:
"""Apply the AMESim icon direction to the signed force output."""
normalized_rotation = int(rotation) % 360
if normalized_rotation not in {0, 90, 180, 270}:
raise ValueError("FORC rotation must be a multiple of 90 degrees.")
direction = -1.0 if normalized_rotation in {180, 270} else 1.0
self._orientation_sign = -direction if mirrored else direction
@property @property
def output_force(self) -> float: def output_force(self) -> float:
return float(self.res.signal) return float(self.res.signal)
@@ -111,7 +121,7 @@ class AmesimForc(AlgebraicComponent):
relation="constitutive", relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"), variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
role="flow", role="flow",
value=self.port_2.f + self.output_force, value=self.port_2.f + self._orientation_sign * self.output_force,
), ),
) )
@@ -170,8 +180,8 @@ class AmesimMecmas21(DynamicComponent):
category_id="mechanical", category_id="mechanical",
symbol="amesim_mecmas21", symbol="amesim_mecmas21",
ports=( ports=(
PortDisplaySpec("port_1", "left", order=10), PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_2", "right", order=20), PortDisplaySpec("port_1", "right", order=20),
), ),
order=30, order=30,
) )
@@ -426,11 +436,11 @@ class AmesimLstp00a(AlgebraicComponent):
assert self._causal_port_2_x is not None assert self._causal_port_2_x is not None
penetration = ( penetration = (
self._causal_penetration self._causal_penetration
+ (self.port_2.x - self._causal_port_2_x) + (self.port_1.x - self._causal_port_1_x)
- (self.port_1.x - self._causal_port_1_x) - (self.port_2.x - self._causal_port_2_x)
) )
return -penetration return -penetration
return self.gap0 - (self.port_2.x - self.port_1.x) return self.gap0 + (self.port_2.x - self.port_1.x)
@property @property
def penetration(self) -> float: def penetration(self) -> float:
@@ -438,7 +448,7 @@ class AmesimLstp00a(AlgebraicComponent):
@property @property
def penetration_velocity(self) -> float: def penetration_velocity(self) -> float:
return self.port_2.v - self.port_1.v return self.port_1.v - self.port_2.v
@property @property
def contact_force(self) -> float: def contact_force(self) -> float:
@@ -516,7 +526,7 @@ class AmesimLstp00a(AlgebraicComponent):
f"{self.name}.port_2.v", f"{self.name}.port_2.v",
), ),
role="flow", role="flow",
value=self.port_1.f + force, value=self.port_1.f - force,
), ),
EquationResidual( EquationResidual(
id=f"{self.name}:port_2_contact_force", id=f"{self.name}:port_2_contact_force",
@@ -531,7 +541,7 @@ class AmesimLstp00a(AlgebraicComponent):
f"{self.name}.port_2.v", f"{self.name}.port_2.v",
), ),
role="flow", role="flow",
value=self.port_2.f - force, value=self.port_2.f + force,
), ),
) )
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
class RecoverableTrialStateError(ValueError):
"""A physical-domain failure caused by an integrator trial state."""
+3 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt from math import acos, cos, isfinite, log, pi, sqrt
@@ -69,7 +71,7 @@ class PengRobinsonFluid:
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float: def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature) self._validate_temperature(temperature)
if molar_volume <= self.b_parameter: if molar_volume <= self.b_parameter:
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.") raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature) a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
+137 -116
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from math import expm1, isfinite, log, sqrt from math import expm1, isfinite, log, sqrt
@@ -32,11 +34,22 @@ class AlgebraicUnknown:
setattr(self.state, self.variable, float(value)) setattr(self.state, self.variable, float(value))
@dataclass(frozen=True)
class ExplicitFlowAssignment:
equation_id: str
unknown: AlgebraicUnknown
evaluate: Callable[[], float]
@dataclass(frozen=True)
class EffortAnchor:
unknown: AlgebraicUnknown
evaluate: Callable[[], float]
@dataclass(frozen=True) @dataclass(frozen=True)
class EffortEqualityGroup: class EffortEqualityGroup:
variable: str variable: str
members: tuple[AlgebraicUnknown, ...] members: tuple[AlgebraicUnknown, ...]
anchors: tuple[tuple[AlgebraicUnknown, float], ...] anchors: tuple[EffortAnchor, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -85,6 +98,11 @@ class PressureFlowSolver:
self.max_evaluations = max_evaluations self.max_evaluations = max_evaluations
self.unknowns = self._build_unknowns() self.unknowns = self._build_unknowns()
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns} self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
self._effort_groups = {
variable: self._build_effort_equality_groups(variable)
for variable in ("p", "x", "v")
}
self._explicit_flow_plan = self._build_explicit_flow_plan()
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]: def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
@@ -132,7 +150,7 @@ class PressureFlowSolver:
for variable in ("p", "x", "v"): for variable in ("p", "x", "v"):
self._seed_equal_effort(variable) self._seed_equal_effort(variable)
def _effort_equality_groups( def _build_effort_equality_groups(
self, self,
variable: str, variable: str,
) -> tuple[EffortEqualityGroup, ...]: ) -> tuple[EffortEqualityGroup, ...]:
@@ -195,10 +213,7 @@ class PressureFlowSolver:
effort_unknowns[endpoint] effort_unknowns[endpoint]
) )
anchors_by_root: dict[ anchors_by_root: dict[tuple[str, str], list[EffortAnchor]] = {}
tuple[str, str],
list[tuple[AlgebraicUnknown, float]],
] = {}
for equations in component_equations.values(): for equations in component_equations.values():
for equation in equations: for equation in equations:
if equation.relation != "state" or equation.role != "effort": if equation.relation != "state" or equation.role != "effort":
@@ -215,11 +230,11 @@ class PressureFlowSolver:
continue continue
endpoint = endpoints[0] endpoint = endpoints[0]
unknown = effort_unknowns[endpoint] unknown = effort_unknowns[endpoint]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
anchors_by_root.setdefault(find(endpoint), []).append( anchors_by_root.setdefault(find(endpoint), []).append(
(unknown, target_value) EffortAnchor(
unknown=unknown,
evaluate=self._equation_value_reader(equation),
)
) )
return tuple( return tuple(
@@ -232,9 +247,17 @@ class PressureFlowSolver:
) )
def _seed_equal_effort(self, variable: str) -> None: def _seed_equal_effort(self, variable: str) -> None:
for group in self._effort_equality_groups(variable): for group in self._effort_groups[variable]:
members = group.members members = group.members
anchors = group.anchors anchors = tuple(
(anchor.unknown, anchor.unknown.read() - anchor.evaluate())
for anchor in group.anchors
)
anchors = tuple(
(unknown, value)
for unknown, value in anchors
if isfinite(value)
)
if anchors: if anchors:
# Keep each state-owned port current even when an invalid model # Keep each state-owned port current even when an invalid model
# has conflicting anchors in one equality group. # has conflicting anchors in one equality group.
@@ -490,9 +513,9 @@ class PressureFlowSolver:
gap0 = float(getattr(component, "gap0", 0.0)) gap0 = float(getattr(component, "gap0", 0.0))
if binding.algebraic_port == 1: if binding.algebraic_port == 1:
target = component.port_2.x - gap0 - penetration target = component.port_2.x + gap0 + penetration
else: else:
target = component.port_1.x + gap0 + penetration target = component.port_1.x - gap0 - penetration
for unknown in binding.algebraic_group.members: for unknown in binding.algebraic_group.members:
unknown.write(target) unknown.write(target)
component.set_causal_contact( component.set_causal_contact(
@@ -515,7 +538,7 @@ class PressureFlowSolver:
position_groups = { position_groups = {
unknown.id: group unknown.id: group
for group in self._effort_equality_groups("x") for group in self._effort_groups["x"]
for unknown in group.members for unknown in group.members
} }
bindings: list[UnilateralContactBinding] = [] bindings: list[UnilateralContactBinding] = []
@@ -547,7 +570,7 @@ class PressureFlowSolver:
algebraic_group=first_group, algebraic_group=first_group,
neighbor_force=first_neighbor, neighbor_force=first_neighbor,
algebraic_port=1, algebraic_port=1,
force_sign=1.0, force_sign=-1.0,
) )
elif not second_group.anchors and second_neighbor is not None: elif not second_group.anchors and second_neighbor is not None:
binding = UnilateralContactBinding( binding = UnilateralContactBinding(
@@ -555,7 +578,7 @@ class PressureFlowSolver:
algebraic_group=second_group, algebraic_group=second_group,
neighbor_force=second_neighbor, neighbor_force=second_neighbor,
algebraic_port=2, algebraic_port=2,
force_sign=-1.0, force_sign=1.0,
) )
else: else:
# With both coordinates state-owned, penetration is a dynamic # With both coordinates state-owned, penetration is a dynamic
@@ -574,137 +597,135 @@ class PressureFlowSolver:
return tuple(bindings) return tuple(bindings)
def _solve_explicit_flow_unknowns(self) -> set[str]: def _flow_unknowns_for_equation(self, equation) -> tuple[AlgebraicUnknown, ...]:
"""Directly evaluate explicit flow variables before nonlinear closure. return tuple(
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].role == "flow"
)
Component constitutive equations use the normalized residual form def _equation_value_reader(self, equation) -> Callable[[], float]:
``flow_unknown + remainder = 0`` whenever exactly one physical flow if equation.owner == "connection":
variable is present. Solve those relations by substitution first, if len(equation.variables) != 2:
then propagate the known values through component balances and physical raise ValueError(
connectors. This covers pneumatic ``m_flow`` variables as well as f"Connection equation {equation.id} must contain two variables."
mechanical forces ``f`` such as ``FORC`` without asking the nonlinear )
optimizer to discover values many orders of magnitude away from zero. first = self._unknowns_by_id[equation.variables[0]]
second = self._unknowns_by_id[equation.variables[1]]
if equation.relation == "sumToZero":
return lambda: first.read() + second.read()
if equation.relation == "equal":
return lambda: first.read() - second.read()
raise ValueError(
f"Unsupported connection equation relation: {equation.relation}."
)
The remaining coupled equations still go through ``least_squares``; component = self.network.components[equation.owner_id]
these assignments provide both a consistent initial guess and the equation_id = equation.id
nominal magnitudes used to scale that smaller nonlinear problem.
"""
def read_component_equation() -> float:
for current in component.pressure_flow_equation_residuals():
if current.id == equation_id:
return float(current.value)
raise RuntimeError(
f"Compiled algebraic equation disappeared at runtime: {equation_id}."
)
return read_component_equation
def _build_explicit_flow_plan(self) -> tuple[ExplicitFlowAssignment, ...]:
"""Compile the legacy deterministic flow assignment order once."""
assignments: list[ExplicitFlowAssignment] = []
seeded_ids: set[str] = set() seeded_ids: set[str] = set()
# Mechanical reaction balances can contain null-space forces. Reusing def append_assignment(equation, unknown: AlgebraicUnknown) -> None:
# an arbitrary least-squares distribution from the preceding RHS call assignments.append(
# makes contact activation history-dependent, so choose deterministic ExplicitFlowAssignment(
# zero tear values and rebuild the force chain from current signals, equation_id=equation.id,
# states, and pressure loads on every closure. unknown=unknown,
for unknown in self.unknowns: evaluate=self._equation_value_reader(equation),
if unknown.variable == "f": )
unknown.write(0.0) )
seeded_ids.add(unknown.id)
# First evaluate constitutive relations that expose one flow unknown
# with unit coefficient. Other variables in the equation (pressure,
# displacement, velocity, or a signal) have already been refreshed for
# the current state and time by the staged system closure.
for component in self.network.components.values(): for component in self.network.components.values():
for equation in component.pressure_flow_equation_residuals(): for equation in component.pressure_flow_equation_residuals():
if equation.relation != "constitutive" or equation.role != "flow": if equation.relation != "constitutive" or equation.role != "flow":
continue continue
flow_unknowns = [ flow_unknowns = self._flow_unknowns_for_equation(equation)
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].role == "flow"
]
if len(flow_unknowns) != 1: if len(flow_unknowns) != 1:
continue continue
unknown = flow_unknowns[0] unknown = flow_unknowns[0]
if unknown.id in seeded_ids: if unknown.id not in seeded_ids:
continue append_assignment(equation, unknown)
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
# V1/correctness-first implementation: repeatedly solve any balance that equations = self.network.pressure_flow_equation_residuals()
# now has exactly one unknown flow variable left. Rebuilding and
# rescanning the complete residual tuple after every assignment keeps
# propagation deterministic, but costs O(flow unknowns * equations) and
# can dominate long, stiff simulations. A production follow-up should
# compile the assignment/tear order from the static topology once and
# evaluate only each owning component or connection residual here.
while True: while True:
propagated = False propagated = False
for equation in self.network.pressure_flow_equation_residuals(): for equation in equations:
if equation.role != "flow" or equation.relation not in { if equation.role != "flow" or equation.relation not in {
"constitutive", "constitutive",
"sumToZero", "sumToZero",
}: }:
continue continue
flow_unknowns = [ flow_unknowns = self._flow_unknowns_for_equation(equation)
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].role == "flow"
]
if not flow_unknowns: if not flow_unknowns:
continue continue
variable_names = {unknown.variable for unknown in flow_unknowns} if len({unknown.variable for unknown in flow_unknowns}) != 1:
if len(variable_names) != 1:
continue continue
unseeded = [ unseeded = tuple(
unknown for unknown in flow_unknowns if unknown.id not in seeded_ids unknown
] for unknown in flow_unknowns
if unknown.id not in seeded_ids
)
if len(unseeded) != 1: if len(unseeded) != 1:
continue continue
unknown = unseeded[0] append_assignment(equation, unseeded[0])
target_value = unknown.read() - float(equation.value) propagated = True
if not isfinite(target_value): break
if propagated:
continue
for equation in equations:
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue continue
unknown.write(target_value) flow_unknowns = self._flow_unknowns_for_equation(equation)
seeded_ids.add(unknown.id) unseeded = tuple(
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
)
if len(unseeded) <= 1:
continue
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
append_assignment(equation, unseeded[-1])
propagated = True propagated = True
break break
if not propagated:
# Causalize one remaining free flow in an otherwise normalized
# linear balance. This is the algebraic equivalent of choosing
# a tear variable: the other free flows retain their current
# guesses and one dependent flow closes the equation exactly.
# It also gives rank-deficient rigid-body reaction balances a
# deterministic starting point before state reduction supplies
# their common acceleration.
for equation in self.network.pressure_flow_equation_residuals():
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
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].role == "flow"
]
unseeded = [
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
]
if len(unseeded) <= 1:
continue
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
unknown = unseeded[-1]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
propagated = True
break
if not propagated: if not propagated:
break break
return tuple(assignments)
def _solve_explicit_flow_unknowns(self) -> set[str]:
"""Execute the precompiled explicit flow/force causalization plan."""
for unknown in self.unknowns:
if unknown.variable == "f":
unknown.write(0.0)
seeded_ids: set[str] = set()
for assignment in self._explicit_flow_plan:
target_value = assignment.unknown.read() - assignment.evaluate()
if not isfinite(target_value):
continue
assignment.unknown.write(target_value)
seeded_ids.add(assignment.unknown.id)
return seeded_ids return seeded_ids
def _scales(self) -> dict[str, float]: def _scales(self) -> dict[str, float]:
+65 -9
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Literal, Sequence from typing import Callable, Literal, Sequence
@@ -535,11 +537,10 @@ def _integrate_scipy_stepwise(
) -> ODESolution: ) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets. """Initial stepwise integration path for breakpoints and state resets.
Known V1 limitation: an adaptive solver can evaluate a trial state outside Recoverable physical-domain failures from rejected integrator trial states
the algebraic or thermodynamic model domain. Such an RHS exception still restore the last accepted state and rebuild the same solver with a smaller
aborts the run here; recoverable trial failures are not yet restored to the maximum/first step. Structural, algebraic, and ordinary model errors still
last accepted state and retried with a smaller step. This is not specific fail immediately.
to BDF, although implicit Newton/Jacobian probes make it especially visible.
""" """
import numpy as np import numpy as np
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
@@ -609,6 +610,9 @@ def _integrate_scipy_stepwise(
math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end
) )
has_integration_interval = integration_end > last_accepted_time has_integration_interval = integration_end > last_accepted_time
segment_max_step = float(config.max_step)
recoverable_retry_count = 0
last_recoverable_error: RecoverableTrialStateError | None = None
while has_integration_interval and last_accepted_time < integration_end: while has_integration_interval and last_accepted_time < integration_end:
if cancel_check(): if cancel_check():
@@ -619,11 +623,16 @@ def _integrate_scipy_stepwise(
solver_options = { solver_options = {
"rtol": config.rtol, "rtol": config.rtol,
"atol": config.atol, "atol": config.atol,
"max_step": config.max_step, "max_step": segment_max_step,
} }
if config.first_step is not None: requested_first_step = (
0.1 * segment_max_step
if last_recoverable_error is not None
else config.first_step
)
if requested_first_step is not None:
solver_options["first_step"] = min( solver_options["first_step"] = min(
config.first_step, requested_first_step,
integration_end - last_accepted_time, integration_end - last_accepted_time,
) )
@@ -639,6 +648,18 @@ def _integrate_scipy_stepwise(
status = "cancelled" status = "cancelled"
message = cancellation_message() message = cancellation_message()
break break
except RecoverableTrialStateError as exc:
recoverable_retry_count += 1
last_recoverable_error = exc
next_step = 0.5 * segment_max_step
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
if recoverable_retry_count > 16 or next_step <= minimum_step:
status = "failed"
message = str(exc)
error = exc
break
segment_max_step = next_step
continue
except Exception as exc: except Exception as exc:
status = "failed" status = "failed"
message = str(exc) message = str(exc)
@@ -646,6 +667,7 @@ def _integrate_scipy_stepwise(
break break
restart_at_transition = False restart_at_transition = False
restart_after_recoverable = False
while solver.status == "running": while solver.status == "running":
if cancel_check(): if cancel_check():
status = "cancelled" status = "cancelled"
@@ -664,6 +686,22 @@ def _integrate_scipy_stepwise(
"Simulation was stopped before reaching the requested end time." "Simulation was stopped before reaching the requested end time."
) )
break break
except RecoverableTrialStateError as exc:
recoverable_retry_count += 1
last_recoverable_error = exc
attempted_step = segment_max_step
next_step = 0.5 * attempted_step
minimum_step = 64.0 * math.ulp(
max(abs(last_accepted_time), 1.0)
)
if recoverable_retry_count > 16 or next_step <= minimum_step:
status = "failed"
message = str(exc)
error = exc
break
segment_max_step = next_step
restart_after_recoverable = True
break
except Exception as exc: except Exception as exc:
status = "failed" status = "failed"
message = str(exc) message = str(exc)
@@ -672,6 +710,19 @@ def _integrate_scipy_stepwise(
integration_progressed = True integration_progressed = True
if solver.status == "failed": if solver.status == "failed":
if last_recoverable_error is not None:
recoverable_retry_count += 1
next_step = 0.5 * segment_max_step
minimum_step = 64.0 * math.ulp(
max(abs(last_accepted_time), 1.0)
)
if (
recoverable_retry_count <= 16
and next_step > minimum_step
):
segment_max_step = next_step
restart_after_recoverable = True
break
status = "failed" status = "failed"
message = str(step_message or "Integration step failed.") message = str(step_message or "Integration step failed.")
break break
@@ -775,6 +826,7 @@ def _integrate_scipy_stepwise(
last_accepted_time = step_end_time last_accepted_time = step_end_time
last_accepted_state = step_end_state last_accepted_state = step_end_state
recoverable_retry_count = 0
reported_time = ( reported_time = (
float(segment_end) float(segment_end)
if is_breakpoint and solver.status == "finished" if is_breakpoint and solver.status == "finished"
@@ -806,7 +858,11 @@ def _integrate_scipy_stepwise(
) )
report_step(reported_time) report_step(reported_time)
if status != "completed" or not restart_at_transition: if status != "completed":
break
if restart_after_recoverable:
continue
if not restart_at_transition:
break break
if status != "completed": if status != "completed":
+16 -1
View File
@@ -5967,7 +5967,7 @@ function normalizeLoadedPorts(
return definition?.ports.map((port) => ({ ...port })) ?? []; return definition?.ports.map((port) => ({ ...port })) ?? [];
} }
return rawPorts.flatMap((rawPort, index) => { const loadedPorts = rawPorts.flatMap((rawPort, index) => {
const record = const record =
rawPort !== null && typeof rawPort === "object" rawPort !== null && typeof rawPort === "object"
? (rawPort as Record<string, unknown>) ? (rawPort as Record<string, unknown>)
@@ -6004,6 +6004,21 @@ function normalizeLoadedPorts(
}, },
]; ];
}); });
if (!definition) {
return loadedPorts;
}
const loadedByName = new Map(loadedPorts.map((port) => [port.name, port]));
const registeredNames = new Set(definition.ports.map((port) => port.name));
return [
...definition.ports.map((registered) => ({
...(loadedByName.get(registered.name) ?? registered),
// Port placement is versioned catalog metadata, just like the symbol.
// Prefer it so projects saved with an incorrect display order are upgraded.
side: registered.side,
})),
...loadedPorts.filter((port) => !registeredNames.has(port.name)),
];
} }
function isPortNominalRole(value: unknown): value is PortNominalRole { function isPortNominalRole(value: unknown): value is PortNominalRole {
@@ -17,6 +17,12 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.medium = IdealGasMedium() self.medium = IdealGasMedium()
def test_mecmas21_catalog_ports_follow_amesim_icon_sides(self) -> None:
self.assertEqual(
tuple((port.name, port.side) for port in AmesimMecmas21.DISPLAY.ports),
(("port_2", "left"), ("port_1", "right")),
)
def test_f000_constrains_mechanical_port_force_to_zero(self) -> None: def test_f000_constrains_mechanical_port_force_to_zero(self) -> None:
source = AmesimF000("zero_1") source = AmesimF000("zero_1")
source.port_1.f = 12.5 source.port_1.f = 12.5
@@ -38,6 +44,14 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
self.assertAlmostEqual(residuals[0].value, 0.0) self.assertAlmostEqual(residuals[0].value, 0.0)
self.assertEqual(converter.component_result_values(), {"force": 20.0}) self.assertEqual(converter.component_result_values(), {"force": 20.0})
converter.apply_layout_transform(rotation=180, mirrored=False)
converter.port_2.f = 20.0
self.assertAlmostEqual(
converter.pressure_flow_equation_residuals()[0].value,
0.0,
)
self.assertEqual(converter.component_result_values(), {"force": 20.0})
def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None: def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None:
mass = AmesimMecmas21( mass = AmesimMecmas21(
"mass_1", "mass_1",
@@ -96,12 +110,12 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
kcont=1000.0, kcont=1000.0,
rcont=10.0, rcont=10.0,
) )
contact.port_1.x = 0.0 contact.port_1.x = 0.002
contact.port_2.x = 0.002 contact.port_2.x = 0.0
contact.port_1.v = 0.0 contact.port_1.v = 0.1
contact.port_2.v = 0.1 contact.port_2.v = 0.0
contact.port_1.f = -3.0 contact.port_1.f = 3.0
contact.port_2.f = 3.0 contact.port_2.f = -3.0
self.assertAlmostEqual(contact.gap, -0.002) self.assertAlmostEqual(contact.gap, -0.002)
self.assertAlmostEqual(contact.penetration, 0.002) self.assertAlmostEqual(contact.penetration, 0.002)
@@ -116,8 +130,8 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
def test_lstp00a_returns_zero_before_contact(self) -> None: def test_lstp00a_returns_zero_before_contact(self) -> None:
contact = AmesimLstp00a("contact_1", self.medium, gap0=0.001, kcont=1000.0) contact = AmesimLstp00a("contact_1", self.medium, gap0=0.001, kcont=1000.0)
contact.port_1.x = 0.0 contact.port_1.x = 0.0005
contact.port_2.x = 0.0005 contact.port_2.x = 0.0
self.assertAlmostEqual(contact.gap, 0.0005) self.assertAlmostEqual(contact.gap, 0.0005)
self.assertAlmostEqual(contact.contact_force, 0.0) self.assertAlmostEqual(contact.contact_force, 0.0)
+6 -6
View File
@@ -146,9 +146,9 @@ def signal_force_mass_project() -> ReactFlowProjectPayload:
def elastic_contact_project() -> ReactFlowProjectPayload: def elastic_contact_project() -> ReactFlowProjectPayload:
left_parameters = dict(MECMAS21_DEFAULTS) left_parameters = dict(MECMAS21_DEFAULTS)
left_parameters["x0"] = 0.0 left_parameters["x0"] = 0.001
right_parameters = dict(MECMAS21_DEFAULTS) right_parameters = dict(MECMAS21_DEFAULTS)
right_parameters["x0"] = 0.001 right_parameters["x0"] = 0.0
return ReactFlowProjectPayload( return ReactFlowProjectPayload(
name="amesim-mechanical-elastic-contact-smoke", name="amesim-mechanical-elastic-contact-smoke",
nodes=[ nodes=[
@@ -260,10 +260,10 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertTrue(result["success"], result["message"]) self.assertTrue(result["success"], result["message"])
self.assertEqual(result["series"]["time"], [0.0, 0.01, 0.02]) self.assertEqual(result["series"]["time"], [0.0, 0.01, 0.02])
self.assertAlmostEqual(result["series"]["contact_1.force"][0], 1.0) self.assertAlmostEqual(result["series"]["contact_1.force"][0], 1.0)
self.assertAlmostEqual(result["series"]["mass_left.a"][0], 0.5) self.assertAlmostEqual(result["series"]["mass_left.a"][0], -0.5)
self.assertAlmostEqual(result["series"]["mass_right.a"][0], -0.5) self.assertAlmostEqual(result["series"]["mass_right.a"][0], 0.5)
self.assertGreater(result["series"]["mass_left.x"][-1], 0.0) self.assertLess(result["series"]["mass_left.x"][-1], 0.001)
self.assertLess(result["series"]["mass_right.x"][-1], 0.001) self.assertGreater(result["series"]["mass_right.x"][-1], 0.0)
def test_zero_force_mechanical_project_compiles_and_simulates(self) -> None: def test_zero_force_mechanical_project_compiles_and_simulates(self) -> None:
xml = build_reactflow_system_xml(zero_force_mass_project()) xml = build_reactflow_system_xml(zero_force_mass_project())
+14 -2
View File
@@ -79,9 +79,9 @@ def pnrp17_coupled_project() -> ReactFlowProjectPayload:
"amesim_pnrp17", "amesim_pnrp17",
[ [
_pneumatic_port("port_1", "left"), _pneumatic_port("port_1", "left"),
mechanical_port("port_2", "right"),
mechanical_port("port_3", "left"), mechanical_port("port_3", "left"),
mechanical_port("port_4", "left"), mechanical_port("port_2", "left"),
mechanical_port("port_4", "right"),
mechanical_port("port_5", "right"), mechanical_port("port_5", "right"),
], ],
{"gi": 0.0, "dp": 0.1, "dr": 0.02, "x0": 0.0}, {"gi": 0.0, "dp": 0.1, "dr": 0.02, "x0": 0.0},
@@ -131,6 +131,18 @@ def pnrp17_coupled_project() -> ReactFlowProjectPayload:
class AmesimPnrp17Tests(unittest.TestCase): class AmesimPnrp17Tests(unittest.TestCase):
def test_catalog_ports_follow_amesim_pnrp17_icon_sides(self) -> None:
self.assertEqual(
tuple((port.name, port.side) for port in AmesimPnrp17.DISPLAY.ports),
(
("port_1", "left"),
("port_3", "left"),
("port_2", "left"),
("port_4", "right"),
("port_5", "right"),
),
)
def test_component_equations_match_pnrp17_geometry_and_signs(self) -> None: def test_component_equations_match_pnrp17_geometry_and_signs(self) -> None:
medium = IdealGasMedium() medium = IdealGasMedium()
piston = AmesimPnrp17( piston = AmesimPnrp17(
+2 -2
View File
@@ -163,7 +163,7 @@ class ComponentCatalogTests(unittest.TestCase):
for parameter in components["amesim_mecmas21"]["parameters"] for parameter in components["amesim_mecmas21"]["parameters"]
} }
self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical") self.assertEqual(components["amesim_mecmas21"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_1", "port_2"]) self.assertEqual([port["name"] for port in components["amesim_mecmas21"]["ports"]], ["port_2", "port_1"])
self.assertEqual(mecmas_parameters["mass"]["unit"], "kg") self.assertEqual(mecmas_parameters["mass"]["unit"], "kg")
self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m") self.assertEqual(mecmas_parameters["Kbmin"]["unit"], "N/m")
lstp_parameters = { lstp_parameters = {
@@ -185,7 +185,7 @@ class ComponentCatalogTests(unittest.TestCase):
for parameter in components["amesim_pnrp17"]["parameters"] for parameter in components["amesim_pnrp17"]["parameters"]
} }
self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical") self.assertEqual(components["amesim_pnrp17"]["category"]["id"], "mechanical")
self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_4", "port_2", "port_5"]) self.assertEqual([port["name"] for port in components["amesim_pnrp17"]["ports"]], ["port_1", "port_3", "port_2", "port_4", "port_5"])
self.assertEqual(pnrp_parameters["dp"]["unit"], "m") self.assertEqual(pnrp_parameters["dp"]["unit"], "m")
self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0) self.assertEqual(lmechn_parameters["v1"]["maximum"], 8.0)
self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow")
+5 -5
View File
@@ -58,7 +58,7 @@ class _PressureCoupledMechanicalLoad(AlgebraicComponent):
f"{self.name}.pneumatic.p", f"{self.name}.pneumatic.p",
), ),
role="flow", role="flow",
value=self.mechanical.f - self.pneumatic.p, value=self.mechanical.f + self.pneumatic.p,
), ),
EquationResidual( EquationResidual(
id=f"{self.name}:pressure_closure", id=f"{self.name}:pressure_closure",
@@ -163,7 +163,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
self.assertTrue(diagnostics.success, diagnostics.message) self.assertTrue(diagnostics.success, diagnostics.message)
self.assertGreater(diagnostics.evaluations, 0) self.assertGreater(diagnostics.evaluations, 0)
self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3) self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3)
self.assertAlmostEqual(load.mechanical.f, 41.0, delta=1.0e-3) self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3)
self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3) self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3)
self.assertAlmostEqual(contact.penetration, 4.1e-10, delta=1.0e-14) self.assertAlmostEqual(contact.penetration, 4.1e-10, delta=1.0e-14)
@@ -172,7 +172,7 @@ class ContactSolverCausalizationTests(unittest.TestCase):
expected_force = 10.0 - 20.0 * (1.0 - exp(-1.0)) expected_force = 10.0 - 20.0 * (1.0 - exp(-1.0))
load = _PrescribedMechanicalLoad( load = _PrescribedMechanicalLoad(
"load", "load",
force=expected_force, force=-expected_force,
displacement=0.0, displacement=0.0,
velocity=0.0, velocity=0.0,
) )
@@ -191,8 +191,8 @@ class ContactSolverCausalizationTests(unittest.TestCase):
mass=1.0, mass=1.0,
useFriction=0.0, useFriction=0.0,
stoptype=4.0, stoptype=4.0,
x0=0.08, x0=-0.08,
v0=-2.0, v0=2.0,
) )
zero = AmesimF000("zero") zero = AmesimF000("zero")
+76
View File
@@ -4,6 +4,7 @@ import types
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.solvers.solver import ( from app.simulation.solvers.solver import (
SolveIVPConfig, SolveIVPConfig,
StateTransition, StateTransition,
@@ -71,6 +72,81 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(calls[0]["max_step"], 1.0e-3) self.assertEqual(calls[0]["max_step"], 1.0e-3)
self.assertNotIn("first_step", calls[0]) self.assertNotIn("first_step", calls[0])
def test_stepwise_solver_rebuilds_after_recoverable_trial_failure(self) -> None:
import numpy as np
import scipy.integrate
attempted_max_steps: list[float] = []
class RetryBdf:
def __init__(self, fun, t0, y0, t_bound, **kwargs):
self.fun = fun
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.h_abs = float(kwargs["max_step"])
self.status = "running"
attempted_max_steps.append(self.h_abs)
def step(self):
if self.h_abs > 0.25:
raise RecoverableTrialStateError("trial state outside domain")
self.t = self.t_bound
self.status = "finished"
return None
def dense_output(self):
state = self.y.copy()
return lambda _time: state.copy()
with patch.object(scipy.integrate, "BDF", RetryBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
t_eval=[0.0, 1.0],
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(attempted_max_steps, [1.0, 0.5, 0.25])
self.assertEqual(result.t, [0.0, 1.0])
self.assertEqual(result.y, [[1.0, 1.0]])
def test_stepwise_solver_does_not_retry_ordinary_model_errors(self) -> None:
import numpy as np
import scipy.integrate
attempts = 0
class FailingBdf:
def __init__(self, fun, t0, y0, t_bound, **kwargs):
nonlocal attempts
attempts += 1
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.status = "running"
def step(self):
raise ValueError("structural model error")
with patch.object(scipy.integrate, "BDF", FailingBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(t_start=0.0, t_stop=1.0, method="BDF"),
cancel_check=lambda: False,
)
self.assertFalse(result.success)
self.assertEqual(result.message, "structural model error")
self.assertEqual(attempts, 1)
def test_scipy_stepwise_solver_can_cancel_before_start(self) -> None: def test_scipy_stepwise_solver_can_cancel_before_start(self) -> None:
result = integrate_ode( result = integrate_ode(
rhs=lambda _time, state: state, rhs=lambda _time, state: state,
@@ -144,10 +144,10 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
Pdis=0.1, Pdis=0.1,
discContactOption=1.0, discContactOption=1.0,
) )
contact.port_1.x = 0.0 contact.port_1.x = 0.1
contact.port_2.x = 0.1 contact.port_2.x = 0.0
contact.port_1.v = 0.0 contact.port_1.v = -2.0
contact.port_2.v = -2.0 contact.port_2.v = 0.0
expected = 10.0 - 20.0 * (1.0 - exp(-1.0)) expected = 10.0 - 20.0 * (1.0 - exp(-1.0))
self.assertAlmostEqual(contact.contact_force, expected, places=12) self.assertAlmostEqual(contact.contact_force, expected, places=12)
@@ -175,7 +175,7 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
def test_causal_contact_survives_unrelated_nonlinear_fallback(self) -> None: def test_causal_contact_survives_unrelated_nonlinear_fallback(self) -> None:
medium = IdealGasMedium() medium = IdealGasMedium()
source = AmesimForc("contact_force") source = AmesimForc("contact_force")
source.res.signal = -40.0 source.res.signal = 40.0
contact = AmesimLstp00a( contact = AmesimLstp00a(
"contact", "contact",
medium, medium,