Skip to main content

hydra_engine_wds/simulation/
engine.rs

1// simulation — §8 of crates/simulation/spec.md
2//
3// The public-facing API of hydra. Exposes the full simulation lifecycle:
4// create → load → run/step hydraulics → run/step quality → get results →
5// destroy. No I/O is performed here; all I/O is the responsibility of adapters.
6
7use std::{f64, time::SystemTime};
8
9use super::accounting::{self, AccountingState};
10use super::controls;
11use super::timestep;
12use crate::hydraulics::{self as hydraulics, SolveResult, SolverContext};
13use crate::io::HydSnapshot;
14use crate::quality::{self as quality, QualityState};
15use crate::{
16    FavadCoeffs, FlowUnits, LinkKind, LinkState, LinkStatus, Network, NodeKind, NodeState,
17    QualityMode,
18};
19
20#[path = "lifecycle.rs"]
21mod lifecycle;
22#[path = "mutation.rs"]
23mod mutation;
24#[path = "results.rs"]
25mod results;
26pub use results::{LinkResult, NodeResult, ResultRanges};
27#[path = "types.rs"]
28mod types;
29#[path = "writable.rs"]
30mod writable;
31
32use types::Phase;
33pub use types::{
34    LinkProperty, LinkQuantity, NodeProperty, NodeQuantity, SessionError, SimWarning, WarningKind,
35};
36
37// ── Session ───────────────────────────────────────────────────────────────────
38
39/// A simulation session: owns network, solver context, results, and accounting.
40///
41/// Sessions are not thread-safe with respect to themselves (§8.3 invariants).
42/// Multiple independent sessions may coexist in the same process.
43pub struct Simulation {
44    phase: Phase,
45
46    // Loaded network + derived context.
47    network: Option<Network>,
48    favad: Option<FavadCoeffs>,
49    solver_ctx: Option<SolverContext>,
50
51    // Live simulation state.
52    node_states: Vec<NodeState>,
53    link_states: Vec<LinkState>,
54    current_t: f64,
55    next_report_t: f64,  // next report time boundary
56    report_count: usize, // number of report boundaries passed
57
58    // Hydraulic result history.
59    hyd_snapshots: Vec<HydSnapshot>,
60
61    // Quality.
62    quality_state: Option<QualityState>,
63    quality_t: f64,
64
65    // Accounting.
66    accounting: Option<AccountingState>,
67
68    // Warnings.
69    warnings: Vec<SimWarning>,
70    /// Tracks which nodes have already emitted a NegativePressure warning
71    /// to avoid O(N×T) accumulation — only the first occurrence is stored.
72    neg_pressure_seen: Vec<bool>,
73
74    // Wall-clock timestamps for the report.
75    analysis_begun: Option<SystemTime>,
76    analysis_ended: Option<SystemTime>,
77}
78
79// ── Session internal helpers ──────────────────────────────────────────────────
80
81impl Simulation {
82    fn require_phase(&self, expected: Phase) -> Result<(), SessionError> {
83        if self.phase != expected {
84            Err(SessionError::InvalidPhase {
85                expected: expected.name().to_string(),
86                actual: self.phase.name().to_string(),
87            })
88        } else {
89            Ok(())
90        }
91    }
92
93    fn require_loaded_network(&self) -> Result<&Network, SessionError> {
94        self.network
95            .as_ref()
96            .ok_or_else(|| SessionError::InvalidPhase {
97                expected: "Loaded".into(),
98                actual: Phase::Created.name().to_string(),
99            })
100    }
101
102    // ── Snapshot helpers ──────────────────────────────────────────────────────
103
104    /// Record a snapshot at `new_t`.
105    ///
106    /// With quality enabled, snapshots are recorded at every hydraulic step so
107    /// the quality engine can observe intermediate flow-field changes.
108    /// With quality disabled, snapshots are recorded only at report boundaries
109    /// to avoid retaining O(steps) cloned state that is never consumed.
110    fn maybe_record_snapshot(&mut self, new_t: f64) {
111        let network = match &self.network {
112            Some(n) => n,
113            None => return,
114        };
115        let duration = network.options.duration;
116        if new_t > duration + 1e-6 {
117            return;
118        }
119
120        let quality_enabled = network.options.quality_mode != QualityMode::None;
121        let at_or_past_report = new_t >= self.next_report_t - 1e-6;
122        if quality_enabled || at_or_past_report {
123            self.hyd_snapshots.push(HydSnapshot {
124                t: new_t,
125                node_states: self.node_states.clone(),
126                link_states: self.link_states.clone(),
127            });
128        }
129
130        // Advance the report-time marker independently of snapshot count.
131        let report_step = network.options.report_step;
132        let report_start = network.options.report_start;
133        while new_t >= self.next_report_t - 1e-6 && self.next_report_t <= duration + 1e-6 {
134            self.report_count += 1;
135            self.next_report_t = report_start + report_step * (self.report_count as f64);
136        }
137    }
138
139    /// Find the snapshot closest to `t` (within 0.5 s tolerance).
140    ///
141    /// Uses binary search — snapshots are always in ascending time order.
142    fn snapshot_near(&self, t: f64) -> Option<&HydSnapshot> {
143        if self.hyd_snapshots.is_empty() {
144            return None;
145        }
146        // Binary search for the insertion point, then check the immediate neighbours.
147        let idx = self.hyd_snapshots.partition_point(|s| s.t < t);
148        let candidates = [idx.checked_sub(1), Some(idx)]
149            .into_iter()
150            .flatten()
151            .filter(|&i| i < self.hyd_snapshots.len());
152        candidates
153            .min_by(|&a, &b| {
154                let da = (self.hyd_snapshots[a].t - t).abs();
155                let db = (self.hyd_snapshots[b].t - t).abs();
156                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
157            })
158            .map(|i| &self.hyd_snapshots[i])
159            .filter(|s| (s.t - t).abs() < 0.5)
160    }
161
162    /// Find the snapshot whose time matches `t` (within 0.5 s tolerance).
163    fn find_snapshot_index_at(&self, t: f64) -> Option<usize> {
164        self.hyd_snapshots
165            .iter()
166            .position(|s| (s.t - t).abs() < 0.5)
167    }
168
169    /// Return initial states from the first snapshot (or live states if no
170    /// snapshot was recorded yet) without cloning.
171    fn first_snapshot_states(&self) -> (&[NodeState], &[LinkState]) {
172        match self.hyd_snapshots.first() {
173            Some(s) => (&s.node_states, &s.link_states),
174            None => (&self.node_states, &self.link_states),
175        }
176    }
177}
178
179// ── Free-standing helpers ─────────────────────────────────────────────────────
180
181/// Initialise node states from the static network (§2.4).
182fn init_node_states(network: &Network) -> Vec<NodeState> {
183    network
184        .nodes
185        .iter()
186        .map(|n| {
187            let mut ns = NodeState::default();
188            // Initial head: 0.0 for junctions (matching EPANET's calloc-zeroed NodeHead),
189            // elevation for reservoirs, or head_from_level for tanks.
190            ns.head = match &n.kind {
191                NodeKind::Junction(_) => 0.0,
192                NodeKind::Reservoir(_) => n.base.elevation,
193                NodeKind::Tank(t) => t.head_from_level(n.base.elevation, t.initial_level),
194            };
195            ns.level = match &n.kind {
196                NodeKind::Tank(t) => t.initial_level,
197                _ => 0.0,
198            };
199            ns.volume = match &n.kind {
200                NodeKind::Tank(t) => {
201                    // Use volume curve if present, otherwise π r² h.
202                    if let Some(ref cv_id) = t.volume_curve {
203                        if let Some(curve) = network.curves.iter().find(|c| c.id == *cv_id) {
204                            return NodeState {
205                                head: ns.head,
206                                level: t.initial_level,
207                                volume: curve.eval(t.initial_level),
208                                quality: n.base.initial_quality,
209                                ..NodeState::default()
210                            };
211                        }
212                    }
213                    std::f64::consts::PI * (t.diameter / 2.0).powi(2) * t.initial_level
214                }
215                _ => 0.0,
216            };
217            ns.quality = n.base.initial_quality;
218            ns
219        })
220        .collect()
221}
222
223/// Initialise link states from static network data (§2.6).
224fn init_link_states(network: &Network) -> Vec<LinkState> {
225    network
226        .links
227        .iter()
228        .map(|l| {
229            let flow = if l.base.initial_status == LinkStatus::Closed {
230                1.0e-6 // QZERO
231            } else {
232                match &l.kind {
233                    LinkKind::Pipe(pipe) => {
234                        // Flow at 1 fps velocity = cross-section area (§3.1).
235                        std::f64::consts::PI * pipe.diameter * pipe.diameter / 4.0
236                    }
237                    LinkKind::Pump(pump) => {
238                        let speed = l.base.initial_setting.unwrap_or(1.0);
239                        let q0 = pump_design_flow(pump, &network.curves);
240                        speed * q0
241                    }
242                    LinkKind::Valve(v) => {
243                        // Same as pipe: area at 1 fps.
244                        std::f64::consts::PI * v.diameter * v.diameter / 4.0
245                    }
246                }
247            };
248            // EPANET inithyd: for non-GPV valves with InitStatus != Active,
249            // setting is cleared to MISSING (NaN), preventing automatic status
250            // transitions. Then, PRV/PSV/FCV with a surviving (non-None)
251            // setting are forced Active. GPV always starts Open.
252            let mut status = l.base.initial_status;
253            let mut setting = l.base.initial_setting;
254            if let LinkKind::Valve(v) = &l.kind {
255                if v.valve_type == crate::ValveType::Gpv {
256                    // GPV: always Open (EPANET never sets GPV to Active).
257                    status = LinkStatus::Open;
258                } else {
259                    if status != LinkStatus::Active {
260                        setting = None;
261                    }
262                    if matches!(
263                        v.valve_type,
264                        crate::ValveType::Prv | crate::ValveType::Psv | crate::ValveType::Fcv
265                    ) && setting.is_some()
266                    {
267                        status = LinkStatus::Active;
268                    }
269                }
270            }
271            LinkState {
272                flow,
273                status,
274                setting: setting.unwrap_or(f64::NAN),
275                quality: 0.0,
276                reaction_rate: 0.0,
277            }
278        })
279        .collect()
280}
281
282/// Compute the design flow Q0 for a pump (spec §3.10).
283///
284/// - PowerFunction: Q0 = middle curve point flow (q1).
285/// - Custom: Q0 = midpoint of curve flow range.
286/// - ConstHp: Q0 = 0.028317 m³/s (= 1 ft³/s fixed initial guess, spec §3.10).
287fn pump_design_flow(pump: &crate::Pump, curves: &[crate::Curve]) -> f64 {
288    match pump.curve_type {
289        crate::PumpCurveType::ConstHp => 0.028317,
290        crate::PumpCurveType::PowerFunction => {
291            if let Some(ref cid) = pump.head_curve {
292                if let Some(curve) = curves.iter().find(|c| &c.id == cid) {
293                    if curve.points.len() >= 3 {
294                        return curve.points[1].x; // q1 design point
295                    } else if !curve.points.is_empty() {
296                        return curve.points[0].x;
297                    }
298                }
299            }
300            0.028317 // fallback: 1 ft³/s in m³/s (spec §3.10)
301        }
302        crate::PumpCurveType::Custom => {
303            if let Some(ref cid) = pump.head_curve {
304                if let Some(curve) = curves.iter().find(|c| &c.id == cid) {
305                    if curve.points.len() >= 2 {
306                        let first = curve.points.first().unwrap().x;
307                        let last = curve.points.last().unwrap().x;
308                        return (first + last) / 2.0;
309                    }
310                }
311            }
312            0.028317 // fallback: 1 ft³/s in m³/s (spec §3.10)
313        }
314    }
315}
316
317/// Find a node's 0-based index by string ID.
318fn node_index_by_id(network: &Network, id: &str) -> Option<usize> {
319    network.nodes.iter().position(|n| n.base.id == id)
320}
321
322/// Find a link's 0-based index by string ID.
323fn link_index_by_id(network: &Network, id: &str) -> Option<usize> {
324    network.links.iter().position(|l| l.base.id == id)
325}
326
327fn link_status_to_f64(status: LinkStatus) -> f64 {
328    match status {
329        LinkStatus::Closed | LinkStatus::XPressure | LinkStatus::XHead | LinkStatus::TempClosed => {
330            0.0
331        }
332        LinkStatus::Open => 1.0,
333        LinkStatus::Active | LinkStatus::XFcv => 2.0,
334    }
335}
336
337// ── Tests ─────────────────────────────────────────────────────────────────────
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::{
343        DemandCategory, HeadLossFormula, Junction, Link, LinkBase, LinkKind, Node, NodeBase,
344        NodeKind, Pipe, Reservoir, SimulationOptions,
345    };
346
347    /// Two-node (reservoir + junction), one-pipe network. No tanks, no pumps.
348    fn simple_network() -> Network {
349        let options = SimulationOptions {
350            duration: 3600.0,
351            hyd_step: 3600.0,
352            report_step: 3600.0,
353            report_start: 0.0,
354            ..SimulationOptions::default()
355        };
356        Network {
357            title: vec![],
358            options,
359            patterns: vec![],
360            curves: vec![],
361            nodes: vec![
362                Node {
363                    base: NodeBase {
364                        id: "R1".into(),
365                        index: 1,
366                        elevation: 100.0,
367                        initial_quality: 0.0,
368                    },
369                    kind: NodeKind::Reservoir(Reservoir { head_pattern: None }),
370                    source: None,
371                },
372                Node {
373                    base: NodeBase {
374                        id: "J1".into(),
375                        index: 2,
376                        elevation: 0.0,
377                        initial_quality: 0.0,
378                    },
379                    kind: NodeKind::Junction(Junction {
380                        demands: vec![DemandCategory {
381                            base_demand: 0.01,
382                            pattern: None,
383                            name: None,
384                        }],
385                        emitter_coeff: 0.0,
386                        emitter_exp: 0.5,
387                    }),
388                    source: None,
389                },
390            ],
391            links: vec![Link {
392                base: LinkBase {
393                    id: "P1".into(),
394                    index: 1,
395                    from_node: 1,
396                    to_node: 2,
397                    initial_status: LinkStatus::Open,
398                    initial_setting: Some(1.0),
399                },
400                kind: LinkKind::Pipe(Pipe {
401                    length: 1000.0,
402                    diameter: 0.3,
403                    roughness: 100.0,
404                    minor_loss: 0.0,
405                    check_valve: false,
406                    bulk_coeff: None,
407                    wall_coeff: None,
408                    leak_coeff_1: 0.0,
409                    leak_coeff_2: 0.0,
410                }),
411            }],
412            controls: vec![],
413            rules: vec![],
414            pattern_index: std::collections::HashMap::new(),
415            report: crate::ReportOptions::default(),
416            coordinates: std::collections::HashMap::new(),
417            vertices: std::collections::HashMap::new(),
418            node_tags: std::collections::HashMap::new(),
419            link_tags: std::collections::HashMap::new(),
420        }
421    }
422
423    #[test]
424    fn session_create_and_load() {
425        let mut sess = Simulation::create();
426        assert_eq!(sess.phase, Phase::Created);
427        sess.load(simple_network()).expect("load failed");
428        assert_eq!(sess.phase, Phase::Loaded);
429    }
430
431    #[test]
432    fn session_run_hydraulics_completes() {
433        let mut sess = Simulation::create();
434        sess.load(simple_network()).expect("load failed");
435        sess.run_hydraulics().expect("run_hydraulics failed");
436        assert_eq!(sess.phase, Phase::HydraulicsDone);
437    }
438
439    #[test]
440    fn session_snapshot_recorded_after_step() {
441        let mut sess = Simulation::create();
442        sess.load(simple_network()).expect("load failed");
443        sess.run_hydraulics().expect("run_hydraulics failed");
444        // At least one snapshot should be recorded.
445        assert!(!sess.hyd_snapshots.is_empty());
446    }
447
448    #[test]
449    fn get_node_head_after_hydraulics() {
450        let mut sess = Simulation::create();
451        sess.load(simple_network()).expect("load failed");
452        sess.run_hydraulics().expect("run_hydraulics failed");
453        // Get node result at first snapshot time.
454        let snap_t = sess.hyd_snapshots[0].t;
455        let head = sess
456            .get_node_result("R1", NodeQuantity::Head, snap_t)
457            .expect("get_node_result failed");
458        // Reservoir head should be its elevation (100 ft).
459        assert!((head - 100.0).abs() < 1.0, "head = {head}");
460    }
461
462    #[test]
463    fn get_link_flow_after_hydraulics() {
464        let mut sess = Simulation::create();
465        sess.load(simple_network()).expect("load failed");
466        sess.run_hydraulics().expect("run_hydraulics failed");
467        let snap_t = sess.hyd_snapshots[0].t;
468        let flow = sess
469            .get_link_result("P1", LinkQuantity::Flow, snap_t)
470            .expect("get_link_result failed");
471        // Flow must be non-negative (demand-driven network).
472        assert!(flow >= 0.0, "flow = {flow}");
473    }
474
475    #[test]
476    fn friction_factor_zero_for_non_darcy_weisbach() {
477        let mut sess = Simulation::create();
478        sess.load(simple_network()).expect("load failed");
479        sess.run_hydraulics().expect("run_hydraulics failed");
480        let snap_t = sess.hyd_snapshots[0].t;
481        let friction_factor = sess
482            .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
483            .expect("get_link_result failed");
484
485        assert_eq!(friction_factor, 0.0);
486    }
487
488    #[test]
489    fn friction_factor_positive_for_darcy_weisbach_pipe() {
490        let mut network = simple_network();
491        network.options.head_loss_formula = HeadLossFormula::DarcyWeisbach;
492
493        let mut sess = Simulation::create();
494        sess.load(network).expect("load failed");
495        sess.run_hydraulics().expect("run_hydraulics failed");
496        let snap_t = sess.hyd_snapshots[0].t;
497        let friction_factor = sess
498            .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
499            .expect("get_link_result failed");
500
501        assert!(
502            friction_factor.is_finite(),
503            "friction_factor = {friction_factor}"
504        );
505        assert!(friction_factor > 0.0, "friction_factor = {friction_factor}");
506    }
507
508    #[test]
509    fn friction_factor_zero_for_zero_flow_pipe() {
510        let mut network = simple_network();
511        network.options.head_loss_formula = HeadLossFormula::DarcyWeisbach;
512        if let NodeKind::Junction(junction) = &mut network.nodes[1].kind {
513            junction.demands[0].base_demand = 0.0;
514        }
515
516        let mut sess = Simulation::create();
517        sess.load(network).expect("load failed");
518        sess.run_hydraulics().expect("run_hydraulics failed");
519        let snap_t = sess.hyd_snapshots[0].t;
520        let friction_factor = sess
521            .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
522            .expect("get_link_result failed");
523
524        assert_eq!(friction_factor, 0.0);
525    }
526
527    #[test]
528    fn unknown_node_id_returns_error() {
529        let mut sess = Simulation::create();
530        sess.load(simple_network()).expect("load");
531        sess.run_hydraulics().unwrap();
532        let t = sess.hyd_snapshots[0].t;
533        let err = sess.get_node_result("ZZZZ", NodeQuantity::Head, t);
534        assert!(matches!(err, Err(SessionError::UnknownId(_))));
535    }
536
537    #[test]
538    fn wrong_phase_returns_error() {
539        let mut sess = Simulation::create();
540        // run_hydraulics without load
541        let err = sess.run_hydraulics();
542        assert!(matches!(err, Err(SessionError::InvalidPhase { .. })));
543    }
544
545    #[test]
546    fn set_link_property_changes_roughness() {
547        let mut sess = Simulation::create();
548        sess.load(simple_network()).expect("load");
549        sess.set_link_property("P1", LinkProperty::Roughness, 50.0)
550            .expect("set_link_property");
551        let network = sess.network.as_ref().unwrap();
552        if let LinkKind::Pipe(p) = &network.links[0].kind {
553            assert!((p.roughness - 50.0).abs() < 1e-10);
554        } else {
555            panic!("expected pipe");
556        }
557    }
558
559    #[test]
560    fn flow_balance_accessible_after_hydraulics() {
561        let mut sess = Simulation::create();
562        sess.load(simple_network()).expect("load");
563        sess.run_hydraulics().unwrap();
564        let fb = sess.get_flow_balance().expect("get_flow_balance");
565        // After a full run the balance ratio should be close to 1.
566        let ratio = fb.balance_ratio(
567            sess.node_states
568                .iter()
569                .enumerate()
570                .filter_map(|(i, ns)| {
571                    if matches!(
572                        sess.network.as_ref().unwrap().nodes[i].kind,
573                        NodeKind::Tank(_)
574                    ) {
575                        Some(ns.volume)
576                    } else {
577                        None
578                    }
579                })
580                .sum::<f64>(),
581        );
582        // No tanks → numerator/denominator = outflow/inflow ≈ 1.
583        assert!(ratio >= 0.0);
584    }
585
586    #[test]
587    fn step_quality_direct_loop_terminates() {
588        // Regression test for the runaway quality loop bug: calling step_quality()
589        // directly (without run_quality()) must initialise quality state on the
590        // first call and terminate normally when quality_t reaches duration.
591        let mut net = simple_network();
592        net.options.duration = 2.0 * 3600.0;
593        net.options.hyd_step = 3600.0;
594        net.options.qual_step = 360.0;
595        net.options.report_step = 3600.0;
596        net.options.report_start = 0.0;
597        net.options.quality_mode = QualityMode::Age;
598
599        let mut sess = Simulation::create();
600        sess.load(net).expect("load");
601        sess.run_hydraulics().expect("run_hydraulics");
602
603        // Drive quality via step_quality, exactly as the CLI progress loop does.
604        let mut steps = 0usize;
605        let mut total_t = 0.0_f64;
606        loop {
607            let dt = sess.step_quality().expect("step_quality");
608            if dt == 0.0 {
609                break;
610            }
611            total_t += dt;
612            steps += 1;
613            assert!(
614                steps < 1000,
615                "step_quality did not terminate within 1000 steps"
616            );
617        }
618
619        assert_eq!(sess.phase, Phase::QualityDone);
620        assert!((total_t - 2.0 * 3600.0).abs() < 1.0, "total_t = {total_t}");
621    }
622
623    #[test]
624    fn step_quality_and_run_quality_produce_same_results() {
625        // Regression test: step_quality loop must produce the same quality
626        // values as run_quality, ensuring lazy-init in step_quality is correct.
627        let mut net = simple_network();
628        net.options.duration = 2.0 * 3600.0;
629        net.options.hyd_step = 3600.0;
630        net.options.qual_step = 360.0;
631        net.options.report_step = 3600.0;
632        net.options.report_start = 0.0;
633        net.options.quality_mode = QualityMode::Age;
634
635        // Session A: use run_quality().
636        let mut sess_a = Simulation::create();
637        sess_a.load(net.clone()).expect("load");
638        sess_a.run_hydraulics().expect("run_hydraulics");
639        sess_a.run_quality().expect("run_quality");
640
641        // Session B: drive quality via step_quality loop (CLI-style).
642        let mut sess_b = Simulation::create();
643        sess_b.load(net).expect("load");
644        sess_b.run_hydraulics().expect("run_hydraulics");
645        loop {
646            let dt = sess_b.step_quality().expect("step_quality");
647            if dt == 0.0 {
648                break;
649            }
650        }
651
652        // Both sessions must produce the same quality at every snapshot.
653        let times_a = sess_a.snapshot_times();
654        let times_b = sess_b.snapshot_times();
655        assert_eq!(times_a, times_b);
656        for &t in &times_a {
657            let q_a = sess_a
658                .get_node_result("J1", NodeQuantity::Quality, t)
659                .unwrap();
660            let q_b = sess_b
661                .get_node_result("J1", NodeQuantity::Quality, t)
662                .unwrap();
663            assert!(
664                (q_a - q_b).abs() < 1e-9,
665                "quality mismatch at t={t}: run_quality={q_a}, step_quality={q_b}"
666            );
667        }
668    }
669
670    #[test]
671    fn step_quality_returns_zero_immediately_when_quality_none() {
672        // When quality mode is None, step_quality must return 0.0 immediately
673        // and transition to QualityDone — it must not loop indefinitely.
674        let mut sess = Simulation::create();
675        sess.load(simple_network()).expect("load");
676        sess.run_hydraulics().expect("run_hydraulics");
677        let dt = sess.step_quality().expect("step_quality");
678        assert_eq!(dt, 0.0);
679        assert_eq!(sess.phase, Phase::QualityDone);
680    }
681
682    // ── Additional results-coverage tests ────────────────────────────────────
683
684    #[test]
685    fn mean_velocity_positive_for_flowing_pipe() {
686        let mut sess = Simulation::create();
687        sess.load(simple_network()).expect("load");
688        sess.run_hydraulics().expect("run_hydraulics");
689        let t = sess.hyd_snapshots[0].t;
690        let v = sess
691            .get_link_result("P1", LinkQuantity::MeanVelocity, t)
692            .expect("get_link_result");
693        assert!(v > 0.0, "expected positive velocity, got {v}");
694    }
695
696    #[test]
697    fn unit_head_loss_positive_for_flowing_pipe() {
698        let mut sess = Simulation::create();
699        sess.load(simple_network()).expect("load");
700        sess.run_hydraulics().expect("run_hydraulics");
701        let t = sess.hyd_snapshots[0].t;
702        let uhl = sess
703            .get_link_result("P1", LinkQuantity::UnitHeadLoss, t)
704            .expect("get_link_result");
705        assert!(uhl > 0.0, "expected positive unit head loss, got {uhl}");
706    }
707
708    #[test]
709    fn link_status_open_returns_one() {
710        let mut sess = Simulation::create();
711        sess.load(simple_network()).expect("load");
712        sess.run_hydraulics().expect("run_hydraulics");
713        let t = sess.hyd_snapshots[0].t;
714        let status = sess
715            .get_link_result("P1", LinkQuantity::Status, t)
716            .expect("get_link_result");
717        // Pipe is Open → encoding 1.0.
718        assert_eq!(status, 1.0);
719    }
720
721    #[test]
722    fn link_setting_returns_setting_for_pipe() {
723        let mut sess = Simulation::create();
724        sess.load(simple_network()).expect("load");
725        sess.run_hydraulics().expect("run_hydraulics");
726        let t = sess.hyd_snapshots[0].t;
727        let setting = sess
728            .get_link_result("P1", LinkQuantity::Setting, t)
729            .expect("get_link_result");
730        // Pipe initial_setting = 1.0; roughness-based pipes pass setting through.
731        assert!(setting.is_finite(), "setting = {setting}");
732    }
733
734    #[test]
735    fn gauge_pressure_for_junction_equals_head_minus_elevation() {
736        let mut sess = Simulation::create();
737        sess.load(simple_network()).expect("load");
738        sess.run_hydraulics().expect("run_hydraulics");
739        let t = sess.hyd_snapshots[0].t;
740        let head = sess
741            .get_node_result("J1", NodeQuantity::Head, t)
742            .expect("head");
743        let gp = sess
744            .get_node_result("J1", NodeQuantity::GaugePressure, t)
745            .expect("gauge_pressure");
746        // J1 elevation = 0.0, so GaugePressure = Head − 0.
747        assert!((gp - head).abs() < 1e-9, "gp={gp}, head={head}");
748    }
749
750    #[test]
751    fn demand_for_reservoir_returns_net_flow() {
752        let mut sess = Simulation::create();
753        sess.load(simple_network()).expect("load");
754        sess.run_hydraulics().expect("run_hydraulics");
755        let t = sess.hyd_snapshots[0].t;
756        let demand = sess
757            .get_node_result("R1", NodeQuantity::Demand, t)
758            .expect("demand");
759        // Reservoir net_flow should be negative (outflow to supply junction).
760        assert!(demand < 0.0, "reservoir net_flow = {demand}");
761    }
762
763    #[test]
764    fn no_snapshot_at_time_returns_error() {
765        let mut sess = Simulation::create();
766        sess.load(simple_network()).expect("load");
767        sess.run_hydraulics().expect("run_hydraulics");
768        let err = sess.get_node_result("J1", NodeQuantity::Head, 999_999.0);
769        assert!(matches!(err, Err(SessionError::NoSnapshotAtTime { .. })));
770    }
771
772    #[test]
773    fn node_ids_empty_before_load() {
774        let sess = Simulation::create();
775        assert!(sess.node_ids().is_empty());
776    }
777
778    #[test]
779    fn link_ids_empty_before_load() {
780        let sess = Simulation::create();
781        assert!(sess.link_ids().is_empty());
782    }
783
784    #[test]
785    fn pump_ids_empty_before_load() {
786        let sess = Simulation::create();
787        assert!(sess.pump_ids().is_empty());
788    }
789
790    #[test]
791    fn flow_units_none_before_load() {
792        let sess = Simulation::create();
793        assert!(sess.flow_units().is_none());
794    }
795
796    #[test]
797    fn get_pump_energy_error_for_non_pump() {
798        let mut sess = Simulation::create();
799        sess.load(simple_network()).expect("load");
800        sess.run_hydraulics().expect("run_hydraulics");
801        // "P1" is a pipe, not a pump; expect UnknownId.
802        let err = sess.get_pump_energy("P1");
803        assert!(matches!(err, Err(SessionError::UnknownId(_))));
804    }
805
806    #[test]
807    fn link_status_to_f64_encoding() {
808        assert_eq!(link_status_to_f64(LinkStatus::Open), 1.0);
809        assert_eq!(link_status_to_f64(LinkStatus::Closed), 0.0);
810        assert_eq!(link_status_to_f64(LinkStatus::TempClosed), 0.0);
811        assert_eq!(link_status_to_f64(LinkStatus::XHead), 0.0);
812        assert_eq!(link_status_to_f64(LinkStatus::Active), 2.0);
813        assert_eq!(link_status_to_f64(LinkStatus::XFcv), 2.0);
814    }
815
816    // ── from_network / mutation coverage ─────────────────────────────────────
817
818    #[test]
819    fn from_network_succeeds_with_valid_network() {
820        let sess = Simulation::from_network(simple_network()).expect("from_network");
821        assert_eq!(sess.phase, Phase::Loaded);
822    }
823
824    #[test]
825    fn set_node_property_elevation_changes_elevation() {
826        let mut sess = Simulation::create();
827        sess.load(simple_network()).expect("load");
828        sess.set_node_property("J1", NodeProperty::Elevation, 25.0)
829            .expect("set_node_property");
830        let elev = sess.network.as_ref().unwrap().nodes[1].base.elevation;
831        assert!((elev - 25.0).abs() < 1e-10);
832    }
833
834    #[test]
835    fn set_node_property_initial_quality_changes_quality() {
836        let mut sess = Simulation::create();
837        sess.load(simple_network()).expect("load");
838        sess.set_node_property("J1", NodeProperty::InitialQuality, 0.8)
839            .expect("set_node_property");
840        let iq = sess.network.as_ref().unwrap().nodes[1].base.initial_quality;
841        assert!((iq - 0.8).abs() < 1e-10);
842    }
843
844    #[test]
845    fn set_link_property_initial_status_closes_link() {
846        let mut sess = Simulation::create();
847        sess.load(simple_network()).expect("load");
848        sess.set_link_property("P1", LinkProperty::InitialStatus, 0.0)
849            .expect("set_link_property");
850        let status = sess.network.as_ref().unwrap().links[0].base.initial_status;
851        assert_eq!(status, LinkStatus::Closed);
852    }
853
854    #[test]
855    fn set_link_property_initial_setting_changes_setting() {
856        let mut sess = Simulation::create();
857        sess.load(simple_network()).expect("load");
858        sess.set_link_property("P1", LinkProperty::InitialSetting, 1.5)
859            .expect("set_link_property");
860        let setting = sess.network.as_ref().unwrap().links[0].base.initial_setting;
861        assert_eq!(setting, Some(1.5));
862    }
863
864    #[test]
865    fn set_node_property_unknown_id_returns_error() {
866        let mut sess = Simulation::create();
867        sess.load(simple_network()).expect("load");
868        let err = sess.set_node_property("ZZZZ", NodeProperty::Elevation, 1.0);
869        assert!(matches!(err, Err(SessionError::UnknownId(_))));
870    }
871
872    #[test]
873    fn set_node_property_before_load_returns_invalid_phase() {
874        let mut sess = Simulation::create();
875        let err = sess.set_node_property("J1", NodeProperty::Elevation, 1.0);
876        assert!(matches!(err, Err(SessionError::InvalidPhase { .. })));
877    }
878
879    #[test]
880    fn peak_demand_cost_is_zero_when_no_pumps() {
881        let mut sess = Simulation::create();
882        sess.load(simple_network()).expect("load");
883        sess.run_hydraulics().expect("run_hydraulics");
884        // No pumps in simple_network, so peak demand cost is 0.
885        assert_eq!(sess.peak_demand_cost(), 0.0);
886    }
887
888    #[test]
889    fn snapshots_are_report_only_when_quality_none() {
890        let mut net = simple_network();
891        net.options.duration = 3.0 * 3600.0;
892        net.options.hyd_step = 3600.0;
893        net.options.report_step = 2.0 * 3600.0;
894        net.options.report_start = 0.0;
895        net.options.quality_mode = QualityMode::None;
896
897        let mut sess = Simulation::create();
898        sess.load(net).expect("load");
899        sess.run_hydraulics().expect("run_hydraulics");
900        let ts = sess.snapshot_times();
901
902        assert_eq!(ts, vec![0.0, 7200.0]);
903    }
904
905    #[test]
906    fn snapshots_remain_per_step_when_quality_enabled() {
907        let mut net = simple_network();
908        net.options.duration = 3.0 * 3600.0;
909        net.options.hyd_step = 3600.0;
910        net.options.report_step = 2.0 * 3600.0;
911        net.options.report_start = 0.0;
912        net.options.quality_mode = QualityMode::Age;
913
914        let mut sess = Simulation::create();
915        sess.load(net).expect("load");
916        sess.run_hydraulics().expect("run_hydraulics");
917        let ts = sess.snapshot_times();
918
919        assert_eq!(ts, vec![0.0, 3600.0, 7200.0, 10800.0]);
920    }
921}