完善通用求解器回归与前端交互

- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
lujingze committed 2026-08-18 06:42:07 +00:00
1 parent 143e8dd309
commit b435daecf2
65 files changed
+172271 -701

No files matched your search

+245 -24
View File
@@ -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,