55import logging
66import numpy as np
77
8+ from andes .routines .adaptive import accept_reject , check_adaptive_bust , weighted_rms_error
89from andes .shared import sparse , matrix , tqdm
910from 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
322557method_map = {"trapezoid" : Trapezoid ,
558+ "trap_adapt" : TrapezoidAdaptive ,
323559 "backeuler" : BackEuler ,
324560 'qndf' : QNDF ,
325561 }
0 commit comments