Skip to main content

hydra_engine_wds/simulation/
estimator.rs

1use crate::{Network, RuntimeEstimate};
2
3const LOW_EFFORT_THRESHOLD_MS: f64 = 600.0;
4const MEDIUM_EFFORT_THRESHOLD_MS: f64 = 3_000.0;
5
6/// Estimate simulation runtime from a fully loaded network.
7///
8/// Advisory only — does not influence time-step selection, convergence
9/// behaviour, or any simulation result. For identical network inputs the
10/// output is deterministic.
11///
12/// Cost model increases monotonically with: hydraulic step count
13/// (duration / hyd_step), network size (nodes + links), mesh density, and
14/// quality step count when quality is enabled. Larger/longer simulations
15/// will not systematically receive lower estimates than smaller/shorter ones.
16pub fn estimate_simulation_runtime(network: &Network) -> RuntimeEstimate {
17    estimate_simulation_runtime_from_summary(
18        network.nodes.len(),
19        network.links.len(),
20        network.options.duration,
21        network.options.hyd_step,
22        network.options.qual_step,
23        network.options.quality_mode != crate::QualityMode::None,
24    )
25}
26
27/// Classify a predicted runtime (milliseconds) into effort buckets.
28pub fn classify_simulation_runtime_millis(predicted_millis: f64) -> RuntimeEstimate {
29    if predicted_millis < LOW_EFFORT_THRESHOLD_MS {
30        RuntimeEstimate::Low
31    } else if predicted_millis < MEDIUM_EFFORT_THRESHOLD_MS {
32        RuntimeEstimate::Medium
33    } else {
34        RuntimeEstimate::High
35    }
36}
37
38/// Estimate simulation runtime in milliseconds from summary metadata.
39///
40/// Inputs: node count, link count, simulation duration, hydraulic time step,
41/// quality time step, and whether quality simulation is enabled. Does not
42/// depend on mutable post-run state — the estimate is stable before and
43/// after executing a simulation on the same network definition.
44pub fn estimate_simulation_runtime_millis_from_summary(
45    node_count: usize,
46    link_count: usize,
47    duration_seconds: f64,
48    hydraulic_timestep_seconds: f64,
49    quality_timestep_seconds: f64,
50    has_quality: bool,
51) -> f64 {
52    let hydraulic_steps =
53        ((duration_seconds.max(0.0) / hydraulic_timestep_seconds.max(1.0)).floor() + 1.0).max(1.0);
54    let quality_steps =
55        ((duration_seconds.max(0.0) / quality_timestep_seconds.max(1.0)).floor() + 1.0).max(1.0);
56    let nodes = node_count.max(1) as f64;
57    let links = link_count.max(1) as f64;
58
59    // Static proxy terms:
60    // 1) setup scales with graph size and mesh density,
61    // 2) hydraulic cost scales with hydraulic step count,
62    // 3) quality cost scales with quality step count when enabled.
63    let mesh_factor = (links / nodes).clamp(0.75, 2.5);
64    let setup_work_ms = 1.2e-6 * (nodes * nodes) * mesh_factor;
65
66    // Warm-step timings were fit from Criterion solve benchmarks and then
67    // translated into a per-step linear proxy on (nodes + links).
68    let hydraulic_step_us = (-2.1 + 0.0266 * (nodes + links)).max(25.0);
69    let hydraulic_work_ms = hydraulic_steps * (hydraulic_step_us / 1_000.0);
70
71    let quality_work_ms = if has_quality {
72        // Keep quality as an additive static term; this preserves monotonicity
73        // with respect to quality step count and topology size.
74        quality_steps * (0.012 * (nodes + links) / 1_000.0)
75    } else {
76        0.0
77    };
78
79    12.0 + setup_work_ms + hydraulic_work_ms + quality_work_ms
80}
81
82/// Estimate simulation runtime from summary metadata.
83pub fn estimate_simulation_runtime_from_summary(
84    node_count: usize,
85    link_count: usize,
86    duration_seconds: f64,
87    hydraulic_timestep_seconds: f64,
88    quality_timestep_seconds: f64,
89    has_quality: bool,
90) -> RuntimeEstimate {
91    classify_simulation_runtime_millis(estimate_simulation_runtime_millis_from_summary(
92        node_count,
93        link_count,
94        duration_seconds,
95        hydraulic_timestep_seconds,
96        quality_timestep_seconds,
97        has_quality,
98    ))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{
104        classify_simulation_runtime_millis, estimate_simulation_runtime_from_summary,
105        estimate_simulation_runtime_millis_from_summary,
106    };
107
108    #[test]
109    fn estimate_increases_with_network_size() {
110        let small =
111            estimate_simulation_runtime_from_summary(200, 250, 86_400.0, 3_600.0, 300.0, false);
112        let large =
113            estimate_simulation_runtime_from_summary(2_000, 2_500, 86_400.0, 3_600.0, 300.0, false);
114        assert!(large >= small);
115    }
116
117    #[test]
118    fn estimate_increases_with_duration() {
119        let short =
120            estimate_simulation_runtime_from_summary(500, 600, 3_600.0, 3_600.0, 300.0, false);
121        let long =
122            estimate_simulation_runtime_from_summary(500, 600, 86_400.0, 3_600.0, 300.0, false);
123        assert!(long >= short);
124    }
125
126    #[test]
127    fn quality_mode_increases_estimate() {
128        let hyd_only =
129            estimate_simulation_runtime_from_summary(800, 900, 86_400.0, 3_600.0, 300.0, false);
130        let with_quality =
131            estimate_simulation_runtime_from_summary(800, 900, 86_400.0, 3_600.0, 300.0, true);
132        assert!(with_quality >= hyd_only);
133    }
134
135    #[test]
136    fn smaller_quality_timestep_increases_estimate_when_quality_enabled() {
137        let coarse_quality =
138            estimate_simulation_runtime_from_summary(1_200, 1_500, 86_400.0, 3_600.0, 900.0, true);
139        let fine_quality =
140            estimate_simulation_runtime_from_summary(1_200, 1_500, 86_400.0, 3_600.0, 60.0, true);
141        assert!(fine_quality >= coarse_quality);
142    }
143
144    #[test]
145    fn very_large_case_maps_to_high_effort() {
146        let estimate =
147            estimate_simulation_runtime_from_summary(80_000, 90_000, 604_800.0, 300.0, 30.0, true);
148        assert_eq!(estimate, crate::RuntimeEstimate::High);
149    }
150
151    #[test]
152    fn predicted_millis_increase_with_network_size() {
153        let small = estimate_simulation_runtime_millis_from_summary(
154            200, 250, 86_400.0, 3_600.0, 300.0, false,
155        );
156        let large = estimate_simulation_runtime_millis_from_summary(
157            2_000, 2_500, 86_400.0, 3_600.0, 300.0, false,
158        );
159        assert!(large >= small);
160    }
161
162    #[test]
163    fn classifier_thresholds_are_stable() {
164        assert_eq!(
165            classify_simulation_runtime_millis(100.0),
166            crate::RuntimeEstimate::Low
167        );
168        assert_eq!(
169            classify_simulation_runtime_millis(1_000.0),
170            crate::RuntimeEstimate::Medium
171        );
172        assert_eq!(
173            classify_simulation_runtime_millis(10_000.0),
174            crate::RuntimeEstimate::High
175        );
176    }
177}