Skip to main content

oxiphysics_core/
adaptive_timestepping.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified adaptive time-stepping module for multi-domain physics simulations.
5//!
6//! Provides CFL-based timestep constraints, Richardson extrapolation for
7//! error-based control, and a [`UnifiedTimeStepper`] that coordinates
8//! multiple physics domains with subcycling support.
9//!
10//! # Example
11//!
12//! ```no_run
13//! use oxiphysics_core::adaptive_timestepping::*;
14//!
15//! // Configure two domains: a fast fluid and a slow structure
16//! let fluid = TimeDomainConfig::new(1e-6, 1e-3, 0.25, 0.9);
17//! let structure = TimeDomainConfig::new(1e-5, 1e-2, 0.5, 0.9);
18//!
19//! let mut stepper = UnifiedTimeStepper::new(vec![fluid, structure]);
20//!
21//! // After evaluating physics, update domain timesteps
22//! stepper.update_domain_dt(0, 1e-4);
23//! stepper.update_domain_dt(1, 5e-4);
24//!
25//! let global_dt = stepper.compute_global_dt();
26//! assert!(global_dt > 0.0);
27//! ```
28
29// ---------------------------------------------------------------------------
30// Free functions: CFL / Courant / diffusive timestep constraints
31// ---------------------------------------------------------------------------
32
33/// Compute a CFL timestep from maximum velocity and grid spacing.
34///
35/// `dt = cfl_number * grid_spacing / max_velocity`
36///
37/// Returns `f64::MAX` when `max_velocity` is effectively zero.
38pub fn cfl_timestep(max_velocity: f64, grid_spacing: f64, cfl_number: f64) -> f64 {
39    let v = max_velocity.abs();
40    if v < 1e-30 {
41        return f64::MAX;
42    }
43    cfl_number * grid_spacing / v
44}
45
46/// Compute the Courant timestep for wave propagation.
47///
48/// `dt = cfl * dx / c` where `c` is the wave/sound speed.
49///
50/// Returns `f64::MAX` when `c` is effectively zero.
51pub fn courant_dt(dx: f64, c: f64, cfl: f64) -> f64 {
52    let c_abs = c.abs();
53    if c_abs < 1e-30 {
54        return f64::MAX;
55    }
56    cfl * dx / c_abs
57}
58
59/// Compute the diffusive (viscous) timestep constraint.
60///
61/// `dt = safety * dx^2 / (2 * nu)` (for 1-D; the factor 2 accounts for the
62/// standard Von-Neumann stability limit).
63///
64/// Returns `f64::MAX` when `nu` is effectively zero.
65pub fn diffusive_dt(dx: f64, nu: f64, safety: f64) -> f64 {
66    let nu_abs = nu.abs();
67    if nu_abs < 1e-30 {
68        return f64::MAX;
69    }
70    safety * dx * dx / (2.0 * nu_abs)
71}
72
73// ---------------------------------------------------------------------------
74// Richardson extrapolation helper
75// ---------------------------------------------------------------------------
76
77/// Compute a new timestep from an error estimate using Richardson
78/// extrapolation / PI-controller logic.
79///
80/// `dt_new = dt_old * (tolerance / error)^(1 / (order + 1))`
81///
82/// The result is clamped to `[dt_old * 0.1, dt_old * 5.0]` to prevent
83/// violent oscillations.
84///
85/// If `error` is effectively zero the timestep is multiplied by the maximum
86/// growth factor (5×).
87pub fn richardson_dt(dt_old: f64, error: f64, tolerance: f64, order: u32) -> f64 {
88    const MIN_FACTOR: f64 = 0.1;
89    const MAX_FACTOR: f64 = 5.0;
90
91    if error < 1e-30 {
92        return dt_old * MAX_FACTOR;
93    }
94
95    let exponent = 1.0 / (order as f64 + 1.0);
96    let ratio = (tolerance / error).powf(exponent);
97    let factor = ratio.clamp(MIN_FACTOR, MAX_FACTOR);
98    dt_old * factor
99}
100
101// ---------------------------------------------------------------------------
102// Per-domain configuration
103// ---------------------------------------------------------------------------
104
105/// Configuration for a single time domain.
106#[derive(Debug, Clone)]
107pub struct TimeDomainConfig {
108    /// Minimum allowed timestep.
109    pub min_dt: f64,
110    /// Maximum allowed timestep.
111    pub max_dt: f64,
112    /// CFL factor (Courant number target).
113    pub cfl_factor: f64,
114    /// Safety factor applied after computing raw timestep (typically 0.8–0.95).
115    pub safety_factor: f64,
116}
117
118impl TimeDomainConfig {
119    /// Create a new configuration.
120    pub fn new(min_dt: f64, max_dt: f64, cfl_factor: f64, safety_factor: f64) -> Self {
121        Self {
122            min_dt,
123            max_dt,
124            cfl_factor,
125            safety_factor,
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Per-domain state
132// ---------------------------------------------------------------------------
133
134/// Mutable state for a single time domain.
135#[derive(Debug, Clone)]
136pub struct TimeDomainState {
137    /// Current timestep for this domain.
138    pub current_dt: f64,
139    /// Latest local truncation error estimate (set by the integrator).
140    pub error_estimate: f64,
141    /// Number of steps taken in this domain.
142    pub step_count: u64,
143    /// Configuration (immutable after construction, but stored here for
144    /// convenient access).
145    pub config: TimeDomainConfig,
146}
147
148impl TimeDomainState {
149    /// Create a new domain state from its configuration.
150    ///
151    /// The initial `current_dt` is set to `config.max_dt * config.safety_factor`.
152    pub fn from_config(config: TimeDomainConfig) -> Self {
153        let initial_dt = config.max_dt * config.safety_factor;
154        Self {
155            current_dt: initial_dt,
156            error_estimate: 0.0,
157            step_count: 0,
158            config,
159        }
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Step schedule entry
165// ---------------------------------------------------------------------------
166
167/// Describes how a single domain should be advanced during one global step.
168#[derive(Debug, Clone, PartialEq)]
169pub struct StepSchedule {
170    /// Index of the domain.
171    pub domain_idx: usize,
172    /// Number of sub-steps this domain should take.
173    pub n_substeps: usize,
174    /// Timestep for each sub-step.
175    pub substep_dt: f64,
176}
177
178// ---------------------------------------------------------------------------
179// UnifiedTimeStepper
180// ---------------------------------------------------------------------------
181
182/// Coordinates adaptive time-stepping across multiple physics domains.
183///
184/// Each domain may have a different natural timestep; the stepper computes a
185/// global step size (the minimum, after safety factors) and determines how
186/// many sub-steps faster domains effectively need within that global window.
187/// Domains whose natural timestep is larger than the global one take a single
188/// step of size `global_dt`.
189#[derive(Debug, Clone)]
190pub struct UnifiedTimeStepper {
191    /// Per-domain states.
192    pub domains: Vec<TimeDomainState>,
193    /// Current global timestep (minimum across domains, with safety).
194    pub global_dt: f64,
195    /// Accumulated simulation time.
196    pub global_time: f64,
197    /// How many sub-steps each domain takes per global step.
198    pub subcycle_ratios: Vec<usize>,
199}
200
201impl UnifiedTimeStepper {
202    /// Create a new stepper from per-domain configurations.
203    pub fn new(configs: Vec<TimeDomainConfig>) -> Self {
204        let domains: Vec<TimeDomainState> = configs
205            .into_iter()
206            .map(TimeDomainState::from_config)
207            .collect();
208        let n = domains.len();
209        let mut stepper = Self {
210            domains,
211            global_dt: 0.0,
212            global_time: 0.0,
213            subcycle_ratios: vec![1; n],
214        };
215        stepper.global_dt = stepper.raw_global_dt();
216        stepper.recompute_subcycle_ratios();
217        stepper
218    }
219
220    // -- internal helpers ---------------------------------------------------
221
222    /// Compute the raw global dt (minimum of domain dts with safety).
223    fn raw_global_dt(&self) -> f64 {
224        self.domains
225            .iter()
226            .map(|d| d.current_dt * d.config.safety_factor)
227            .fold(f64::MAX, f64::min)
228    }
229
230    /// Recompute subcycle ratios from current domain dts.
231    fn recompute_subcycle_ratios(&mut self) {
232        if self.global_dt <= 0.0 {
233            // Fallback: all 1.
234            for r in &mut self.subcycle_ratios {
235                *r = 1;
236            }
237            return;
238        }
239        for (i, domain) in self.domains.iter().enumerate() {
240            let effective_dt = domain.current_dt * domain.config.safety_factor;
241            // Ratio: how many global steps fit in one domain step.
242            // If domain is slower (larger dt), it takes 1 sub-step of global_dt.
243            // If domain is faster (smaller dt), it needs ceil(global_dt / domain_dt).
244            if effective_dt >= self.global_dt {
245                self.subcycle_ratios[i] = 1;
246            } else {
247                // Domain is faster than global; needs multiple sub-steps.
248                let ratio = (self.global_dt / effective_dt).ceil() as usize;
249                self.subcycle_ratios[i] = ratio.max(1);
250            }
251        }
252    }
253
254    // -- public API ---------------------------------------------------------
255
256    /// Compute and update the global timestep (minimum across domains).
257    ///
258    /// Returns the new `global_dt`.
259    pub fn compute_global_dt(&mut self) -> f64 {
260        self.global_dt = self.raw_global_dt();
261        // Clamp to the tightest domain bounds.
262        let global_min = self
263            .domains
264            .iter()
265            .map(|d| d.config.min_dt)
266            .fold(0.0_f64, f64::max);
267        let global_max = self
268            .domains
269            .iter()
270            .map(|d| d.config.max_dt)
271            .fold(f64::MAX, f64::min);
272        self.global_dt = self.global_dt.clamp(global_min, global_max);
273        self.recompute_subcycle_ratios();
274        self.global_dt
275    }
276
277    /// Recompute the subcycle ratios based on the current `global_dt` and
278    /// each domain's `current_dt`.
279    pub fn compute_subcycle_ratios(&mut self) {
280        self.recompute_subcycle_ratios();
281    }
282
283    /// Advance the global simulation time by one `global_dt` and increment
284    /// each domain's step counter by its subcycle ratio.
285    pub fn advance_global_time(&mut self) {
286        self.global_time += self.global_dt;
287        for (i, domain) in self.domains.iter_mut().enumerate() {
288            domain.step_count += self.subcycle_ratios[i] as u64;
289        }
290    }
291
292    /// Update a domain's suggested timestep.
293    ///
294    /// The new value is clamped to the domain's `[min_dt, max_dt]` range.
295    ///
296    /// Returns `true` if `domain_idx` was valid, `false` otherwise.
297    pub fn update_domain_dt(&mut self, domain_idx: usize, new_dt: f64) -> bool {
298        if let Some(domain) = self.domains.get_mut(domain_idx) {
299            domain.current_dt = new_dt.clamp(domain.config.min_dt, domain.config.max_dt);
300            true
301        } else {
302            false
303        }
304    }
305
306    /// Record an error estimate for a domain (used by Richardson /
307    /// embedded-pair integrators).
308    ///
309    /// Returns `true` if `domain_idx` was valid, `false` otherwise.
310    pub fn update_domain_error(&mut self, domain_idx: usize, error: f64) -> bool {
311        if let Some(domain) = self.domains.get_mut(domain_idx) {
312            domain.error_estimate = error;
313            true
314        } else {
315            false
316        }
317    }
318
319    /// Generate the step schedule describing how each domain should be
320    /// advanced during the next global step.
321    pub fn step_schedule(&self) -> Vec<StepSchedule> {
322        self.domains
323            .iter()
324            .enumerate()
325            .map(|(i, _domain)| {
326                let n = self.subcycle_ratios[i];
327                let sub_dt = if n > 0 {
328                    self.global_dt / n as f64
329                } else {
330                    self.global_dt
331                };
332                StepSchedule {
333                    domain_idx: i,
334                    n_substeps: n,
335                    substep_dt: sub_dt,
336                }
337            })
338            .collect()
339    }
340}
341
342// ===========================================================================
343// Tests
344// ===========================================================================
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    // -- Free-function tests ------------------------------------------------
351
352    #[test]
353    fn test_cfl_timestep_basic() {
354        let dt = cfl_timestep(10.0, 1.0, 0.5);
355        assert!((dt - 0.05).abs() < 1e-12, "dt = {dt}");
356    }
357
358    #[test]
359    fn test_cfl_timestep_zero_velocity_returns_max() {
360        let dt = cfl_timestep(0.0, 1.0, 0.5);
361        assert_eq!(dt, f64::MAX);
362    }
363
364    #[test]
365    fn test_courant_dt_basic() {
366        let dt = courant_dt(0.1, 340.0, 0.8);
367        let expected = 0.8 * 0.1 / 340.0;
368        assert!((dt - expected).abs() < 1e-14, "dt = {dt}");
369    }
370
371    #[test]
372    fn test_courant_dt_zero_speed() {
373        assert_eq!(courant_dt(0.1, 0.0, 0.8), f64::MAX);
374    }
375
376    #[test]
377    fn test_diffusive_dt_basic() {
378        let dt = diffusive_dt(0.01, 1e-3, 0.9);
379        let expected = 0.9 * 0.01 * 0.01 / (2.0 * 1e-3);
380        assert!((dt - expected).abs() < 1e-14, "dt = {dt}");
381    }
382
383    #[test]
384    fn test_diffusive_dt_zero_nu() {
385        assert_eq!(diffusive_dt(0.01, 0.0, 0.9), f64::MAX);
386    }
387
388    // -- Richardson extrapolation tests ------------------------------------
389
390    #[test]
391    fn test_richardson_increases_dt_when_error_below_tolerance() {
392        let dt_old = 0.01;
393        let error = 1e-6;
394        let tolerance = 1e-4;
395        let dt_new = richardson_dt(dt_old, error, tolerance, 2);
396        assert!(
397            dt_new > dt_old,
398            "dt_new={dt_new} should be > dt_old={dt_old}"
399        );
400    }
401
402    #[test]
403    fn test_richardson_decreases_dt_when_error_above_tolerance() {
404        let dt_old = 0.01;
405        let error = 1e-2;
406        let tolerance = 1e-4;
407        let dt_new = richardson_dt(dt_old, error, tolerance, 2);
408        assert!(
409            dt_new < dt_old,
410            "dt_new={dt_new} should be < dt_old={dt_old}"
411        );
412    }
413
414    #[test]
415    fn test_richardson_zero_error_gives_max_growth() {
416        let dt_old = 0.01;
417        let dt_new = richardson_dt(dt_old, 0.0, 1e-4, 2);
418        assert!((dt_new - dt_old * 5.0).abs() < 1e-14);
419    }
420
421    #[test]
422    fn test_richardson_clamped_growth() {
423        // Very tiny error => growth capped at 5×.
424        let dt_new = richardson_dt(0.01, 1e-30, 1.0, 1);
425        assert!((dt_new - 0.05).abs() < 1e-10);
426    }
427
428    #[test]
429    fn test_richardson_clamped_shrink() {
430        // Huge error => shrink capped at 0.1×.
431        let dt_new = richardson_dt(0.01, 1e10, 1e-4, 2);
432        assert!((dt_new - 0.001).abs() < 1e-10);
433    }
434
435    // -- Single-domain convergence -----------------------------------------
436
437    #[test]
438    fn test_single_domain_dt_converges() {
439        let cfg = TimeDomainConfig::new(1e-6, 1e-2, 0.5, 0.9);
440        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
441
442        // Simulate a controller loop: update dt based on "physics".
443        for _ in 0..20 {
444            let target_dt = 5e-4;
445            stepper.update_domain_dt(0, target_dt);
446            stepper.compute_global_dt();
447        }
448
449        let effective = stepper.global_dt;
450        let expected = 5e-4 * 0.9;
451        assert!(
452            (effective - expected).abs() < 1e-10,
453            "effective={effective}, expected={expected}"
454        );
455    }
456
457    // -- Two-domain subcycling ---------------------------------------------
458
459    #[test]
460    fn test_two_domains_subcycling() {
461        let fast = TimeDomainConfig::new(1e-6, 1e-2, 0.25, 0.9);
462        let slow = TimeDomainConfig::new(1e-5, 1e-1, 0.5, 0.9);
463        let mut stepper = UnifiedTimeStepper::new(vec![fast, slow]);
464
465        // Fast domain wants dt = 1e-4, slow domain wants dt = 1e-3.
466        stepper.update_domain_dt(0, 1e-4);
467        stepper.update_domain_dt(1, 1e-3);
468        stepper.compute_global_dt();
469
470        // Global dt should be driven by the fast domain.
471        let g = stepper.global_dt;
472        let fast_eff = 1e-4 * 0.9;
473        assert!(
474            (g - fast_eff).abs() < 1e-12,
475            "global_dt={g}, expected={fast_eff}"
476        );
477
478        // Fast domain: 1 sub-step; slow domain: 1 sub-step (its dt is larger).
479        assert_eq!(stepper.subcycle_ratios[0], 1);
480        assert_eq!(stepper.subcycle_ratios[1], 1);
481    }
482
483    #[test]
484    fn test_global_dt_is_minimum_across_domains() {
485        let d1 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
486        let d2 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
487        let d3 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
488        let mut stepper = UnifiedTimeStepper::new(vec![d1, d2, d3]);
489
490        stepper.update_domain_dt(0, 0.1);
491        stepper.update_domain_dt(1, 0.05);
492        stepper.update_domain_dt(2, 0.2);
493        let g = stepper.compute_global_dt();
494
495        // safety=1.0, so global = min(0.1, 0.05, 0.2) = 0.05
496        assert!((g - 0.05).abs() < 1e-14, "global_dt={g}");
497    }
498
499    // -- Schedule generation -----------------------------------------------
500
501    #[test]
502    fn test_schedule_single_domain() {
503        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
504        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
505        stepper.update_domain_dt(0, 0.01);
506        stepper.compute_global_dt();
507
508        let sched = stepper.step_schedule();
509        assert_eq!(sched.len(), 1);
510        assert_eq!(sched[0].domain_idx, 0);
511        assert_eq!(sched[0].n_substeps, 1);
512        assert!((sched[0].substep_dt - 0.01).abs() < 1e-14);
513    }
514
515    #[test]
516    fn test_schedule_multi_domain() {
517        let d1 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
518        let d2 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
519        let mut stepper = UnifiedTimeStepper::new(vec![d1, d2]);
520
521        stepper.update_domain_dt(0, 0.01);
522        stepper.update_domain_dt(1, 0.1);
523        stepper.compute_global_dt();
524
525        let sched = stepper.step_schedule();
526        assert_eq!(sched.len(), 2);
527
528        // Domain 0 drives global_dt = 0.01.
529        assert_eq!(sched[0].n_substeps, 1);
530        assert!((sched[0].substep_dt - 0.01).abs() < 1e-14);
531
532        // Domain 1 has larger dt, so also 1 sub-step of global_dt.
533        assert_eq!(sched[1].n_substeps, 1);
534        assert!((sched[1].substep_dt - 0.01).abs() < 1e-14);
535    }
536
537    // -- Advance time -------------------------------------------------------
538
539    #[test]
540    fn test_advance_global_time() {
541        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
542        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
543        stepper.update_domain_dt(0, 0.01);
544        stepper.compute_global_dt();
545
546        let dt = stepper.global_dt;
547        stepper.advance_global_time();
548        assert!((stepper.global_time - dt).abs() < 1e-14);
549        assert_eq!(stepper.domains[0].step_count, 1); // 0 from init + 1 advance
550    }
551
552    #[test]
553    fn test_advance_accumulates() {
554        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
555        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
556        stepper.update_domain_dt(0, 0.01);
557        stepper.compute_global_dt();
558
559        for _ in 0..100 {
560            stepper.advance_global_time();
561        }
562        let expected_time = 100.0 * stepper.global_dt;
563        assert!(
564            (stepper.global_time - expected_time).abs() < 1e-10,
565            "time={}, expected={}",
566            stepper.global_time,
567            expected_time
568        );
569    }
570
571    // -- Domain error updates -----------------------------------------------
572
573    #[test]
574    fn test_update_domain_error() {
575        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
576        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
577        assert!(stepper.update_domain_error(0, 1e-5));
578        assert!((stepper.domains[0].error_estimate - 1e-5).abs() < 1e-20);
579    }
580
581    #[test]
582    fn test_update_domain_error_invalid_index() {
583        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
584        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
585        assert!(!stepper.update_domain_error(5, 1e-5));
586    }
587
588    #[test]
589    fn test_update_domain_dt_clamps() {
590        let cfg = TimeDomainConfig::new(1e-6, 1e-2, 0.5, 1.0);
591        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
592
593        // Try to set dt above max.
594        stepper.update_domain_dt(0, 1.0);
595        assert!((stepper.domains[0].current_dt - 1e-2).abs() < 1e-14);
596
597        // Try to set dt below min.
598        stepper.update_domain_dt(0, 1e-10);
599        assert!((stepper.domains[0].current_dt - 1e-6).abs() < 1e-14);
600    }
601
602    #[test]
603    fn test_update_domain_dt_invalid_index() {
604        let cfg = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
605        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
606        assert!(!stepper.update_domain_dt(99, 0.01));
607    }
608
609    // -- Richardson + stepper integration -----------------------------------
610
611    #[test]
612    fn test_richardson_driven_adaptation() {
613        let cfg = TimeDomainConfig::new(1e-8, 1.0, 0.5, 0.9);
614        let mut stepper = UnifiedTimeStepper::new(vec![cfg]);
615        stepper.update_domain_dt(0, 0.01);
616
617        let tolerance = 1e-4;
618        let order = 2_u32;
619
620        // Simulate: error is large, dt should shrink.
621        let error = 1e-2;
622        let new_dt = richardson_dt(stepper.domains[0].current_dt, error, tolerance, order);
623        stepper.update_domain_dt(0, new_dt);
624        stepper.compute_global_dt();
625        assert!(stepper.global_dt < 0.01 * 0.9, "should have shrunk");
626
627        // Simulate: error is tiny, dt should grow.
628        let error2 = 1e-10;
629        let new_dt2 = richardson_dt(stepper.domains[0].current_dt, error2, tolerance, order);
630        stepper.update_domain_dt(0, new_dt2);
631        stepper.compute_global_dt();
632        assert!(
633            stepper.domains[0].current_dt > stepper.domains[0].config.min_dt,
634            "should have grown"
635        );
636    }
637
638    // -- Subcycling with a domain faster than global -----------------------
639
640    #[test]
641    fn test_subcycling_fast_domain() {
642        // Construct: d0 has safety 1.0 and dt=0.01,
643        //            d1 has safety 0.5, so effective dt = dt*0.5.
644        // If d1.current_dt = 0.01, effective = 0.005.
645        // Global dt = min(0.01, 0.005) = 0.005.
646        // d0 effective = 0.01 >= 0.005 → 1 sub-step.
647        // d1 effective = 0.005 >= 0.005 → 1 sub-step.
648        //
649        // Now if we manually set global_dt larger via a different route,
650        // we can test true subcycling:
651        let d0 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
652        let d1 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 1.0);
653        let mut stepper = UnifiedTimeStepper::new(vec![d0, d1]);
654
655        // d0 wants dt=0.1, d1 wants dt=0.025
656        stepper.update_domain_dt(0, 0.1);
657        stepper.update_domain_dt(1, 0.025);
658        stepper.compute_global_dt();
659
660        // global = 0.025, d0 effective 0.1 >= 0.025 → 1 sub-step
661        assert_eq!(stepper.subcycle_ratios[0], 1);
662        assert_eq!(stepper.subcycle_ratios[1], 1);
663        assert!((stepper.global_dt - 0.025).abs() < 1e-14);
664    }
665
666    // -- Edge cases ---------------------------------------------------------
667
668    #[test]
669    fn test_empty_domains() {
670        let stepper = UnifiedTimeStepper::new(vec![]);
671        assert_eq!(stepper.domains.len(), 0);
672        assert_eq!(stepper.subcycle_ratios.len(), 0);
673        assert!(stepper.step_schedule().is_empty());
674    }
675
676    #[test]
677    fn test_identical_domains() {
678        let c1 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 0.9);
679        let c2 = TimeDomainConfig::new(1e-6, 1.0, 0.5, 0.9);
680        let mut stepper = UnifiedTimeStepper::new(vec![c1, c2]);
681
682        stepper.update_domain_dt(0, 0.01);
683        stepper.update_domain_dt(1, 0.01);
684        stepper.compute_global_dt();
685
686        assert_eq!(stepper.subcycle_ratios[0], 1);
687        assert_eq!(stepper.subcycle_ratios[1], 1);
688        assert!((stepper.global_dt - 0.01 * 0.9).abs() < 1e-14);
689    }
690
691    #[test]
692    fn test_global_dt_respects_min_dt_bound() {
693        // Both domains have min_dt = 0.001. Even if the raw safety-adjusted
694        // dt would be smaller, it should be clamped.
695        let c1 = TimeDomainConfig::new(0.001, 1.0, 0.5, 0.01);
696        let mut stepper = UnifiedTimeStepper::new(vec![c1]);
697        stepper.update_domain_dt(0, 0.001); // safety 0.01 → effective 1e-5
698        let g = stepper.compute_global_dt();
699        assert!(g >= 0.001, "global_dt={g} should be >= min_dt=0.001");
700    }
701
702    #[test]
703    fn test_negative_velocity_cfl() {
704        // Negative velocity should be treated as abs.
705        let dt = cfl_timestep(-100.0, 1.0, 0.5);
706        let expected = 0.5 * 1.0 / 100.0;
707        assert!((dt - expected).abs() < 1e-14);
708    }
709
710    #[test]
711    fn test_diffusive_dt_negative_nu() {
712        // Negative viscosity should use abs.
713        let dt = diffusive_dt(0.1, -0.01, 0.9);
714        let expected = 0.9 * 0.01 / 0.02;
715        assert!((dt - expected).abs() < 1e-14);
716    }
717}