完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
@@ -29,6 +29,8 @@ StateTransitionHandler = Callable[
|
||||
]
|
||||
|
||||
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
|
||||
_MAX_RECOVERABLE_RETRIES = 16
|
||||
_RECOVERABLE_RETRY_FACTOR = 0.5
|
||||
|
||||
|
||||
class IntegrationCancelled(Exception):
|
||||
@@ -48,6 +50,29 @@ class SolveIVPConfig:
|
||||
first_step: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoverableRetryDiagnostics:
|
||||
"""One recoverable trial failure and the step cap chosen for its retry."""
|
||||
|
||||
phase: Literal["constructor", "step", "solver-status"]
|
||||
attempted_step: float
|
||||
reason: str
|
||||
next_max_step: float | None = None
|
||||
next_first_step: float | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"phase": self.phase,
|
||||
"attemptedStep": self.attempted_step,
|
||||
"reason": self.reason,
|
||||
}
|
||||
if self.next_max_step is not None:
|
||||
result["nextMaxStep"] = self.next_max_step
|
||||
if self.next_first_step is not None:
|
||||
result["nextFirstStep"] = self.next_first_step
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolverSegmentDiagnostics:
|
||||
"""Work performed by implicit solver instances inside one event segment."""
|
||||
@@ -61,6 +86,7 @@ class SolverSegmentDiagnostics:
|
||||
accepted_step_count: int = 0
|
||||
solver_start_count: int = 0
|
||||
state_transition_count: int = 0
|
||||
state_transition_times: tuple[float, ...] = ()
|
||||
recoverable_retry_count: int = 0
|
||||
jacobian_evaluation_count: int = 0
|
||||
jacobian_full_build_count: int = 0
|
||||
@@ -72,9 +98,10 @@ class SolverSegmentDiagnostics:
|
||||
exact_column_build_count: int = 0
|
||||
exact_column_fallback_count: int = 0
|
||||
jacobian_assembly_seconds: float = 0.0
|
||||
recoverable_retries: tuple[RecoverableRetryDiagnostics, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, float | int]:
|
||||
result: dict[str, float | int] = {
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"startTime": self.start_time,
|
||||
"requestedStopTime": self.requested_stop_time,
|
||||
"simulatedUntil": self.simulated_until,
|
||||
@@ -86,6 +113,14 @@ class SolverSegmentDiagnostics:
|
||||
"stateTransitionCount": self.state_transition_count,
|
||||
"recoverableRetryCount": self.recoverable_retry_count,
|
||||
}
|
||||
if self.state_transition_times:
|
||||
result["stateTransitionTimes"] = list(
|
||||
self.state_transition_times
|
||||
)
|
||||
if self.recoverable_retries:
|
||||
result["recoverableRetries"] = [
|
||||
retry.as_dict() for retry in self.recoverable_retries
|
||||
]
|
||||
if (
|
||||
self.jacobian_evaluation_count
|
||||
or self.finite_difference_rhs_evaluation_count
|
||||
@@ -143,6 +178,80 @@ def _jacobian_diagnostic_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def _positive_finite_step(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
candidate = abs(float(value))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return candidate if candidate > 0.0 and math.isfinite(candidate) else None
|
||||
|
||||
|
||||
def _smallest_positive_finite_step(*values: object) -> float:
|
||||
"""Return a conservative step bound from configuration candidates."""
|
||||
|
||||
candidates = [
|
||||
candidate
|
||||
for value in values
|
||||
if (candidate := _positive_finite_step(value)) is not None
|
||||
]
|
||||
if not candidates:
|
||||
raise ValueError("No positive finite integration step is available.")
|
||||
return min(candidates)
|
||||
|
||||
|
||||
def _solver_attempted_step(
|
||||
solver: object,
|
||||
*,
|
||||
segment_max_step: float,
|
||||
remaining_interval: float,
|
||||
) -> float:
|
||||
"""Snapshot the real trial scale before calling ``solver.step()``.
|
||||
|
||||
SciPy exposes the proposed step as ``h_abs``. ``step_size`` is the prior
|
||||
accepted step, so it is only a fallback for solvers without a valid
|
||||
``h_abs``; it must not reduce an otherwise valid failed-trial estimate.
|
||||
"""
|
||||
|
||||
configured_cap = _smallest_positive_finite_step(
|
||||
segment_max_step,
|
||||
remaining_interval,
|
||||
)
|
||||
for attribute in ("h_abs", "step_size"):
|
||||
try:
|
||||
candidate = _positive_finite_step(
|
||||
getattr(solver, attribute, None)
|
||||
)
|
||||
except Exception:
|
||||
# A third-party OdeSolver may implement these as fragile
|
||||
# properties. The configured cap remains a safe fallback.
|
||||
continue
|
||||
if candidate is not None:
|
||||
return min(candidate, configured_cap)
|
||||
return configured_cap
|
||||
|
||||
|
||||
def _recoverable_retry_steps(
|
||||
attempted_step: float,
|
||||
*,
|
||||
last_accepted_time: float,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Return strictly smaller max/first steps, or None at machine precision."""
|
||||
|
||||
next_step = _RECOVERABLE_RETRY_FACTOR * attempted_step
|
||||
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
|
||||
if (
|
||||
not math.isfinite(next_step)
|
||||
or next_step <= minimum_step
|
||||
or next_step >= attempted_step
|
||||
):
|
||||
return None
|
||||
# This first step is intentionally one-shot. Keeping it equal to the new
|
||||
# cap makes both controls strictly smaller than the failed trial scale.
|
||||
return next_step, next_step
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ODESolution:
|
||||
t: list[float]
|
||||
@@ -751,13 +860,18 @@ def _integrate_scipy_stepwise(
|
||||
segment_max_step = float(config.max_step)
|
||||
recoverable_retry_count = 0
|
||||
last_recoverable_error: RecoverableTrialStateError | None = None
|
||||
retry_first_step: float | None = None
|
||||
segment_nfev = 0
|
||||
segment_njev = 0
|
||||
segment_nlu = 0
|
||||
segment_accepted_steps = 0
|
||||
segment_solver_starts = 0
|
||||
segment_state_transitions = 0
|
||||
segment_state_transition_times: list[float] = []
|
||||
segment_recoverable_retries = 0
|
||||
segment_recoverable_retry_diagnostics: list[
|
||||
RecoverableRetryDiagnostics
|
||||
] = []
|
||||
jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac)
|
||||
|
||||
while has_integration_interval and last_accepted_time < integration_end:
|
||||
@@ -777,8 +891,8 @@ def _integrate_scipy_stepwise(
|
||||
elif jac_sparsity is not None:
|
||||
solver_options["jac_sparsity"] = jac_sparsity
|
||||
requested_first_step = (
|
||||
0.1 * segment_max_step
|
||||
if last_recoverable_error is not None
|
||||
retry_first_step
|
||||
if retry_first_step is not None
|
||||
else config.first_step
|
||||
)
|
||||
if requested_first_step is not None:
|
||||
@@ -786,8 +900,12 @@ def _integrate_scipy_stepwise(
|
||||
requested_first_step,
|
||||
integration_end - last_accepted_time,
|
||||
)
|
||||
|
||||
try:
|
||||
constructor_attempted_step = _smallest_positive_finite_step(
|
||||
segment_max_step,
|
||||
integration_end - last_accepted_time,
|
||||
solver_options.get("first_step"),
|
||||
)
|
||||
start_segment = getattr(implicit_jac, "start_segment", None)
|
||||
if start_segment is not None:
|
||||
start_segment()
|
||||
@@ -806,14 +924,33 @@ def _integrate_scipy_stepwise(
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 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:
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
constructor_attempted_step,
|
||||
last_accepted_time=last_accepted_time,
|
||||
)
|
||||
if recoverable_retry_count <= _MAX_RECOVERABLE_RETRIES
|
||||
else None
|
||||
)
|
||||
segment_recoverable_retry_diagnostics.append(
|
||||
RecoverableRetryDiagnostics(
|
||||
phase="constructor",
|
||||
attempted_step=constructor_attempted_step,
|
||||
reason=str(exc),
|
||||
next_max_step=(
|
||||
retry_steps[0] if retry_steps is not None else None
|
||||
),
|
||||
next_first_step=(
|
||||
retry_steps[1] if retry_steps is not None else None
|
||||
),
|
||||
)
|
||||
)
|
||||
if retry_steps is None:
|
||||
status = "failed"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
segment_max_step = next_step
|
||||
segment_max_step, retry_first_step = retry_steps
|
||||
continue
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
@@ -836,6 +973,13 @@ def _integrate_scipy_stepwise(
|
||||
step_start_time = last_accepted_time
|
||||
step_start_state = list(last_accepted_state)
|
||||
try:
|
||||
attempted_step = _solver_attempted_step(
|
||||
solver,
|
||||
segment_max_step=segment_max_step,
|
||||
remaining_interval=(
|
||||
integration_end - last_accepted_time
|
||||
),
|
||||
)
|
||||
step_message = solver.step()
|
||||
except IntegrationCancelled:
|
||||
status = "cancelled"
|
||||
@@ -847,17 +991,38 @@ def _integrate_scipy_stepwise(
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 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)
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
attempted_step,
|
||||
last_accepted_time=last_accepted_time,
|
||||
)
|
||||
if recoverable_retry_count
|
||||
<= _MAX_RECOVERABLE_RETRIES
|
||||
else None
|
||||
)
|
||||
if recoverable_retry_count > 16 or next_step <= minimum_step:
|
||||
segment_recoverable_retry_diagnostics.append(
|
||||
RecoverableRetryDiagnostics(
|
||||
phase="step",
|
||||
attempted_step=attempted_step,
|
||||
reason=str(exc),
|
||||
next_max_step=(
|
||||
retry_steps[0]
|
||||
if retry_steps is not None
|
||||
else None
|
||||
),
|
||||
next_first_step=(
|
||||
retry_steps[1]
|
||||
if retry_steps is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
if retry_steps is None:
|
||||
status = "failed"
|
||||
message = str(exc)
|
||||
error = exc
|
||||
break
|
||||
segment_max_step = next_step
|
||||
segment_max_step, retry_first_step = retry_steps
|
||||
restart_after_recoverable = True
|
||||
break
|
||||
except Exception as exc:
|
||||
@@ -871,21 +1036,62 @@ def _integrate_scipy_stepwise(
|
||||
if last_recoverable_error is not None:
|
||||
recoverable_retry_count += 1
|
||||
segment_recoverable_retries += 1
|
||||
next_step = 0.5 * segment_max_step
|
||||
minimum_step = 64.0 * math.ulp(
|
||||
max(abs(last_accepted_time), 1.0)
|
||||
retry_steps = (
|
||||
_recoverable_retry_steps(
|
||||
attempted_step,
|
||||
last_accepted_time=last_accepted_time,
|
||||
)
|
||||
if recoverable_retry_count
|
||||
<= _MAX_RECOVERABLE_RETRIES
|
||||
else None
|
||||
)
|
||||
if (
|
||||
recoverable_retry_count <= 16
|
||||
and next_step > minimum_step
|
||||
):
|
||||
segment_max_step = next_step
|
||||
failure_reason = str(
|
||||
step_message or last_recoverable_error
|
||||
)
|
||||
segment_recoverable_retry_diagnostics.append(
|
||||
RecoverableRetryDiagnostics(
|
||||
phase="solver-status",
|
||||
attempted_step=attempted_step,
|
||||
reason=failure_reason,
|
||||
next_max_step=(
|
||||
retry_steps[0]
|
||||
if retry_steps is not None
|
||||
else None
|
||||
),
|
||||
next_first_step=(
|
||||
retry_steps[1]
|
||||
if retry_steps is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
if retry_steps is not None:
|
||||
segment_max_step, retry_first_step = retry_steps
|
||||
restart_after_recoverable = True
|
||||
break
|
||||
status = "failed"
|
||||
message = str(step_message or "Integration step failed.")
|
||||
break
|
||||
|
||||
# A returned running/finished status means this step was
|
||||
# accepted. Any prior recoverable failure is now historical:
|
||||
# it must not influence an event restart or an ordinary later
|
||||
# solver failure. The reduced cap is local to the failed
|
||||
# trial: after one accepted retry step, let this solver grow
|
||||
# adaptively again and ensure a later event restart receives
|
||||
# the configured maximum. The retry-specific first step is
|
||||
# likewise strictly one-shot.
|
||||
if retry_first_step is not None:
|
||||
segment_max_step = float(config.max_step)
|
||||
try:
|
||||
solver.max_step = segment_max_step
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# Third-party OdeSolver-compatible test doubles may not
|
||||
# expose a writable cap. SciPy's supported solvers do.
|
||||
pass
|
||||
last_recoverable_error = None
|
||||
retry_first_step = None
|
||||
recoverable_retry_count = 0
|
||||
segment_accepted_steps += 1
|
||||
step_end_time = float(solver.t)
|
||||
step_end_state = [float(value) for value in solver.y]
|
||||
@@ -940,6 +1146,9 @@ def _integrate_scipy_stepwise(
|
||||
|
||||
if transition is not None:
|
||||
segment_state_transitions += 1
|
||||
segment_state_transition_times.append(
|
||||
float(transition.time)
|
||||
)
|
||||
try:
|
||||
same_time_transition_count = (
|
||||
_next_same_time_transition_count(
|
||||
@@ -997,7 +1206,6 @@ 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"
|
||||
@@ -1057,7 +1265,13 @@ def _integrate_scipy_stepwise(
|
||||
accepted_step_count=segment_accepted_steps,
|
||||
solver_start_count=segment_solver_starts,
|
||||
state_transition_count=segment_state_transitions,
|
||||
state_transition_times=tuple(
|
||||
segment_state_transition_times
|
||||
),
|
||||
recoverable_retry_count=segment_recoverable_retries,
|
||||
recoverable_retries=tuple(
|
||||
segment_recoverable_retry_diagnostics
|
||||
),
|
||||
jacobian_evaluation_count=int(
|
||||
jacobian_work["jacobianEvaluationCount"]
|
||||
),
|
||||
@@ -1150,6 +1364,7 @@ def integrate_ode(
|
||||
state_transition_handler: StateTransitionHandler | None = None,
|
||||
jac_sparsity=None,
|
||||
jac: JacobianCallable | None = None,
|
||||
recoverable_trial_retries: bool = False,
|
||||
):
|
||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
||||
|
||||
@@ -1161,6 +1376,11 @@ def integrate_ode(
|
||||
interpolant. When it returns a transition, samples before the event retain
|
||||
the pre-event trajectory, the reset state is stored at the event, and a fresh
|
||||
solver continues from that state.
|
||||
|
||||
``recoverable_trial_retries`` opts an eventless/cancellation-free caller
|
||||
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.
|
||||
"""
|
||||
|
||||
if (
|
||||
@@ -1209,6 +1429,7 @@ def integrate_ode(
|
||||
cancel_check is not None
|
||||
or normalized_breakpoints
|
||||
or state_transition_handler is not None
|
||||
or recoverable_trial_retries
|
||||
):
|
||||
return _integrate_scipy_stepwise(
|
||||
rhs,
|
||||
|
||||
Reference in new issue
Block a user