优化求解器重试并校正AMESim机械端口
This commit is contained in:
1 parent
971e8f2336
commit
18d9802f03
15 files changed
+383
-166
No files matched your search
+137
-116
@@ -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]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user