整合求解器活动监控与步长回归证据

同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。

纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
This commit is contained in:
lujingze committed 2026-08-19 16:24:31 +00:00
1 parent c19cf77aee
commit e18399c022
46 files changed
+181589 -170

No files matched your search

+208 -4
View File
@@ -35,6 +35,12 @@ CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2"
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_COORDINATE_KERNEL"
)
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS"
)
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS"
)
CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64
@@ -57,6 +63,20 @@ def _causal_coordinate_kernel_environment_enabled() -> bool:
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_sum_assignments_environment_enabled() -> bool:
"""Return whether exact sum-to-zero targets bypass component tuples."""
value = os.getenv(CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_equation_readers_environment_enabled() -> bool:
"""Return whether exact-class scalar residual readers are enabled."""
value = os.getenv(CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
class AlgebraicSolveError(RuntimeError):
def __init__(
self,
@@ -127,6 +147,7 @@ class EffortAnchor:
unknown: AlgebraicUnknown
evaluate: Callable[[], float]
equation_id: str
causal_evaluate: Callable[[], float] | None = None
@dataclass(frozen=True)
@@ -326,6 +347,14 @@ class PressureFlowSolver:
self.residual_tolerance = residual_tolerance
self.max_evaluations = max_evaluations
self.scope_kind = scope_kind
self._causal_direct_sum_assignments_environment_enabled = (
_causal_direct_sum_assignments_environment_enabled()
)
self._causal_direct_sum_flow_assignment_count = 0
self._causal_direct_equation_readers_environment_enabled = (
_causal_direct_equation_readers_environment_enabled()
)
self._causal_direct_effort_anchor_count = 0
self.unknowns = self._build_unknowns()
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
self._unknowns_by_variable = {
@@ -371,6 +400,9 @@ class PressureFlowSolver:
for item in self._component_equation_plan
for equation in item.templates
) + tuple(item.template for item in self._connection_equation_plan)
self._causal_direct_equation_readers = (
self._build_causal_direct_equation_readers()
)
self._effort_groups = {
variable: self._build_effort_equality_groups(variable)
for variable in ("p", "x", "v")
@@ -418,6 +450,11 @@ class PressureFlowSolver:
self._causal_fast_path_eligible,
self._causal_fast_path_fallback_reason,
) = self._build_causal_execution_plan()
self._causal_direct_effort_anchor_count = sum(
assignment.anchor.causal_evaluate is not None
for assignments in self._causal_effort_plan_by_variable.values()
for assignment in assignments
)
self._causal_fast_path_environment_enabled = (
_causal_fast_path_environment_enabled()
)
@@ -599,6 +636,18 @@ class PressureFlowSolver:
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"directSumAssignmentsConfigured": (
self._causal_direct_sum_assignments_environment_enabled
),
"directSumFlowAssignmentCount": (
self._causal_direct_sum_flow_assignment_count
),
"directEquationReadersConfigured": (
self._causal_direct_equation_readers_environment_enabled
),
"directEffortAnchorCount": (
self._causal_direct_effort_anchor_count
),
"compiledEffortUnknownCount": (
self._causal_compiled_effort_unknown_count
),
@@ -850,6 +899,9 @@ class PressureFlowSolver:
] = {}
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
for coordinate_index, assignment in indexed_assignments:
if assignment.anchor.causal_evaluate is not None:
direct_targets.append((coordinate_index, assignment))
continue
equation_index = equation_index_by_id[
assignment.anchor.equation_id
]
@@ -934,10 +986,22 @@ class PressureFlowSolver:
if stage is None:
return False
for coordinate_index, assignment in stage.direct_targets:
workspace[coordinate_index] = (
self._read_effort_anchor(assignment)
- assignment.anchor.evaluate()
evaluate = (
assignment.anchor.causal_evaluate
if assignment.anchor.causal_evaluate is not None
else assignment.anchor.evaluate
)
try:
workspace[coordinate_index] = (
self._read_effort_anchor(assignment)
- evaluate()
)
except MemoryError:
raise
except Exception:
if assignment.anchor.causal_evaluate is None:
raise
return False
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for target in evaluation.targets:
@@ -975,7 +1039,19 @@ class PressureFlowSolver:
return False
for assignment in assignments:
anchor = assignment.anchor
target = anchor.unknown.read() - anchor.evaluate()
evaluate = (
anchor.causal_evaluate
if anchor.causal_evaluate is not None
else anchor.evaluate
)
try:
target = anchor.unknown.read() - evaluate()
except MemoryError:
raise
except Exception:
if anchor.causal_evaluate is None:
raise
return False
if not isfinite(target) or (
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
@@ -1431,6 +1507,9 @@ class PressureFlowSolver:
unknown=unknown,
evaluate=self._equation_value_reader(equation),
equation_id=equation.id,
causal_evaluate=(
self._causal_direct_equation_readers.get(equation.id)
),
)
)
@@ -1851,6 +1930,45 @@ class PressureFlowSolver:
return read_component_equation
def _build_causal_direct_equation_readers(
self,
) -> dict[str, Callable[[], float]]:
"""Compile exact-class scalar residual capabilities, failing closed."""
if not self._causal_direct_equation_readers_environment_enabled:
return {}
readers: dict[str, Callable[[], float]] = {}
for evaluation in self._component_equation_plan:
component = evaluation.component
# A subclass must repeat the declaration after changing equation
# semantics; inherited purity promises are deliberately ignored.
declared = type(component).__dict__.get(
"pressure_flow_equation_value_readers"
)
if not callable(declared):
continue
try:
component_readers = declared(component)
except MemoryError:
raise
except Exception:
continue
if not isinstance(component_readers, Mapping):
continue
template_ids = frozenset(
template.id for template in evaluation.templates
)
if any(
not isinstance(equation_id, str)
or equation_id not in template_ids
or not callable(reader)
for equation_id, reader in component_readers.items()
):
continue
readers.update(component_readers)
return readers
def _connection_flow_target_reader(
self,
equation,
@@ -1870,6 +1988,69 @@ class PressureFlowSolver:
f"Connection flow equation {equation.id} does not contain {unknown.id}."
)
def _sum_to_zero_flow_target_reader(
self,
equation,
unknown: AlgebraicUnknown,
) -> Callable[[], float]:
"""Compile the exact target of a declared flow sum without callbacks."""
if equation.relation != "sumToZero":
raise ValueError(
f"Equation {equation.id} is not a sum-to-zero relation."
)
if len(set(equation.variables)) != len(equation.variables):
raise ValueError(
f"Equation {equation.id} repeats a sum-to-zero variable."
)
equation_unknowns: list[AlgebraicUnknown] = []
for variable in equation.variables:
current = self._unknowns_by_id.get(variable)
if current is None or current.role != "flow":
raise ValueError(
f"Equation {equation.id} has a non-flow variable."
)
equation_unknowns.append(current)
if not any(current is unknown for current in equation_unknowns):
raise ValueError(
f"Equation {equation.id} does not contain {unknown.id}."
)
compiled_unknowns = tuple(equation_unknowns)
if len(compiled_unknowns) == 2:
first, second = compiled_unknowns
return lambda: 0.0 - (first.read() + second.read())
def target() -> float:
# The target unknown is already zeroed by the stage executor. Keep
# its slot in the sum so the floating-point operation order matches
# the declared built-in residual, including near cancellation.
return 0.0 - sum(current.read() for current in compiled_unknowns)
return target
@staticmethod
def _component_declares_exact_sum_to_zero_equation(
component: object,
equation_id: str,
) -> bool:
"""Accept only an exact concrete-class promise for callback bypass."""
declared_suffixes = type(component).__dict__.get(
"PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES"
)
if not isinstance(declared_suffixes, frozenset) or any(
not isinstance(suffix, str) or not suffix or ":" in suffix
for suffix in declared_suffixes
):
return False
component_name = getattr(component, "name", None)
if not isinstance(component_name, str):
return False
return any(
equation_id == f"{component_name}:{suffix}"
for suffix in declared_suffixes
)
def _pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Evaluate live equation values through the compiled topology."""
@@ -1918,6 +2099,29 @@ class PressureFlowSolver:
evaluation = self._component_equation_plans_by_id[equation.owner_id]
component = evaluation.component
if (
self._causal_direct_sum_assignments_environment_enabled
and equation.relation == "sumToZero"
and self._component_declares_exact_sum_to_zero_equation(
component,
equation.id,
)
):
try:
evaluate = self._sum_to_zero_flow_target_reader(
equation,
unknown,
)
except ValueError:
pass
else:
self._causal_direct_sum_flow_assignment_count += 1
return ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=evaluate,
)
equations = evaluation.templates
equation_ids = tuple(current.id for current in equations)
try:
+22 -11
View File
@@ -378,13 +378,7 @@ class CausalNumericIR:
workspace.effort_written[output] = True
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
except Exception as exc:
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
if any(not bool(workspace.effort_written[index]) for index in range(width)):
return failed("effortEvaluationCoverageMismatch")
@@ -530,6 +524,7 @@ def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
def _compile_effort_evaluations(
operations: tuple[CausalIREffortOperation, ...],
anchor_evaluators: tuple[Callable[[], float], ...],
direct_residuals: tuple[bool, ...],
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
],
@@ -538,10 +533,14 @@ def _compile_effort_evaluations(
grouped: dict[int, list[tuple[int, int, str]]] = {}
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
direct: list[tuple[int, Callable[[], float], str]] = []
for output, (operation, anchor_evaluate) in enumerate(
zip(operations, anchor_evaluators)
for output, (operation, anchor_evaluate, direct_residual) in enumerate(
zip(operations, anchor_evaluators, direct_residuals)
):
location = component_locations.get(operation.equation_id)
location = (
None
if direct_residual
else component_locations.get(operation.equation_id)
)
if location is None:
direct.append((output, anchor_evaluate, operation.equation_id))
continue
@@ -638,6 +637,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
for variable in ("p", "x", "v"):
operations: list[CausalIREffortOperation] = []
anchors: list[Callable[[], float]] = []
direct_residuals: list[bool] = []
for assignment in effort_plan[variable]:
result = len(canonical_slots)
equation_id = str(assignment.anchor.equation_id)
@@ -665,7 +665,17 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
equation_id,
)
)
anchors.append(assignment.anchor.evaluate)
causal_evaluate = getattr(
assignment.anchor,
"causal_evaluate",
None,
)
anchors.append(
causal_evaluate
if causal_evaluate is not None
else assignment.anchor.evaluate
)
direct_residuals.append(causal_evaluate is not None)
operation_tuple = tuple(operations)
effort_stages.append(
CausalIREffortStage(
@@ -674,6 +684,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
_compile_effort_evaluations(
operation_tuple,
tuple(anchors),
tuple(direct_residuals),
component_locations,
evaluators,
),
+387 -15
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
import os
from typing import Callable, Literal, Mapping, Sequence
from app.simulation.components.amesim.mechanical.translational import (
AmesimLstp00a,
AmesimMecmas21,
)
from app.simulation.core.base import DynamicComponent
@@ -12,15 +15,114 @@ from app.simulation.systems.network import SimulationNetwork
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"]
DenseState = Callable[[float], Sequence[float]]
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE = (
"SIMULATION_MECHANICAL_ATOL_MODE"
)
def _requested_mechanical_absolute_tolerance_mode(
) -> MechanicalAbsoluteToleranceMode:
value = os.environ.get(
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE,
"legacy",
).strip().lower()
if value == "legacy":
return "legacy"
if value in {"contact-aware-v1", "contact_aware_v1", "contact-aware"}:
return "contact-aware-v1"
raise ValueError(
f"{MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE} must be "
"'legacy' or 'contact-aware-v1'."
)
@dataclass(frozen=True)
class MechanicalToleranceGroupPlan:
"""One rigid-coordinate group's state tolerances and proof result."""
components: tuple[str, ...]
contacts: tuple[str, ...]
eligible: bool
reason: str
velocity_atol: float
position_atol: float
minimum_dvel: float | None
minimum_contact_damping_length: float | None
minimum_damping_strength_ratio: float | None
minimum_force_limited_velocity_atol: float | None
def as_dict(self) -> dict[str, object]:
return {
"components": list(self.components),
"contacts": list(self.contacts),
"eligible": self.eligible,
"reason": self.reason,
"velocityAtol": self.velocity_atol,
"positionAtol": self.position_atol,
"minimumDvel": self.minimum_dvel,
"minimumContactDampingLength": (
self.minimum_contact_damping_length
),
"minimumDampingStrengthRatio": (
self.minimum_damping_strength_ratio
),
"minimumForceLimitedVelocityAtol": (
self.minimum_force_limited_velocity_atol
),
}
@dataclass(frozen=True)
class MechanicalAbsoluteTolerancePlan:
"""State-aligned absolute tolerances with auditable group proofs."""
mode: MechanicalAbsoluteToleranceMode
default_atol: float
legacy_mechanical_atol: float
values: tuple[float, ...]
groups: tuple[MechanicalToleranceGroupPlan, ...]
def as_dict(self) -> dict[str, object]:
legacy_value = min(
self.default_atol,
self.legacy_mechanical_atol,
)
relaxed_groups = tuple(
group
for group in self.groups
if group.velocity_atol > legacy_value
)
return {
"mode": self.mode,
"defaultAtol": self.default_atol,
"legacyMechanicalAtol": self.legacy_mechanical_atol,
"stateCount": len(self.values),
"groupCount": len(self.groups),
"eligibleGroupCount": sum(group.eligible for group in self.groups),
"relaxedVelocityGroupCount": len(relaxed_groups),
"relaxedVelocityStateCount": len(relaxed_groups),
"relaxedPositionStateCount": 0,
"minimumEffectiveAtol": (
min(self.values) if self.values else None
),
"maximumEffectiveAtol": (
max(self.values) if self.values else None
),
"groups": [group.as_dict() for group in self.groups],
}
@dataclass
class MechanicalConstraintGroup:
"""MECMAS21 inertias that share one rigid translational coordinate."""
components: tuple[AmesimMecmas21, ...]
mode: ConstraintMode = "uninitialized"
contact_components: tuple[AmesimLstp00a, ...] = ()
@property
def representative(self) -> AmesimMecmas21:
@@ -361,9 +463,52 @@ class MechanicalStateReducer:
roots = (find("x", first_port), find("v", first_port))
masses_by_roots.setdefault(roots, []).append(component)
contacts_by_roots: dict[
tuple[tuple[str, str], tuple[str, str]],
dict[str, AmesimLstp00a],
] = {}
for component in self.network.components.values():
if not isinstance(component, AmesimLstp00a):
continue
contact_roots = tuple(
(
find("x", (component.name, definition.name)),
find("v", (component.name, definition.name)),
)
for definition in component.active_port_definitions
if (
definition.kind == "physical"
and definition.domain == "mechanical"
)
)
if (
len({roots[0] for roots in contact_roots}) < 2
or len({roots[1] for roots in contact_roots}) < 2
):
# A compliant contact whose two ports resolve to the same
# rigid coordinate cannot damp that coordinate. Treating the
# self-loop as proof would relax an unrelated velocity state.
continue
for definition in component.active_port_definitions:
if (
definition.kind != "physical"
or definition.domain != "mechanical"
):
continue
endpoint = (component.name, definition.name)
roots = (find("x", endpoint), find("v", endpoint))
contacts_by_roots.setdefault(roots, {})[
component.name
] = component
return tuple(
MechanicalConstraintGroup(tuple(components))
for components in masses_by_roots.values()
MechanicalConstraintGroup(
components=tuple(components),
contact_components=tuple(
contacts_by_roots.get(roots, {}).values()
),
)
for roots, components in masses_by_roots.items()
)
def _build_state_entries(self) -> tuple[StateEntry, ...]:
@@ -391,26 +536,253 @@ class MechanicalStateReducer:
def has_state_events(self) -> bool:
return any(group.discrete_endstop_components for group in self.groups)
def absolute_tolerance_plan(
self,
default: float,
*,
mechanical: float = 1.0e-12,
mode: MechanicalAbsoluteToleranceMode | None = None,
) -> MechanicalAbsoluteTolerancePlan:
"""Compile state tolerances without weakening non-smooth coordinates.
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
endstop position across the much smaller unilateral boundary band while
constructing finite-difference Jacobians. Positions and ideal endstop
states therefore retain the legacy machine-scale tolerance.
Strongly damped, compliant LSTP contact can instead drive a *free*
velocity close to zero for hundreds of accepted steps. Only a
compile-proven smooth-contact group may use the bounded velocity floor;
the contact position coordinate remains unchanged.
"""
default_atol = float(default)
mechanical_atol = float(mechanical)
if not isfinite(default_atol) or default_atol <= 0.0:
raise ValueError(
"default absolute tolerance must be finite and positive."
)
if not isfinite(mechanical_atol) or mechanical_atol <= 0.0:
raise ValueError(
"mechanical absolute tolerance must be finite and positive."
)
selected_mode = mode or _requested_mechanical_absolute_tolerance_mode()
if selected_mode not in {"legacy", "contact-aware-v1"}:
raise ValueError(
"mechanical absolute tolerance mode must be 'legacy' or "
"'contact-aware-v1'."
)
legacy_atol = min(default_atol, mechanical_atol)
values: list[float] = []
group_plans: list[MechanicalToleranceGroupPlan] = []
for entry in self.state_entries:
if not isinstance(entry, MechanicalConstraintGroup):
values.extend([default_atol] * entry.state_size)
continue
components = entry.components
contacts = entry.contact_components
positive_dvel = tuple(
float(component.dvel)
for component in components
if isfinite(float(component.dvel))
and float(component.dvel) > 0.0
)
positive_pdis = tuple(
float(contact.Pdis)
for contact in contacts
if isfinite(float(contact.Pdis))
and float(contact.Pdis) > 0.0
)
minimum_dvel = min(positive_dvel, default=None)
minimum_pdis = min(positive_pdis, default=None)
contact_scale_valid = True
contact_force_velocity_limits_list: list[float] = []
damping_strength_ratios_list: list[float] = []
if selected_mode == "contact-aware-v1" and minimum_dvel is not None:
for contact in contacts:
stiffness = float(contact.kcont)
damping_length = float(contact.Pdis)
damping = float(contact.rcont)
if not (
isfinite(stiffness)
and stiffness > 0.0
and isfinite(damping_length)
and damping_length > 0.0
and isfinite(damping)
and damping > 0.0
):
contact_scale_valid = False
continue
elastic_force_scale = stiffness * damping_length
damping_force_scale = damping * minimum_dvel
if not (
isfinite(elastic_force_scale)
and elastic_force_scale > 0.0
and isfinite(damping_force_scale)
and damping_force_scale > 0.0
):
contact_scale_valid = False
continue
force_velocity_limit = (
1.0e-3 * elastic_force_scale / damping
)
damping_strength_ratio = (
damping_force_scale / elastic_force_scale
)
if not (
isfinite(force_velocity_limit)
and force_velocity_limit > 0.0
and isfinite(damping_strength_ratio)
and damping_strength_ratio > 0.0
):
contact_scale_valid = False
continue
contact_force_velocity_limits_list.append(
force_velocity_limit
)
damping_strength_ratios_list.append(
damping_strength_ratio
)
contact_force_velocity_limits = tuple(
contact_force_velocity_limits_list
)
minimum_force_velocity_atol = min(
contact_force_velocity_limits,
default=None,
)
damping_strength_ratios = tuple(
damping_strength_ratios_list
)
minimum_damping_strength_ratio = min(
damping_strength_ratios,
default=None,
)
if selected_mode == "legacy":
eligible = False
reason = "legacyMode"
elif entry.discrete_endstop_components:
eligible = False
reason = "discreteEndstop"
elif any(int(component.stoptype) != 4 for component in components):
eligible = False
reason = "unsupportedStopType"
elif any(
component.use_friction and float(component.fcoul) != 0.0
for component in components
):
eligible = False
reason = "dryFriction"
elif not contacts:
eligible = False
reason = "noFlexibleContact"
elif any(
not isfinite(float(contact.Pdis))
or float(contact.Pdis) <= 0.0
for contact in contacts
):
eligible = False
reason = "nonSmoothContactDampingLength"
elif any(
not isfinite(float(contact.rcont))
or float(contact.rcont) <= 0.0
for contact in contacts
):
eligible = False
reason = "undampedContact"
elif any(
not isfinite(float(contact.kcont))
or float(contact.kcont) <= 0.0
for contact in contacts
):
eligible = False
reason = "invalidContactStiffness"
elif not contact_scale_valid:
eligible = False
reason = "invalidContactScale"
elif any(
int(contact.discContactOption) != 1
for contact in contacts
):
eligible = False
reason = "clampedContactForce"
elif len(positive_dvel) != len(components):
eligible = False
reason = "invalidVelocityScale"
elif (
len(damping_strength_ratios) != len(contacts)
or minimum_damping_strength_ratio is None
or minimum_damping_strength_ratio < 1.0
):
eligible = False
reason = "weakContactDamping"
else:
eligible = True
reason = "eligibleFlexibleContact"
velocity_atol = legacy_atol
if eligible:
assert minimum_dvel is not None
assert minimum_force_velocity_atol is not None
velocity_atol = min(
default_atol,
max(
mechanical_atol,
min(
1.0e-9,
1.0e-3 * minimum_dvel,
minimum_force_velocity_atol,
),
),
)
position_atol = legacy_atol
values.extend((velocity_atol, position_atol))
group_plans.append(
MechanicalToleranceGroupPlan(
components=tuple(
component.name for component in components
),
contacts=tuple(contact.name for contact in contacts),
eligible=eligible,
reason=reason,
velocity_atol=velocity_atol,
position_atol=position_atol,
minimum_dvel=minimum_dvel,
minimum_contact_damping_length=minimum_pdis,
minimum_damping_strength_ratio=(
minimum_damping_strength_ratio
),
minimum_force_limited_velocity_atol=(
minimum_force_velocity_atol
),
)
)
return MechanicalAbsoluteTolerancePlan(
mode=selected_mode,
default_atol=default_atol,
legacy_mechanical_atol=mechanical_atol,
values=tuple(values),
groups=tuple(group_plans),
)
def absolute_tolerances(
self,
default: float,
*,
mechanical: float = 1.0e-12,
mode: MechanicalAbsoluteToleranceMode | None = None,
) -> list[float]:
"""Return state-aligned tolerances with machine-scale mechanics.
"""Return state-aligned values from the auditable tolerance plan."""
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
endstop position across the much smaller unilateral boundary band while
constructing finite-difference Jacobians. Mechanical coordinates need
a tighter floor; thermodynamic states retain the caller's tolerance.
"""
values: list[float] = []
for entry in self.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
values.extend([min(default, mechanical)] * 2)
else:
values.extend([default] * entry.state_size)
return values
return list(
self.absolute_tolerance_plan(
default,
mechanical=mechanical,
mode=mode,
).values
)
def reset_constraint_modes(self) -> None:
for group in self.groups:
+208 -13
View File
@@ -15,6 +15,135 @@ DenseState = Callable[[float], list[float]]
JacobianCallable = Callable[[float, object], object]
@dataclass(frozen=True)
class SolverActivitySnapshot:
"""Low-cost, additive view of work inside an integration task.
``accepted_time`` deliberately changes only after an accepted solver step.
Trial evaluations may continue to advance ``activity_sequence`` and
``current_trial_time`` while that public progress value stays fixed.
"""
activity_sequence: int
activity_kind: str
current_trial_time: float | None
rhs_call_count: int
accepted_step_sequence: int
accepted_time: float | None
solver_step_sequence: int
jacobian_evaluation_count: int
thermofluid_closure_count: int
def as_dict(self) -> dict[str, object]:
return {
"activitySequence": self.activity_sequence,
"activityKind": self.activity_kind,
"currentTrialTime": self.current_trial_time,
"rhsCallCount": self.rhs_call_count,
"acceptedStepSequence": self.accepted_step_sequence,
"acceptedTime": self.accepted_time,
"solverStepSequence": self.solver_step_sequence,
"jacobianEvaluationCount": self.jacobian_evaluation_count,
"thermofluidClosureCount": self.thermofluid_closure_count,
}
class SolverActivityTracker:
"""Single-writer activity telemetry for a solver worker.
The solver thread is the only writer and the stream thread only snapshots
scalar attributes. The sequence is published last, so a reader never
treats partially published fields as a newer completed activity update.
Passing no tracker to :func:`integrate_ode` is the zero-cost opt-out path.
"""
__slots__ = (
"_accepted_step_sequence",
"_accepted_time",
"_activity_kind",
"_activity_sequence",
"_current_trial_time",
"_jacobian_evaluation_count",
"_rhs_call_count",
"_solver_step_sequence",
"_thermofluid_closure_count",
)
def __init__(self) -> None:
self._activity_sequence = 0
self._activity_kind = "idle"
self._current_trial_time: float | None = None
self._rhs_call_count = 0
self._accepted_step_sequence = 0
self._accepted_time: float | None = None
self._solver_step_sequence = 0
self._jacobian_evaluation_count = 0
self._thermofluid_closure_count = 0
def _publish(self, kind: str, time: float | None = None) -> None:
self._activity_kind = kind
if time is not None:
self._current_trial_time = float(time)
self._activity_sequence += 1
def start_integration(self, time: float) -> None:
self._accepted_time = float(time)
self._publish("solver_initialization", time)
def record_phase(self, kind: str, time: float | None = None) -> None:
self._publish(kind, time)
def record_solver_step(self, time: float) -> None:
self._solver_step_sequence += 1
self._publish("solver_step", time)
def record_rhs(self, time: float) -> None:
self._rhs_call_count += 1
self._publish("rhs", time)
def record_jacobian(self, time: float) -> None:
self._jacobian_evaluation_count += 1
self._publish("jacobian", time)
def record_thermofluid_closure(self, time: float) -> None:
self._thermofluid_closure_count += 1
self._publish("thermofluid_closure", time)
def record_accepted_step(self, time: float) -> None:
accepted_time = float(time)
if (
self._accepted_time is not None
and accepted_time <= self._accepted_time
):
return
self._accepted_step_sequence += 1
self._accepted_time = accepted_time
self._publish("accepted_step", accepted_time)
def snapshot(self) -> SolverActivitySnapshot:
# ``activity_sequence`` is read last because writers publish it last.
activity_kind = self._activity_kind
current_trial_time = self._current_trial_time
rhs_call_count = self._rhs_call_count
accepted_step_sequence = self._accepted_step_sequence
accepted_time = self._accepted_time
solver_step_sequence = self._solver_step_sequence
jacobian_evaluation_count = self._jacobian_evaluation_count
thermofluid_closure_count = self._thermofluid_closure_count
activity_sequence = self._activity_sequence
return SolverActivitySnapshot(
activity_sequence=activity_sequence,
activity_kind=activity_kind,
current_trial_time=current_trial_time,
rhs_call_count=rhs_call_count,
accepted_step_sequence=accepted_step_sequence,
accepted_time=accepted_time,
solver_step_sequence=solver_step_sequence,
jacobian_evaluation_count=jacobian_evaluation_count,
thermofluid_closure_count=thermofluid_closure_count,
)
@dataclass(frozen=True)
class StateTransition:
"""A state reset located inside an accepted integration step."""
@@ -773,6 +902,7 @@ def _integrate_scipy_stepwise(
state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None,
jac: JacobianCallable | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets.
@@ -796,6 +926,18 @@ def _integrate_scipy_stepwise(
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solver_jac = implicit_jac
if implicit_jac is not None and activity_tracker is not None:
original_jacobian = implicit_jac
def activity_jacobian(time, state):
activity_tracker.record_jacobian(float(time))
try:
return original_jacobian(time, state)
finally:
activity_tracker.record_phase("solver_step", float(time))
solver_jac = activity_jacobian
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
@@ -886,8 +1028,8 @@ def _integrate_scipy_stepwise(
"max_step": segment_max_step,
}
if config.method in {"BDF", "Radau"}:
if implicit_jac is not None:
solver_options["jac"] = implicit_jac
if solver_jac is not None:
solver_options["jac"] = solver_jac
elif jac_sparsity is not None:
solver_options["jac_sparsity"] = jac_sparsity
requested_first_step = (
@@ -909,6 +1051,11 @@ def _integrate_scipy_stepwise(
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
if activity_tracker is not None:
activity_tracker.record_phase(
"solver_initialization",
last_accepted_time,
)
solver = solver_type(
cancellable_rhs,
last_accepted_time,
@@ -980,6 +1127,10 @@ def _integrate_scipy_stepwise(
integration_end - last_accepted_time
),
)
if activity_tracker is not None:
activity_tracker.record_solver_step(
last_accepted_time
)
step_message = solver.step()
except IntegrationCancelled:
status = "cancelled"
@@ -1365,6 +1516,7 @@ def integrate_ode(
jac_sparsity=None,
jac: JacobianCallable | None = None,
recoverable_trial_retries: bool = False,
activity_tracker: SolverActivityTracker | None = None,
):
"""Integrate an ODE, optionally restarting at equation discontinuities.
@@ -1381,8 +1533,34 @@ def integrate_ode(
into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the
solver from its last accepted state. It defaults to false to preserve the
direct ``solve_ivp`` path for ordinary callers.
``activity_tracker`` is optional and additive. When omitted, the numerical
call path and callback behavior are unchanged.
"""
integration_rhs = rhs
integration_accepted_step_callback = accepted_step_callback
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
original_rhs = rhs
def activity_rhs(time, state):
numeric_time = float(time)
activity_tracker.record_rhs(numeric_time)
try:
return original_rhs(time, state)
finally:
activity_tracker.record_phase("solver_step", numeric_time)
integration_rhs = activity_rhs
def activity_accepted_step(time: float) -> None:
activity_tracker.record_accepted_step(float(time))
if accepted_step_callback is not None:
accepted_step_callback(float(time))
integration_accepted_step_callback = activity_accepted_step
if (
state_transition_handler is not None
and config.t_stop < config.t_start
@@ -1406,22 +1584,22 @@ def integrate_ode(
except ImportError:
if normalized_breakpoints:
return _runge_kutta_4_segmented(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
normalized_breakpoints,
cancel_check,
accepted_step_callback,
integration_accepted_step_callback,
state_transition_handler,
)
return _runge_kutta_4(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
integration_accepted_step_callback,
state_transition_handler,
)
@@ -1432,25 +1610,26 @@ def integrate_ode(
or recoverable_trial_retries
):
return _integrate_scipy_stepwise(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
cancel_check or (lambda: False),
accepted_step_callback,
integration_accepted_step_callback,
normalized_breakpoints,
state_transition_handler,
jac_sparsity,
jac,
activity_tracker,
)
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solve_rhs = rhs
solve_rhs = integration_rhs
if implicit_jac is not None:
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
def observed_rhs(time, state):
derivative = rhs(time, state)
derivative = integration_rhs(time, state)
observer(float(time), state, derivative)
return derivative
@@ -1459,6 +1638,19 @@ def integrate_ode(
if start_segment is not None:
start_segment()
solve_jac = implicit_jac
if implicit_jac is not None and activity_tracker is not None:
original_jacobian = implicit_jac
def activity_jacobian(time, state):
activity_tracker.record_jacobian(float(time))
try:
return original_jacobian(time, state)
finally:
activity_tracker.record_phase("solver_step", float(time))
solve_jac = activity_jacobian
solve_options = {
"fun": solve_rhs,
"t_span": (config.t_start, config.t_stop),
@@ -1471,8 +1663,11 @@ def integrate_ode(
}
if config.first_step is not None:
solve_options["first_step"] = config.first_step
if implicit_jac is not None:
solve_options["jac"] = implicit_jac
if solve_jac is not None:
solve_options["jac"] = solve_jac
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
solve_options["jac_sparsity"] = jac_sparsity
return solve_ivp(**solve_options)
direct_solution = solve_ivp(**solve_options)
if activity_tracker is not None and len(direct_solution.t):
activity_tracker.record_accepted_step(float(direct_solution.t[-1]))
return direct_solution