优化求解器重试并校正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],
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)
network.add_component(component)
@@ -93,8 +93,8 @@ class AmesimPnrp17(AlgebraicComponent):
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_3", "left", order=20),
PortDisplaySpec("port_4", "left", order=30),
PortDisplaySpec("port_2", "right", order=40),
PortDisplaySpec("port_2", "left", order=30),
PortDisplaySpec("port_4", "right", order=40),
PortDisplaySpec("port_5", "right", order=50),
),
order=60,
@@ -87,6 +87,7 @@ class AmesimForc(AlgebraicComponent):
self.set_parameter_values({})
self.res = self.register_declared_port("res")
self.port_2 = self.register_declared_port("port_2")
self._orientation_sign = 1.0
@classmethod
def create(
@@ -98,6 +99,15 @@ class AmesimForc(AlgebraicComponent):
) -> "AmesimForc":
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
def output_force(self) -> float:
return float(self.res.signal)
@@ -111,7 +121,7 @@ class AmesimForc(AlgebraicComponent):
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
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",
symbol="amesim_mecmas21",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_1", "right", order=20),
),
order=30,
)
@@ -426,11 +436,11 @@ class AmesimLstp00a(AlgebraicComponent):
assert self._causal_port_2_x is not None
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 self.gap0 - (self.port_2.x - self.port_1.x)
return self.gap0 + (self.port_2.x - self.port_1.x)
@property
def penetration(self) -> float:
@@ -438,7 +448,7 @@ class AmesimLstp00a(AlgebraicComponent):
@property
def penetration_velocity(self) -> float:
return self.port_2.v - self.port_1.v
return self.port_1.v - self.port_2.v
@property
def contact_force(self) -> float:
@@ -516,7 +526,7 @@ class AmesimLstp00a(AlgebraicComponent):
f"{self.name}.port_2.v",
),
role="flow",
value=self.port_1.f + force,
value=self.port_1.f - force,
),
EquationResidual(
id=f"{self.name}:port_2_contact_force",
@@ -531,7 +541,7 @@ class AmesimLstp00a(AlgebraicComponent):
f"{self.name}.port_2.v",
),
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 app.simulation.core.errors import RecoverableTrialStateError
from dataclasses import dataclass
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:
self._validate_temperature(temperature)
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)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
+137 -116
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from math import expm1, isfinite, log, sqrt
@@ -32,11 +34,22 @@ class AlgebraicUnknown:
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)
class EffortEqualityGroup:
variable: str
members: tuple[AlgebraicUnknown, ...]
anchors: tuple[tuple[AlgebraicUnknown, float], ...]
anchors: tuple[EffortAnchor, ...]
@dataclass(frozen=True)
@@ -85,6 +98,11 @@ class PressureFlowSolver:
self.max_evaluations = max_evaluations
self.unknowns = self._build_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
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
@@ -132,7 +150,7 @@ class PressureFlowSolver:
for variable in ("p", "x", "v"):
self._seed_equal_effort(variable)
def _effort_equality_groups(
def _build_effort_equality_groups(
self,
variable: str,
) -> tuple[EffortEqualityGroup, ...]:
@@ -195,10 +213,7 @@ class PressureFlowSolver:
effort_unknowns[endpoint]
)
anchors_by_root: dict[
tuple[str, str],
list[tuple[AlgebraicUnknown, float]],
] = {}
anchors_by_root: dict[tuple[str, str], list[EffortAnchor]] = {}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "state" or equation.role != "effort":
@@ -215,11 +230,11 @@ class PressureFlowSolver:
continue
endpoint = endpoints[0]
unknown = effort_unknowns[endpoint]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
anchors_by_root.setdefault(find(endpoint), []).append(
(unknown, target_value)
EffortAnchor(
unknown=unknown,
evaluate=self._equation_value_reader(equation),
)
)
return tuple(
@@ -232,9 +247,17 @@ class PressureFlowSolver:
)
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
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:
# Keep each state-owned port current even when an invalid model
# has conflicting anchors in one equality group.
@@ -490,9 +513,9 @@ class PressureFlowSolver:
gap0 = float(getattr(component, "gap0", 0.0))
if binding.algebraic_port == 1:
target = component.port_2.x - gap0 - penetration
target = component.port_2.x + gap0 + penetration
else:
target = component.port_1.x + gap0 + penetration
target = component.port_1.x - gap0 - penetration
for unknown in binding.algebraic_group.members:
unknown.write(target)
component.set_causal_contact(
@@ -515,7 +538,7 @@ class PressureFlowSolver:
position_groups = {
unknown.id: group
for group in self._effort_equality_groups("x")
for group in self._effort_groups["x"]
for unknown in group.members
}
bindings: list[UnilateralContactBinding] = []
@@ -547,7 +570,7 @@ class PressureFlowSolver:
algebraic_group=first_group,
neighbor_force=first_neighbor,
algebraic_port=1,
force_sign=1.0,
force_sign=-1.0,
)
elif not second_group.anchors and second_neighbor is not None:
binding = UnilateralContactBinding(
@@ -555,7 +578,7 @@ class PressureFlowSolver:
algebraic_group=second_group,
neighbor_force=second_neighbor,
algebraic_port=2,
force_sign=-1.0,
force_sign=1.0,
)
else:
# With both coordinates state-owned, penetration is a dynamic
@@ -574,137 +597,135 @@ class PressureFlowSolver:
return tuple(bindings)
def _solve_explicit_flow_unknowns(self) -> set[str]:
"""Directly evaluate explicit flow variables before nonlinear closure.
def _flow_unknowns_for_equation(self, equation) -> tuple[AlgebraicUnknown, ...]:
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
``flow_unknown + remainder = 0`` whenever exactly one physical flow
variable is present. Solve those relations by substitution first,
then propagate the known values through component balances and physical
connectors. This covers pneumatic ``m_flow`` variables as well as
mechanical forces ``f`` such as ``FORC`` without asking the nonlinear
optimizer to discover values many orders of magnitude away from zero.
def _equation_value_reader(self, equation) -> Callable[[], float]:
if equation.owner == "connection":
if len(equation.variables) != 2:
raise ValueError(
f"Connection equation {equation.id} must contain two variables."
)
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``;
these assignments provide both a consistent initial guess and the
nominal magnitudes used to scale that smaller nonlinear problem.
"""
component = self.network.components[equation.owner_id]
equation_id = equation.id
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()
# Mechanical reaction balances can contain null-space forces. Reusing
# an arbitrary least-squares distribution from the preceding RHS call
# makes contact activation history-dependent, so choose deterministic
# zero tear values and rebuild the force chain from current signals,
# states, and pressure loads on every closure.
for unknown in self.unknowns:
if unknown.variable == "f":
unknown.write(0.0)
def append_assignment(equation, unknown: AlgebraicUnknown) -> None:
assignments.append(
ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=self._equation_value_reader(equation),
)
)
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 equation in component.pressure_flow_equation_residuals():
if equation.relation != "constitutive" or equation.role != "flow":
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"
]
flow_unknowns = self._flow_unknowns_for_equation(equation)
if len(flow_unknowns) != 1:
continue
unknown = flow_unknowns[0]
if unknown.id in seeded_ids:
continue
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
if unknown.id not in seeded_ids:
append_assignment(equation, unknown)
# V1/correctness-first implementation: repeatedly solve any balance that
# 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.
equations = self.network.pressure_flow_equation_residuals()
while True:
propagated = False
for equation in self.network.pressure_flow_equation_residuals():
for equation in equations:
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"
]
flow_unknowns = self._flow_unknowns_for_equation(equation)
if not flow_unknowns:
continue
variable_names = {unknown.variable for unknown in flow_unknowns}
if len(variable_names) != 1:
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
unseeded = [
unknown for unknown in flow_unknowns if unknown.id not in seeded_ids
]
unseeded = tuple(
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
)
if len(unseeded) != 1:
continue
unknown = unseeded[0]
target_value = unknown.read() - float(equation.value)
if not isfinite(target_value):
append_assignment(equation, unseeded[0])
propagated = True
break
if propagated:
continue
for equation in equations:
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
unknown.write(target_value)
seeded_ids.add(unknown.id)
flow_unknowns = self._flow_unknowns_for_equation(equation)
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
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:
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
def _scales(self) -> dict[str, float]:
+65 -9
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from app.simulation.core.errors import RecoverableTrialStateError
import math
from dataclasses import dataclass
from typing import Callable, Literal, Sequence
@@ -535,11 +537,10 @@ def _integrate_scipy_stepwise(
) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets.
Known V1 limitation: an adaptive solver can evaluate a trial state outside
the algebraic or thermodynamic model domain. Such an RHS exception still
aborts the run here; recoverable trial failures are not yet restored to the
last accepted state and retried with a smaller step. This is not specific
to BDF, although implicit Newton/Jacobian probes make it especially visible.
Recoverable physical-domain failures from rejected integrator trial states
restore the last accepted state and rebuild the same solver with a smaller
maximum/first step. Structural, algebraic, and ordinary model errors still
fail immediately.
"""
import numpy as np
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
)
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:
if cancel_check():
@@ -619,11 +623,16 @@ def _integrate_scipy_stepwise(
solver_options = {
"rtol": config.rtol,
"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(
config.first_step,
requested_first_step,
integration_end - last_accepted_time,
)
@@ -639,6 +648,18 @@ def _integrate_scipy_stepwise(
status = "cancelled"
message = cancellation_message()
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:
status = "failed"
message = str(exc)
@@ -646,6 +667,7 @@ def _integrate_scipy_stepwise(
break
restart_at_transition = False
restart_after_recoverable = False
while solver.status == "running":
if cancel_check():
status = "cancelled"
@@ -664,6 +686,22 @@ def _integrate_scipy_stepwise(
"Simulation was stopped before reaching the requested end time."
)
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:
status = "failed"
message = str(exc)
@@ -672,6 +710,19 @@ def _integrate_scipy_stepwise(
integration_progressed = True
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"
message = str(step_message or "Integration step failed.")
break
@@ -775,6 +826,7 @@ def _integrate_scipy_stepwise(
last_accepted_time = step_end_time
last_accepted_state = step_end_state
recoverable_retry_count = 0
reported_time = (
float(segment_end)
if is_breakpoint and solver.status == "finished"
@@ -806,7 +858,11 @@ def _integrate_scipy_stepwise(
)
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
if status != "completed":