完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
+477
-1
@@ -203,6 +203,82 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0]["max_step"], 1.0e-3)
|
||||
self.assertNotIn("first_step", calls[0])
|
||||
|
||||
def test_recoverable_trial_retries_default_keeps_direct_scipy_route(
|
||||
self,
|
||||
) -> None:
|
||||
import scipy.integrate
|
||||
|
||||
expected = object()
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"solve_ivp",
|
||||
return_value=expected,
|
||||
) as direct_solve:
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, state: state,
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_start=0.0, t_stop=1.0),
|
||||
)
|
||||
|
||||
self.assertIs(result, expected)
|
||||
direct_solve.assert_called_once()
|
||||
|
||||
def test_recoverable_stepwise_route_matches_direct_scipy_without_failures(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
|
||||
sample_times = [0.0, 0.025, 0.05, 0.075, 0.1]
|
||||
|
||||
def rhs(time, state):
|
||||
return [-2.0 * float(state[0]) + math.sin(float(time))]
|
||||
|
||||
for method in ("RK45", "BDF"):
|
||||
with self.subTest(method=method):
|
||||
config = SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=0.1,
|
||||
method=method,
|
||||
rtol=1.0e-9,
|
||||
atol=1.0e-12,
|
||||
max_step=0.01,
|
||||
)
|
||||
direct = integrate_ode(
|
||||
rhs=rhs,
|
||||
initial_state=[1.0],
|
||||
config=config,
|
||||
t_eval=sample_times,
|
||||
)
|
||||
stepwise = integrate_ode(
|
||||
rhs=rhs,
|
||||
initial_state=[1.0],
|
||||
config=config,
|
||||
t_eval=sample_times,
|
||||
recoverable_trial_retries=True,
|
||||
)
|
||||
|
||||
self.assertTrue(direct.success, direct.message)
|
||||
self.assertTrue(stepwise.success, stepwise.message)
|
||||
np.testing.assert_array_equal(direct.t, stepwise.t)
|
||||
np.testing.assert_allclose(
|
||||
direct.y,
|
||||
stepwise.y,
|
||||
rtol=1.0e-12,
|
||||
atol=1.0e-14,
|
||||
)
|
||||
self.assertEqual(
|
||||
int(direct.nfev),
|
||||
sum(segment.nfev for segment in stepwise.solver_segments),
|
||||
)
|
||||
self.assertEqual(
|
||||
int(getattr(direct, "njev", 0)),
|
||||
sum(segment.njev for segment in stepwise.solver_segments),
|
||||
)
|
||||
self.assertEqual(
|
||||
int(getattr(direct, "nlu", 0)),
|
||||
sum(segment.nlu for segment in stepwise.solver_segments),
|
||||
)
|
||||
|
||||
def test_stepwise_solver_rebuilds_after_recoverable_trial_failure(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
@@ -241,7 +317,7 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
max_step=1.0,
|
||||
),
|
||||
t_eval=[0.0, 1.0],
|
||||
cancel_check=lambda: False,
|
||||
recoverable_trial_retries=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
@@ -249,6 +325,392 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertEqual(result.t, [0.0, 1.0])
|
||||
self.assertEqual(result.y, [[1.0, 1.0]])
|
||||
|
||||
def test_stepwise_solver_retries_recoverable_constructor_failure(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class ConstructorRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
attempts.append(dict(kwargs))
|
||||
if float(kwargs["max_step"]) > 0.25:
|
||||
raise RecoverableTrialStateError("constructor trial failed")
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", ConstructorRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(
|
||||
[attempt["max_step"] for attempt in attempts],
|
||||
[1.0, 0.5, 0.25],
|
||||
)
|
||||
self.assertEqual(
|
||||
[attempt.get("first_step") for attempt in attempts],
|
||||
[None, 0.5, 0.25],
|
||||
)
|
||||
segment = result.solver_segments[0]
|
||||
self.assertEqual(segment.recoverable_retry_count, 2)
|
||||
self.assertEqual(
|
||||
[retry.phase for retry in segment.recoverable_retries],
|
||||
["constructor", "constructor"],
|
||||
)
|
||||
self.assertEqual(
|
||||
segment.as_dict()["recoverableRetries"][0],
|
||||
{
|
||||
"phase": "constructor",
|
||||
"attemptedStep": 1.0,
|
||||
"reason": "constructor trial failed",
|
||||
"nextMaxStep": 0.5,
|
||||
"nextFirstStep": 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
def test_stepwise_retry_uses_solver_actual_step_not_segment_maximum(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class ActualStepRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.04, float(kwargs["max_step"]))
|
||||
self.step_size = min(0.03, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("small actual trial failed")
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", ActualStepRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(attempts[1]["max_step"], 0.02)
|
||||
self.assertEqual(attempts[1]["first_step"], 0.02)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.phase, "step")
|
||||
self.assertEqual(retry.attempted_step, 0.04)
|
||||
self.assertEqual(retry.next_max_step, 0.02)
|
||||
|
||||
def test_stepwise_retry_falls_back_to_previous_step_size_without_h_abs(
|
||||
self,
|
||||
) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
for invalid_h_abs in (None, math.nan):
|
||||
with self.subTest(h_abs=invalid_h_abs):
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class StepSizeFallbackBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = invalid_h_abs
|
||||
self.step_size = 0.06
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError(
|
||||
"trial without valid h_abs"
|
||||
)
|
||||
self.t = self.t_bound
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
state = self.y.copy()
|
||||
return lambda _time: state.copy()
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
StepSizeFallbackBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(attempts[1]["max_step"], 0.03)
|
||||
self.assertEqual(attempts[1]["first_step"], 0.03)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.attempted_step, 0.06)
|
||||
self.assertEqual(retry.next_max_step, 0.03)
|
||||
|
||||
def test_accepted_step_clears_recoverable_retry_state(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
caps_after_accepted_retry: list[float] = []
|
||||
|
||||
class FailureAfterAcceptedRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.2, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
self.step_count = 0
|
||||
|
||||
def step(self):
|
||||
self.step_count += 1
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("retry once")
|
||||
if self.step_count == 1:
|
||||
self.t = 0.25
|
||||
return None
|
||||
caps_after_accepted_retry.append(float(self.max_step))
|
||||
self.status = "failed"
|
||||
return "ordinary failure after accepted step"
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
FailureAfterAcceptedRetryBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.message, "ordinary failure after accepted step")
|
||||
self.assertEqual(len(attempts), 2)
|
||||
self.assertEqual(caps_after_accepted_retry, [1.0])
|
||||
self.assertEqual(result.solver_segments[0].accepted_step_count, 1)
|
||||
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 1)
|
||||
|
||||
def test_transition_restart_does_not_inherit_retry_first_step(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
transition_pending = True
|
||||
|
||||
class TransitionAfterRetryBdf:
|
||||
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
|
||||
self.attempt_index = len(attempts)
|
||||
attempts.append(dict(kwargs))
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.t_bound = float(t_bound)
|
||||
self.h_abs = min(0.2, float(kwargs["max_step"]))
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
if self.attempt_index == 0:
|
||||
raise RecoverableTrialStateError("retry before transition")
|
||||
self.t = self.t_bound
|
||||
self.y = np.asarray([self.t], dtype=float)
|
||||
self.status = "finished"
|
||||
return None
|
||||
|
||||
def dense_output(self):
|
||||
return lambda time: np.asarray([float(time)], dtype=float)
|
||||
|
||||
def transition_handler(
|
||||
previous_time,
|
||||
_previous_state,
|
||||
current_time,
|
||||
_current_state,
|
||||
_dense_state,
|
||||
):
|
||||
nonlocal transition_pending
|
||||
if transition_pending and previous_time <= 0.25 <= current_time:
|
||||
transition_pending = False
|
||||
return StateTransition(time=0.25, state=[0.25])
|
||||
return None
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", TransitionAfterRetryBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [1.0],
|
||||
initial_state=[0.0],
|
||||
config=SolveIVPConfig(
|
||||
t_start=0.0,
|
||||
t_stop=1.0,
|
||||
method="BDF",
|
||||
max_step=1.0,
|
||||
),
|
||||
state_transition_handler=transition_handler,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success, result.message)
|
||||
self.assertEqual(len(attempts), 3)
|
||||
self.assertNotIn("first_step", attempts[0])
|
||||
self.assertEqual(attempts[1]["first_step"], 0.1)
|
||||
self.assertEqual(attempts[1]["max_step"], 0.1)
|
||||
self.assertNotIn("first_step", attempts[2])
|
||||
self.assertEqual(attempts[2]["max_step"], 1.0)
|
||||
|
||||
def test_recoverable_retry_stops_at_minimum_step(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
minimum_step = 64.0 * math.ulp(1.0)
|
||||
attempts = 0
|
||||
|
||||
class MinimumStepBdf:
|
||||
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.h_abs = 2.0 * minimum_step
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
raise RecoverableTrialStateError("minimum step reached")
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", MinimumStepBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.message, "minimum step reached")
|
||||
self.assertEqual(attempts, 1)
|
||||
retry = result.solver_segments[0].recoverable_retries[0]
|
||||
self.assertEqual(retry.attempted_step, 2.0 * minimum_step)
|
||||
self.assertIsNone(retry.next_max_step)
|
||||
|
||||
def test_recoverable_retry_has_sixteen_retry_limit(self) -> None:
|
||||
import scipy.integrate
|
||||
|
||||
attempts: list[dict[str, object]] = []
|
||||
|
||||
class AlwaysFailingConstructorBdf:
|
||||
def __init__(self, _fun, _t0, _y0, _t_bound, **kwargs):
|
||||
attempts.append(dict(kwargs))
|
||||
raise RecoverableTrialStateError("persistent trial failure")
|
||||
|
||||
with patch.object(
|
||||
scipy.integrate,
|
||||
"BDF",
|
||||
AlwaysFailingConstructorBdf,
|
||||
):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(len(attempts), 17)
|
||||
diagnostics = result.solver_segments[0].recoverable_retries
|
||||
self.assertEqual(len(diagnostics), 17)
|
||||
self.assertEqual(
|
||||
sum(retry.next_max_step is not None for retry in diagnostics),
|
||||
16,
|
||||
)
|
||||
self.assertIsNone(diagnostics[-1].next_max_step)
|
||||
|
||||
def test_recoverable_retry_does_not_swallow_cancellation(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
|
||||
attempts = 0
|
||||
|
||||
class CancellingBdf:
|
||||
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
self.t = float(t0)
|
||||
self.y = np.asarray(y0, dtype=float)
|
||||
self.status = "running"
|
||||
|
||||
def step(self):
|
||||
raise IntegrationCancelled
|
||||
|
||||
with patch.object(scipy.integrate, "BDF", CancellingBdf):
|
||||
result = integrate_ode(
|
||||
rhs=lambda _time, _state: [0.0],
|
||||
initial_state=[1.0],
|
||||
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
|
||||
cancel_check=lambda: False,
|
||||
)
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.status, "cancelled")
|
||||
self.assertEqual(attempts, 1)
|
||||
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 0)
|
||||
|
||||
def test_stepwise_solver_does_not_retry_ordinary_model_errors(self) -> None:
|
||||
import numpy as np
|
||||
import scipy.integrate
|
||||
@@ -743,6 +1205,20 @@ class IntegrateOdeTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(result.y[0][1], 0.0, places=12)
|
||||
self.assertAlmostEqual(result.y[0][2], 0.05, places=8)
|
||||
self.assertAlmostEqual(result.y[0][-1], 0.65, places=8)
|
||||
transition_segments = [
|
||||
segment
|
||||
for segment in result.solver_segments
|
||||
if segment.state_transition_count
|
||||
]
|
||||
self.assertEqual(len(transition_segments), 1)
|
||||
self.assertEqual(
|
||||
transition_segments[0].state_transition_times,
|
||||
(event_time,),
|
||||
)
|
||||
self.assertEqual(
|
||||
transition_segments[0].as_dict()["stateTransitionTimes"],
|
||||
[event_time],
|
||||
)
|
||||
|
||||
def test_state_transitions_chain_at_same_time_until_state_repeats(self) -> None:
|
||||
event_time = 0.25
|
||||
|
||||
Reference in new issue
Block a user