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

同步远端 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 -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