Skip to main content

simular/orbit/
heijunka.rs

1//! Heijunka (平準化) - Time-budget load leveling.
2//!
3//! Implements Toyota's Heijunka principle for consistent frame delivery:
4//! - Time-budget per frame (16ms target for 60 FPS)
5//! - Graceful quality degradation when budget exceeded
6//! - Prevents Mura (unevenness) from O(N²) computation
7//!
8//! # Design Philosophy
9//!
10//! Rather than dropping frames or stuttering, Heijunka reduces simulation
11//! fidelity (substeps) to maintain consistent visual delivery.
12//!
13//! # References
14//!
15//! [34] Liker, "The Toyota Way," McGraw-Hill, 2004.
16
17use serde::{Deserialize, Serialize};
18use std::time::{Duration, Instant};
19
20use crate::error::SimResult;
21use crate::orbit::physics::{NBodyState, YoshidaIntegrator};
22use crate::orbit::units::OrbitTime;
23
24/// Quality level for adaptive degradation.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
26pub enum QualityLevel {
27    /// Minimum quality (fewest substeps).
28    Minimum,
29    /// Low quality.
30    Low,
31    /// Medium quality.
32    Medium,
33    /// High quality (default).
34    #[default]
35    High,
36    /// Maximum quality (most substeps).
37    Maximum,
38}
39
40impl QualityLevel {
41    /// Degrade to next lower quality level.
42    #[must_use]
43    pub fn degrade(self) -> Self {
44        match self {
45            Self::Maximum => Self::High,
46            Self::High => Self::Medium,
47            Self::Medium => Self::Low,
48            Self::Low | Self::Minimum => Self::Minimum,
49        }
50    }
51
52    /// Upgrade to next higher quality level.
53    #[must_use]
54    pub fn upgrade(self) -> Self {
55        match self {
56            Self::Minimum => Self::Low,
57            Self::Low => Self::Medium,
58            Self::Medium => Self::High,
59            Self::High | Self::Maximum => Self::Maximum,
60        }
61    }
62
63    /// Get substep multiplier for this quality level.
64    #[must_use]
65    pub fn substep_multiplier(self) -> usize {
66        contract_pre_iterator!();
67        match self {
68            Self::Minimum => 1,
69            Self::Low => 2,
70            Self::Medium => 4,
71            Self::High => 8,
72            Self::Maximum => 16,
73        }
74    }
75}
76
77/// Heijunka scheduler configuration.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct HeijunkaConfig {
80    /// Total frame budget in milliseconds (default: 16ms for 60 FPS).
81    pub frame_budget_ms: f64,
82    /// Physics budget as fraction of frame budget (default: 0.5).
83    pub physics_budget_fraction: f64,
84    /// Base time step in seconds.
85    pub base_dt: f64,
86    /// Maximum substeps per frame.
87    pub max_substeps: usize,
88    /// Minimum substeps per frame.
89    pub min_substeps: usize,
90    /// Auto-adjust quality based on performance.
91    pub auto_adjust_quality: bool,
92    /// Consecutive frames below budget needed to upgrade quality.
93    pub upgrade_threshold: usize,
94}
95
96impl Default for HeijunkaConfig {
97    fn default() -> Self {
98        Self {
99            frame_budget_ms: 16.0,        // 60 FPS
100            physics_budget_fraction: 0.5, // 50% for physics
101            base_dt: 3600.0,              // 1 hour simulation time per step
102            max_substeps: 100,
103            min_substeps: 1,
104            auto_adjust_quality: true,
105            upgrade_threshold: 10,
106        }
107    }
108}
109
110impl HeijunkaConfig {
111    /// Get physics budget in milliseconds.
112    #[must_use]
113    pub fn physics_budget_ms(&self) -> f64 {
114        self.frame_budget_ms * self.physics_budget_fraction
115    }
116}
117
118/// Result of a Heijunka frame execution.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct FrameResult {
121    /// Number of substeps executed.
122    pub substeps: usize,
123    /// Time spent on physics in milliseconds.
124    pub physics_time_ms: f64,
125    /// Current quality level.
126    pub quality: QualityLevel,
127    /// Whether budget was exceeded.
128    pub budget_exceeded: bool,
129    /// Simulation time advanced.
130    pub sim_time_advanced: f64,
131}
132
133/// Heijunka status for visualization.
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135pub struct HeijunkaStatus {
136    /// Current frame budget (ms).
137    pub budget_ms: f64,
138    /// Time used in last frame (ms).
139    pub used_ms: f64,
140    /// Substeps in last frame.
141    pub substeps: usize,
142    /// Current quality level.
143    pub quality: QualityLevel,
144    /// Average physics time over recent frames (ms).
145    pub avg_physics_ms: f64,
146    /// Budget utilization (0.0 - 1.0+).
147    pub utilization: f64,
148}
149
150/// Heijunka scheduler for load-leveled simulation.
151#[derive(Debug, Clone)]
152pub struct HeijunkaScheduler {
153    config: HeijunkaConfig,
154    quality: QualityLevel,
155    integrator: YoshidaIntegrator,
156    consecutive_under_budget: usize,
157    physics_times: Vec<f64>,
158    status: HeijunkaStatus,
159}
160
161impl HeijunkaScheduler {
162    /// Create a new Heijunka scheduler.
163    #[must_use]
164    pub fn new(config: HeijunkaConfig) -> Self {
165        Self {
166            config,
167            quality: QualityLevel::default(),
168            integrator: YoshidaIntegrator::new(),
169            consecutive_under_budget: 0,
170            physics_times: Vec::with_capacity(100),
171            status: HeijunkaStatus::default(),
172        }
173    }
174
175    /// Get current quality level.
176    #[must_use]
177    pub fn quality(&self) -> QualityLevel {
178        self.quality
179    }
180
181    /// Set quality level manually.
182    pub fn set_quality(&mut self, quality: QualityLevel) {
183        self.quality = quality;
184    }
185
186    /// Get current status for visualization.
187    #[must_use]
188    pub fn status(&self) -> &HeijunkaStatus {
189        &self.status
190    }
191
192    /// Execute one frame of simulation with time-budget management.
193    ///
194    /// # Errors
195    ///
196    /// Returns error if physics integration fails.
197    pub fn execute_frame(&mut self, state: &mut NBodyState) -> SimResult<FrameResult> {
198        let budget_ms = self.config.physics_budget_ms();
199        let budget_duration = Duration::from_secs_f64(budget_ms / 1000.0);
200
201        let target_substeps = self
202            .quality
203            .substep_multiplier()
204            .min(self.config.max_substeps)
205            .max(self.config.min_substeps);
206
207        let start = Instant::now();
208        let mut substeps = 0;
209        let mut sim_time_advanced = 0.0;
210
211        // Execute substeps within budget
212        while substeps < target_substeps {
213            // Check if we have time for another step
214            let elapsed = start.elapsed();
215            if elapsed >= budget_duration && substeps > 0 {
216                break;
217            }
218
219            // Execute one physics step
220            let dt = OrbitTime::from_seconds(self.config.base_dt);
221            self.integrator.step(state, dt)?;
222
223            substeps += 1;
224            sim_time_advanced += self.config.base_dt;
225        }
226
227        let physics_time_ms = start.elapsed().as_secs_f64() * 1000.0;
228        let budget_exceeded = physics_time_ms > budget_ms;
229
230        // Track physics times for averaging
231        self.physics_times.push(physics_time_ms);
232        if self.physics_times.len() > 100 {
233            self.physics_times.remove(0);
234        }
235
236        // Auto-adjust quality
237        if self.config.auto_adjust_quality {
238            self.adjust_quality(budget_exceeded, physics_time_ms, budget_ms);
239        }
240
241        // Update status
242        let avg_physics_ms = if self.physics_times.is_empty() {
243            0.0
244        } else {
245            self.physics_times.iter().sum::<f64>() / self.physics_times.len() as f64
246        };
247
248        self.status = HeijunkaStatus {
249            budget_ms,
250            used_ms: physics_time_ms,
251            substeps,
252            quality: self.quality,
253            avg_physics_ms,
254            utilization: physics_time_ms / budget_ms,
255        };
256
257        Ok(FrameResult {
258            substeps,
259            physics_time_ms,
260            quality: self.quality,
261            budget_exceeded,
262            sim_time_advanced,
263        })
264    }
265
266    fn adjust_quality(&mut self, budget_exceeded: bool, physics_time_ms: f64, budget_ms: f64) {
267        if budget_exceeded {
268            // Immediately degrade quality if over budget
269            self.quality = self.quality.degrade();
270            self.consecutive_under_budget = 0;
271        } else if physics_time_ms < budget_ms * 0.5 {
272            // Only upgrade if significantly under budget
273            self.consecutive_under_budget += 1;
274            if self.consecutive_under_budget >= self.config.upgrade_threshold {
275                self.quality = self.quality.upgrade();
276                self.consecutive_under_budget = 0;
277            }
278        } else {
279            // Reset counter if close to budget
280            self.consecutive_under_budget = 0;
281        }
282    }
283
284    /// Estimate substeps possible within budget for given state.
285    #[must_use]
286    pub fn estimate_substeps(&self, _state: &NBodyState) -> usize {
287        contract_pre_iterator!();
288        // Use historical average to estimate
289        if self.physics_times.is_empty() {
290            return self.quality.substep_multiplier();
291        }
292
293        let avg_per_step = self.physics_times.iter().sum::<f64>()
294            / self.physics_times.len() as f64
295            / self.config.max_substeps.max(1) as f64;
296
297        if avg_per_step > 0.0 {
298            let budget_ms = self.config.physics_budget_ms();
299            (budget_ms / avg_per_step) as usize
300        } else {
301            self.quality.substep_multiplier()
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::orbit::physics::OrbitBody;
310    use crate::orbit::units::{OrbitMass, Position3D, Velocity3D, AU, EARTH_MASS, G, SOLAR_MASS};
311
312    fn create_test_state() -> NBodyState {
313        let v_circular = (G * SOLAR_MASS / AU).sqrt();
314        let bodies = vec![
315            OrbitBody::new(
316                OrbitMass::from_kg(SOLAR_MASS),
317                Position3D::zero(),
318                Velocity3D::zero(),
319            ),
320            OrbitBody::new(
321                OrbitMass::from_kg(EARTH_MASS),
322                Position3D::from_au(1.0, 0.0, 0.0),
323                Velocity3D::from_mps(0.0, v_circular, 0.0),
324            ),
325        ];
326        NBodyState::new(bodies, 1e6)
327    }
328
329    #[test]
330    fn test_quality_level_degrade() {
331        assert_eq!(QualityLevel::Maximum.degrade(), QualityLevel::High);
332        assert_eq!(QualityLevel::High.degrade(), QualityLevel::Medium);
333        assert_eq!(QualityLevel::Medium.degrade(), QualityLevel::Low);
334        assert_eq!(QualityLevel::Low.degrade(), QualityLevel::Minimum);
335        assert_eq!(QualityLevel::Minimum.degrade(), QualityLevel::Minimum);
336    }
337
338    #[test]
339    fn test_quality_level_upgrade() {
340        assert_eq!(QualityLevel::Minimum.upgrade(), QualityLevel::Low);
341        assert_eq!(QualityLevel::Low.upgrade(), QualityLevel::Medium);
342        assert_eq!(QualityLevel::Medium.upgrade(), QualityLevel::High);
343        assert_eq!(QualityLevel::High.upgrade(), QualityLevel::Maximum);
344        assert_eq!(QualityLevel::Maximum.upgrade(), QualityLevel::Maximum);
345    }
346
347    #[test]
348    fn test_quality_level_substep_multiplier() {
349        assert_eq!(QualityLevel::Minimum.substep_multiplier(), 1);
350        assert_eq!(QualityLevel::Low.substep_multiplier(), 2);
351        assert_eq!(QualityLevel::Medium.substep_multiplier(), 4);
352        assert_eq!(QualityLevel::High.substep_multiplier(), 8);
353        assert_eq!(QualityLevel::Maximum.substep_multiplier(), 16);
354    }
355
356    #[test]
357    fn test_heijunka_config_default() {
358        let config = HeijunkaConfig::default();
359        assert!((config.frame_budget_ms - 16.0).abs() < 1e-10);
360        assert!((config.physics_budget_fraction - 0.5).abs() < 1e-10);
361        assert!(config.auto_adjust_quality);
362    }
363
364    #[test]
365    fn test_heijunka_config_physics_budget() {
366        let config = HeijunkaConfig::default();
367        let physics_budget = config.physics_budget_ms();
368        assert!((physics_budget - 8.0).abs() < 1e-10);
369    }
370
371    #[test]
372    fn test_heijunka_scheduler_creation() {
373        let config = HeijunkaConfig::default();
374        let scheduler = HeijunkaScheduler::new(config);
375        assert_eq!(scheduler.quality(), QualityLevel::High);
376    }
377
378    #[test]
379    fn test_heijunka_scheduler_set_quality() {
380        let config = HeijunkaConfig::default();
381        let mut scheduler = HeijunkaScheduler::new(config);
382
383        scheduler.set_quality(QualityLevel::Low);
384        assert_eq!(scheduler.quality(), QualityLevel::Low);
385    }
386
387    #[test]
388    fn test_heijunka_execute_frame() {
389        let mut config = HeijunkaConfig::default();
390        config.frame_budget_ms = 1000.0; // Large budget for test
391        config.max_substeps = 4;
392
393        let mut scheduler = HeijunkaScheduler::new(config);
394        let mut state = create_test_state();
395
396        let result = scheduler.execute_frame(&mut state).expect("frame failed");
397
398        assert!(result.substeps > 0);
399        assert!(result.sim_time_advanced > 0.0);
400        assert!(result.physics_time_ms >= 0.0);
401    }
402
403    #[test]
404    fn test_heijunka_status_update() {
405        let mut config = HeijunkaConfig::default();
406        config.frame_budget_ms = 1000.0;
407        config.max_substeps = 2;
408
409        let mut scheduler = HeijunkaScheduler::new(config);
410        let mut state = create_test_state();
411
412        scheduler.execute_frame(&mut state).expect("frame failed");
413
414        let status = scheduler.status();
415        assert!((status.budget_ms - 500.0).abs() < 1e-10); // 50% of 1000
416        assert!(status.used_ms >= 0.0);
417        assert!(status.substeps > 0);
418    }
419
420    #[test]
421    fn test_heijunka_frame_result() {
422        let result = FrameResult {
423            substeps: 4,
424            physics_time_ms: 5.0,
425            quality: QualityLevel::High,
426            budget_exceeded: false,
427            sim_time_advanced: 14400.0,
428        };
429
430        assert_eq!(result.substeps, 4);
431        assert!(!result.budget_exceeded);
432    }
433
434    #[test]
435    fn test_heijunka_estimate_substeps() {
436        let config = HeijunkaConfig::default();
437        let scheduler = HeijunkaScheduler::new(config);
438        let state = create_test_state();
439
440        let estimate = scheduler.estimate_substeps(&state);
441        assert!(estimate > 0);
442    }
443
444    #[test]
445    fn test_heijunka_auto_quality_disabled() {
446        let mut config = HeijunkaConfig::default();
447        config.auto_adjust_quality = false;
448        config.frame_budget_ms = 1000.0;
449
450        let mut scheduler = HeijunkaScheduler::new(config);
451        let mut state = create_test_state();
452
453        let initial_quality = scheduler.quality();
454
455        for _ in 0..20 {
456            scheduler.execute_frame(&mut state).expect("frame failed");
457        }
458
459        // Quality should not change when auto-adjust is disabled
460        assert_eq!(scheduler.quality(), initial_quality);
461    }
462}