Skip to content

Commit ee191eb

Browse files
committed
feat(tds): add adaptive trapezoid with step-doubling LTE and refactor calc_h
Add TrapezoidAdaptive method with Richardson-extrapolation LTE estimation via step-doubling. Extract shared adaptive utilities (weighted_rms_error, accept_reject, check_adaptive_bust) into adaptive.py. Refactor step-size control: move calc_h into each method class via polymorphism, replacing the monolithic if/elif/else chain in TDS.calc_h. Deduplicate niter heuristic into ImplicitIter.niter_next_h. Add requires_variable_step class attribute to replace the removed adaptive flag.
1 parent 1df5af9 commit ee191eb

7 files changed

Lines changed: 679 additions & 87 deletions

File tree

andes/routines/adaptive.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Shared helpers for adaptive integration methods.
3+
"""
4+
5+
import numpy as np
6+
7+
8+
def weighted_rms_error(err_vec, x_prev, x_new, abstol, reltol, err_wt):
9+
"""
10+
Compute weighted RMS norm of an error vector.
11+
12+
Parameters
13+
----------
14+
err_vec : np.ndarray
15+
Error estimate vector.
16+
x_prev : np.ndarray
17+
Previous accepted state.
18+
x_new : np.ndarray
19+
Candidate new state.
20+
abstol : float
21+
Absolute tolerance.
22+
reltol : float
23+
Relative tolerance.
24+
err_wt : np.ndarray
25+
Pre-allocated work array.
26+
"""
27+
np.maximum(np.abs(x_prev), np.abs(x_new), out=err_wt)
28+
err_wt *= reltol
29+
err_wt += abstol
30+
np.divide(err_vec, err_wt, out=err_wt)
31+
return np.sqrt(np.dot(err_wt, err_wt) / len(err_wt))
32+
33+
34+
def propose_step_factor(err_est, order, safety=0.9, min_factor=0.2, max_factor=5.0):
35+
"""
36+
Propose a multiplicative step-size factor from a normalized error estimate.
37+
"""
38+
if err_est <= 0:
39+
return max_factor
40+
41+
factor = safety * err_est ** (-1.0 / (order + 1))
42+
if factor < min_factor:
43+
return min_factor
44+
if factor > max_factor:
45+
return max_factor
46+
return factor
47+
48+
49+
def candidate_h(err_est, h, order, safety=0.9, min_factor=0.2, max_factor=5.0):
50+
"""
51+
Propose a candidate next step size from normalized error.
52+
"""
53+
return h * propose_step_factor(err_est, order,
54+
safety=safety,
55+
min_factor=min_factor,
56+
max_factor=max_factor)
57+
58+
59+
def check_adaptive_bust(tds):
60+
"""
61+
Shared bust-check for adaptive methods (TrapezoidAdaptive, QNDF).
62+
63+
Called after ``step()`` has written ``tds.deltat``. If the step was
64+
rejected and deltat has fallen to the adaptive minimum, marks simulation
65+
as busted.
66+
"""
67+
if not tds.converged and tds.deltat <= tds.deltatmin_adapt:
68+
rejected_h = tds.deltat
69+
tds.deltat = 0
70+
tds.busted = True
71+
tds.err_msg = (
72+
"Step size below adaptive minimum after rejection "
73+
f"(deltat={rejected_h:.4g}, dtmin_adapt={tds.deltatmin_adapt:.4g})."
74+
)
75+
76+
77+
def accept_reject(err_est, h, deltatmax, order,
78+
fail_count=0,
79+
accept_threshold=1.0,
80+
accept_safety=0.9,
81+
accept_min_factor=0.2,
82+
accept_max_factor=2.0,
83+
reject_safety=0.9,
84+
reject_min_factor=0.2,
85+
reject_max_factor=0.9,
86+
repeat_reject_after=1,
87+
repeat_reject_factor=0.5):
88+
"""
89+
Shared accept/reject controller for adaptive methods.
90+
91+
Returns
92+
-------
93+
tuple[bool, float, int]
94+
``(accepted, h_next, fail_count_next)``.
95+
"""
96+
if err_est <= accept_threshold:
97+
h_next = candidate_h(err_est, h, order,
98+
safety=accept_safety,
99+
min_factor=accept_min_factor,
100+
max_factor=accept_max_factor)
101+
return True, min(h_next, deltatmax), 0
102+
103+
fail_count_next = fail_count + 1
104+
h_next = candidate_h(err_est, h, order,
105+
safety=reject_safety,
106+
min_factor=reject_min_factor,
107+
max_factor=reject_max_factor)
108+
if fail_count_next > repeat_reject_after:
109+
h_next = min(h_next, h * repeat_reject_factor)
110+
111+
return False, min(h_next, deltatmax), fail_count_next

andes/routines/daeint.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import numpy as np
77

8+
from andes.routines.adaptive import accept_reject, check_adaptive_bust, weighted_rms_error
89
from andes.shared import sparse, matrix, tqdm
910
from andes.routines.qndf import QNDF
1011

@@ -16,6 +17,56 @@ class ImplicitIter:
1617
"""
1718
Base class for implicit iterative methods.
1819
"""
20+
nolte_event_steps = 0
21+
nolte_event_window = 0.0
22+
requires_variable_step = False
23+
24+
@staticmethod
25+
def niter_next_h(niter, h, h_min, h_max):
26+
"""
27+
Niter-based step-size heuristic shared by all methods.
28+
29+
Returns a clamped next step size based on how many Newton iterations
30+
were needed for convergence.
31+
"""
32+
if niter >= 15:
33+
h_new = h * 0.5
34+
elif niter <= 6:
35+
h_new = h * 1.1
36+
else:
37+
h_new = h * 0.95
38+
return min(max(h_new, h_min), h_max)
39+
40+
@staticmethod
41+
def calc_h(tds):
42+
"""
43+
Default step-size control: niter heuristic with fixt/shrinkt handling.
44+
"""
45+
config = tds.config
46+
47+
if tds.converged:
48+
tds.deltat = ImplicitIter.niter_next_h(
49+
tds.niter, tds.deltat, tds.deltatmin, tds.deltatmax)
50+
51+
if config.fixt:
52+
tds.deltat = min(config.tstep, tds.deltat)
53+
54+
if tds.chatter is True:
55+
tds.chatter = False
56+
else:
57+
if config.fixt and not config.shrinkt:
58+
tds.deltat = 0
59+
tds.busted = True
60+
tds.err_msg = (
61+
f"Simulation did not converge with step size h={config.tstep:.4f}.\n"
62+
"Reduce the step size `tstep`, or set `shrinkt = 1` to let it shrink."
63+
)
64+
else:
65+
tds.deltat *= 0.9
66+
if tds.deltat < tds.deltatmin:
67+
tds.deltat = 0
68+
tds.err_msg = "Time step reduced to zero. Convergence not likely."
69+
tds.busted = True
1970

2071
@staticmethod
2172
def calc_jac(tds, gxs, gys):
@@ -25,6 +76,41 @@ def calc_jac(tds, gxs, gys):
2576
def calc_q(x, f, Tf, h, x0, f0):
2677
pass
2778

79+
@staticmethod
80+
def checkpoint_state(tds):
81+
"""
82+
Snapshot DAE state for rollback.
83+
"""
84+
dae = tds.system.dae
85+
return dae.x.copy(), dae.y.copy(), dae.f.copy()
86+
87+
@staticmethod
88+
def restore_state(tds, state):
89+
"""
90+
Restore DAE state from a checkpoint.
91+
"""
92+
dae = tds.system.dae
93+
x_state, y_state, f_state = state
94+
dae.x[:] = x_state
95+
dae.y[:] = y_state
96+
dae.f[:] = f_state
97+
tds.system.vars_to_models()
98+
99+
@staticmethod
100+
def solve_once(tds, h, method):
101+
"""
102+
Run one implicit step with the given method and step size.
103+
"""
104+
original_method = tds.method
105+
original_h = tds.h
106+
tds.method = method
107+
tds.h = h
108+
try:
109+
return ImplicitIter.step(tds)
110+
finally:
111+
tds.method = original_method
112+
tds.h = original_h
113+
28114
@staticmethod
29115
def step(tds):
30116
"""
@@ -316,10 +402,160 @@ def calc_q(x, f, Tf, h, x0, f0):
316402
return Tf * (x - x0) - h * 0.5 * (f + f0)
317403

318404

405+
class TrapezoidAdaptive(Trapezoid):
406+
"""
407+
Adaptive trapezoid with step-doubling LTE estimation.
408+
409+
The LTE estimate is based on the difference between one full step of size
410+
``h`` and two half-steps of size ``h/2``.
411+
"""
412+
nolte_event_steps = 4
413+
nolte_event_window = 0.1
414+
requires_variable_step = True
415+
_trap_solver = Trapezoid()
416+
417+
@staticmethod
418+
def calc_h(tds):
419+
"""
420+
Step size is set by ``step()``. Only check bust on failure.
421+
"""
422+
check_adaptive_bust(tds)
423+
424+
@staticmethod
425+
def _reject(tds, h_next, state=None):
426+
"""
427+
Reject current candidate, optionally restoring the previous state.
428+
"""
429+
if state is not None:
430+
ImplicitIter.restore_state(tds, state)
431+
# Keep predictor snapshots consistent with restored DAE state.
432+
dae = tds.system.dae
433+
if tds.x0 is not None:
434+
tds.x0[:] = dae.x
435+
if tds.y0 is not None:
436+
tds.y0[:] = dae.y
437+
if tds.f0 is not None:
438+
tds.f0[:] = dae.f
439+
tds.deltat = min(h_next, tds.deltatmax)
440+
tds.converged = False
441+
tds.last_converged = False
442+
return False
443+
444+
@staticmethod
445+
def _nolte_next_h(tds, h):
446+
"""
447+
Heuristic next step size when LTE control is disabled.
448+
"""
449+
return ImplicitIter.niter_next_h(tds.niter, h, tds.deltatmin_adapt, tds.deltatmax)
450+
451+
@staticmethod
452+
def step(tds):
453+
"""
454+
One adaptive trapezoid step.
455+
456+
Reads ``tds.h`` and writes ``tds.deltat``.
457+
Returns True when accepted, False when rejected.
458+
"""
459+
dae = tds.system.dae
460+
h = tds.h
461+
462+
if h == 0:
463+
logger.error("Current step size is zero. Integration is not permitted.")
464+
return False
465+
466+
n = dae.n
467+
trap = TrapezoidAdaptive._trap_solver
468+
469+
# Restart mode near events: use converged trapezoid steps without LTE
470+
# for a few steps and/or for a short event-time window.
471+
use_nolte = tds._adaptive_nolte_steps > 0
472+
if (not use_nolte) and (tds.method.nolte_event_window > 0.0):
473+
use_nolte = (dae.t - tds._last_switch_t) < tds.method.nolte_event_window
474+
475+
if use_nolte:
476+
accepted = ImplicitIter.solve_once(tds, h, trap)
477+
if accepted:
478+
if tds._adaptive_nolte_steps > 0:
479+
tds._adaptive_nolte_steps -= 1
480+
tds.deltat = TrapezoidAdaptive._nolte_next_h(tds, h)
481+
tds.converged = True
482+
tds.last_converged = True
483+
return True
484+
485+
tds.deltat = min(max(h * 0.5, tds.deltatmin_adapt), tds.deltatmax)
486+
tds.converged = False
487+
tds.last_converged = False
488+
return False
489+
490+
# algebraic-only systems: fallback to a single trapezoid solve
491+
if n == 0:
492+
accepted = ImplicitIter.solve_once(tds, h, trap)
493+
if accepted:
494+
tds.deltat = min(h, tds.deltatmax)
495+
else:
496+
tds.deltat = min(h * 0.5, tds.deltatmax)
497+
tds.converged = accepted
498+
tds.last_converged = accepted
499+
return accepted
500+
501+
state0 = ImplicitIter.checkpoint_state(tds)
502+
x_prev = dae.x[:n].copy()
503+
504+
# one full step with h
505+
ok_full = ImplicitIter.solve_once(tds, h, trap)
506+
if not ok_full:
507+
# Base Newton path already rolled state back.
508+
return TrapezoidAdaptive._reject(tds, h * 0.5)
509+
x_full = dae.x[:n].copy()
510+
511+
# restore and run two half-steps
512+
ImplicitIter.restore_state(tds, state0)
513+
514+
if not ImplicitIter.solve_once(tds, 0.5 * h, trap):
515+
return TrapezoidAdaptive._reject(tds, h * 0.5, state0)
516+
517+
if not ImplicitIter.solve_once(tds, 0.5 * h, trap):
518+
return TrapezoidAdaptive._reject(tds, h * 0.5, state0)
519+
520+
# second half-step result is already in dae.x / dae.y
521+
x_half = dae.x[:n]
522+
err_wt = np.empty_like(x_half)
523+
err_vec = (x_half - x_full) / 3.0
524+
err_est = weighted_rms_error(err_vec, x_prev, x_half,
525+
tds.config.abstol, tds.config.reltol, err_wt)
526+
527+
accepted, h_next, _ = accept_reject(
528+
err_est=err_est,
529+
h=h,
530+
deltatmax=tds.deltatmax,
531+
order=2,
532+
accept_safety=0.9,
533+
accept_min_factor=0.2,
534+
accept_max_factor=2.0,
535+
reject_safety=0.9,
536+
reject_min_factor=0.2,
537+
reject_max_factor=0.9,
538+
repeat_reject_after=999, # no failure counter for trapezoid-adaptive
539+
repeat_reject_factor=1.0,
540+
)
541+
542+
if accepted:
543+
tds.deltat = h_next
544+
tds.converged = True
545+
tds.last_converged = True
546+
return True
547+
548+
# Reject — skip LTE for enough steps that the 1.1x/step growth
549+
# can recover from the worst-case 0.2x shrink (1.1^20 ≈ 6.7 > 5).
550+
tds._adaptive_nolte_steps = max(tds._adaptive_nolte_steps, 20)
551+
return TrapezoidAdaptive._reject(tds, h_next, state0)
552+
553+
319554
# --- solution method name-to-class mapping ---
320555
# !!! add new solvers to below
321556

322557
method_map = {"trapezoid": Trapezoid,
558+
"trap_adapt": TrapezoidAdaptive,
323559
"backeuler": BackEuler,
324560
'qndf': QNDF,
325561
}

0 commit comments

Comments
 (0)