Skip to main content

hydra_engine_wds/simulation/
results.rs

1use super::*;
2use crate::io::{FlowBalance, FlowBalanceSummary, MassBalance, PumpEnergy};
3
4/// Global min/max across all timesteps for each display variable.
5///
6/// All values are in the internal SI unit system: pressure in metres of head,
7/// head in metres, demand and flow in m³/s, velocity in m/s.
8#[derive(Debug, Clone, Default)]
9pub struct ResultRanges {
10    /// Minimum gauge pressure observed at any node across all timesteps (m).
11    pub pressure_min: f64,
12    /// Maximum gauge pressure observed at any node across all timesteps (m).
13    pub pressure_max: f64,
14    /// Minimum hydraulic head observed at any node across all timesteps (m).
15    pub head_min: f64,
16    /// Maximum hydraulic head observed at any node across all timesteps (m).
17    pub head_max: f64,
18    /// Minimum demand observed at any node across all timesteps (m³/s).
19    pub demand_min: f64,
20    /// Maximum demand observed at any node across all timesteps (m³/s).
21    pub demand_max: f64,
22    /// Minimum flow observed at any link across all timesteps (m³/s).
23    pub flow_min: f64,
24    /// Maximum flow observed at any link across all timesteps (m³/s).
25    pub flow_max: f64,
26    /// Minimum velocity observed at any link across all timesteps (m/s).
27    pub velocity_min: f64,
28    /// Maximum velocity observed at any link across all timesteps (m/s).
29    pub velocity_max: f64,
30}
31
32/// Per-node result at a single timestep.
33///
34/// All values are in the internal SI unit system.
35#[derive(Debug, Clone)]
36pub struct NodeResult {
37    /// Total hydraulic head at the node (m above datum).
38    pub head: f64,
39    /// Gauge pressure at the node (m of water column = head − elevation).
40    pub pressure: f64,
41    /// Net demand delivered at or extracted from the node (m³/s).
42    pub demand: f64,
43}
44
45/// Per-link result at a single timestep.
46///
47/// All values are in the internal SI unit system.
48#[derive(Debug, Clone)]
49pub struct LinkResult {
50    /// Volumetric flow rate through the link (m³/s; positive = from-node → to-node).
51    pub flow: f64,
52    /// Mean flow velocity in the link (m/s).
53    pub velocity: f64,
54    /// Head loss across the link (m; positive = from-node head > to-node head).
55    pub head_loss: f64,
56    /// Dimensionless link status flag (0.0 = closed/inactive, 1.0 = open/active).
57    pub status: f64,
58}
59
60impl Simulation {
61    /// Query a single scalar result for a node at (or near) simulation time `t`.
62    ///
63    /// Returns `Err(SessionError::UnknownId)` if `node_id` is not in the network
64    /// and `Err(SessionError::NoSnapshotAtTime)` if no hydraulic snapshot was
65    /// recorded at or near `t`. All returned values are in the internal SI unit
66    /// system (head/pressure in m, demand in m³/s, quality in mg/L or h or %).
67    pub fn get_node_result(
68        &self,
69        node_id: &str,
70        quantity: NodeQuantity,
71        t: f64,
72    ) -> Result<f64, SessionError> {
73        let network = self.require_loaded_network()?;
74        let node_index = node_index_by_id(network, node_id)
75            .ok_or_else(|| SessionError::UnknownId(node_id.to_string()))?;
76        let snapshot = self
77            .snapshot_near(t)
78            .ok_or(SessionError::NoSnapshotAtTime { requested_t: t })?;
79        let node_state = &snapshot.node_states[node_index];
80        let node = &network.nodes[node_index];
81        let elevation = node.base.elevation;
82        Ok(match quantity {
83            NodeQuantity::Head => node_state.head,
84            NodeQuantity::GaugePressure => {
85                // For tanks, elevation has been adjusted by +min_level during
86                // import (so that head = elevation + level works). Physical
87                // elevation for pressure is elevation − min_level.
88                let physical_elevation = match &node.kind {
89                    NodeKind::Tank(tank) => elevation - tank.min_level,
90                    _ => elevation,
91                };
92                node_state.head - physical_elevation
93            }
94            NodeQuantity::Demand => {
95                // Junctions report total demand: consumption + emitter + leakage
96                // (matches EPANET NodeDemand = DemandFlow + EmitterFlow + LeakageFlow).
97                // Tanks and reservoirs report their net inflow (net_flow):
98                // positive = inflow (filling), negative = outflow (supply).
99                match &node.kind {
100                    NodeKind::Junction(_) => {
101                        node_state.demand_flow + node_state.emitter_flow + node_state.leakage_flow
102                    }
103                    NodeKind::Reservoir(_) | NodeKind::Tank(_) => node_state.net_flow,
104                }
105            }
106            NodeQuantity::Quality => node_state.quality,
107        })
108    }
109
110    /// Return a link result quantity at the specified simulation time (§8.2.1).
111    pub fn get_link_result(
112        &self,
113        link_id: &str,
114        quantity: LinkQuantity,
115        t: f64,
116    ) -> Result<f64, SessionError> {
117        let network = self.require_loaded_network()?;
118        let link_index = link_index_by_id(network, link_id)
119            .ok_or_else(|| SessionError::UnknownId(link_id.to_string()))?;
120        let snapshot = self
121            .snapshot_near(t)
122            .ok_or(SessionError::NoSnapshotAtTime { requested_t: t })?;
123        let link_state = &snapshot.link_states[link_index];
124        let link = &network.links[link_index];
125
126        // EPANET forces flow to zero for closed links at output time
127        // (Status <= CLOSED means XHEAD, TEMPCLOSED, CLOSED).
128        let is_closed = matches!(
129            link_state.status,
130            LinkStatus::Closed | LinkStatus::XHead | LinkStatus::TempClosed
131        );
132
133        Ok(match quantity {
134            LinkQuantity::Flow => {
135                if is_closed {
136                    0.0
137                } else {
138                    link_state.flow
139                }
140            }
141            LinkQuantity::MeanVelocity => {
142                if let LinkKind::Pipe(pipe) = &link.kind {
143                    let area = std::f64::consts::PI * (pipe.diameter / 2.0).powi(2);
144                    if area > 0.0 {
145                        link_state.flow.abs() / area
146                    } else {
147                        0.0
148                    }
149                } else {
150                    0.0
151                }
152            }
153            LinkQuantity::UnitHeadLoss => {
154                if let LinkKind::Pipe(pipe) = &link.kind {
155                    let from_node_index = link.base.from_idx();
156                    let to_node_index = link.base.to_idx();
157                    let head_drop = (snapshot.node_states[from_node_index].head
158                        - snapshot.node_states[to_node_index].head)
159                        .abs();
160                    if pipe.length > 0.0 {
161                        head_drop / pipe.length
162                    } else {
163                        0.0
164                    }
165                } else {
166                    0.0
167                }
168            }
169            LinkQuantity::FrictionFactor => {
170                // Only meaningful for DW; return 0 for other formulae or non-pipes.
171                use crate::HeadLossFormula;
172                if network.options.head_loss_formula != HeadLossFormula::DarcyWeisbach {
173                    return Ok(0.0);
174                }
175                if let LinkKind::Pipe(pipe) = &link.kind {
176                    let from_node_index = link.base.from_idx();
177                    let to_node_index = link.base.to_idx();
178                    let head_drop = (snapshot.node_states[from_node_index].head
179                        - snapshot.node_states[to_node_index].head)
180                        .abs();
181                    // f = dh * D * 2g / (L * v²) where v = Q/A.
182                    // Guard: flows below Q_CLOSED (1e-6 m³/s) are
183                    // within solver convergence noise — treat as zero.
184                    const Q_CLOSED: f64 = 1.0e-6;
185                    let area = std::f64::consts::PI * (pipe.diameter / 2.0).powi(2);
186                    let velocity = if area > 0.0 && link_state.flow.abs() >= Q_CLOSED {
187                        link_state.flow.abs() / area
188                    } else {
189                        0.0
190                    };
191                    if velocity > 0.0 && pipe.length > 0.0 {
192                        // Friction factor from the DW definition:
193                        //   f = Δh · D · 2g / (L · v²)
194                        // G_DW is 9.81 m/s² (SI); result is dimensionless.
195                        let two_g = 2.0 * crate::hydraulics::G_DW;
196                        (head_drop * pipe.diameter * two_g) / (pipe.length * velocity * velocity)
197                    } else {
198                        0.0
199                    }
200                } else {
201                    0.0
202                }
203            }
204            LinkQuantity::Quality => link_state.quality,
205            LinkQuantity::Status => link_status_to_f64(link_state.status),
206            LinkQuantity::Setting => link_state.setting,
207        })
208    }
209
210    /// Return the times at which hydraulic snapshots were recorded (§8.2.1).
211    ///
212    /// The returned `Vec<f64>` is in ascending order and contains one entry per
213    /// reporting timestep that was stored during `run_hydraulics()` or
214    /// successive `step_hydraulics()` calls.
215    pub fn snapshot_times(&self) -> Vec<f64> {
216        self.hyd_snapshots.iter().map(|s| s.t).collect()
217    }
218
219    /// Compute global min/max for each display quantity across all snapshots.
220    ///
221    /// Iterates directly over snapshot arrays by index — O(snapshots × elements)
222    /// with zero string lookups.
223    pub fn result_ranges(&self) -> Result<ResultRanges, SessionError> {
224        let network = self.require_loaded_network()?;
225        if self.hyd_snapshots.is_empty() {
226            return Err(SessionError::InvalidPhase {
227                expected: "HydraulicsDone".into(),
228                actual: self.phase.name().to_string(),
229            });
230        }
231
232        let mut r = ResultRanges {
233            pressure_min: f64::INFINITY,
234            pressure_max: f64::NEG_INFINITY,
235            head_min: f64::INFINITY,
236            head_max: f64::NEG_INFINITY,
237            demand_min: f64::INFINITY,
238            demand_max: f64::NEG_INFINITY,
239            flow_min: f64::INFINITY,
240            flow_max: f64::NEG_INFINITY,
241            velocity_min: f64::INFINITY,
242            velocity_max: f64::NEG_INFINITY,
243        };
244
245        // Pre-compute pipe areas for velocity calculation.
246        let pipe_areas: Vec<f64> = network
247            .links
248            .iter()
249            .map(|link| {
250                if let LinkKind::Pipe(pipe) = &link.kind {
251                    std::f64::consts::PI * (pipe.diameter / 2.0).powi(2)
252                } else {
253                    0.0
254                }
255            })
256            .collect();
257
258        for snap in &self.hyd_snapshots {
259            // Nodes
260            for (i, ns) in snap.node_states.iter().enumerate() {
261                let node = &network.nodes[i];
262                let elevation = node.base.elevation;
263
264                // Head
265                let h = ns.head;
266                if h < r.head_min {
267                    r.head_min = h;
268                }
269                if h > r.head_max {
270                    r.head_max = h;
271                }
272
273                // Gauge pressure
274                let physical_elevation = match &node.kind {
275                    NodeKind::Tank(tank) => elevation - tank.min_level,
276                    _ => elevation,
277                };
278                let p = h - physical_elevation;
279                if p < r.pressure_min {
280                    r.pressure_min = p;
281                }
282                if p > r.pressure_max {
283                    r.pressure_max = p;
284                }
285
286                // Demand
287                let d = match &node.kind {
288                    NodeKind::Junction(_) => ns.demand_flow + ns.emitter_flow + ns.leakage_flow,
289                    NodeKind::Reservoir(_) | NodeKind::Tank(_) => ns.net_flow,
290                };
291                if d < r.demand_min {
292                    r.demand_min = d;
293                }
294                if d > r.demand_max {
295                    r.demand_max = d;
296                }
297            }
298
299            // Links
300            for (i, ls) in snap.link_states.iter().enumerate() {
301                let is_closed = matches!(
302                    ls.status,
303                    LinkStatus::Closed | LinkStatus::XHead | LinkStatus::TempClosed
304                );
305
306                // Flow
307                let f = if is_closed { 0.0 } else { ls.flow };
308                if f < r.flow_min {
309                    r.flow_min = f;
310                }
311                if f > r.flow_max {
312                    r.flow_max = f;
313                }
314
315                // Velocity (pipes only)
316                let area = pipe_areas[i];
317                if area > 0.0 {
318                    let v = ls.flow.abs() / area;
319                    if v < r.velocity_min {
320                        r.velocity_min = v;
321                    }
322                    if v > r.velocity_max {
323                        r.velocity_max = v;
324                    }
325                }
326            }
327        }
328
329        // Sanitise: if no pipes existed, velocity range stays infinite.
330        if r.velocity_min == f64::INFINITY {
331            r.velocity_min = 0.0;
332            r.velocity_max = 0.0;
333        }
334
335        Ok(r)
336    }
337
338    /// Return all node results at a given simulation time, indexed by position.
339    ///
340    /// Returns one `NodeResult` per node in the same order as `node_ids()`.
341    /// Uses direct index access — O(N) with no string lookups.
342    pub fn all_node_results_at(&self, t: f64) -> Result<Vec<NodeResult>, SessionError> {
343        let network = self.require_loaded_network()?;
344        let snapshot = self
345            .snapshot_near(t)
346            .ok_or(SessionError::NoSnapshotAtTime { requested_t: t })?;
347
348        let mut results = Vec::with_capacity(network.nodes.len());
349        for (i, ns) in snapshot.node_states.iter().enumerate() {
350            let node = &network.nodes[i];
351            let elevation = node.base.elevation;
352
353            let pressure = {
354                let physical_elevation = match &node.kind {
355                    NodeKind::Tank(tank) => elevation - tank.min_level,
356                    _ => elevation,
357                };
358                ns.head - physical_elevation
359            };
360
361            let demand = match &node.kind {
362                NodeKind::Junction(_) => ns.demand_flow + ns.emitter_flow + ns.leakage_flow,
363                NodeKind::Reservoir(_) | NodeKind::Tank(_) => ns.net_flow,
364            };
365
366            results.push(NodeResult {
367                head: ns.head,
368                pressure,
369                demand,
370            });
371        }
372        Ok(results)
373    }
374
375    /// Return all link results at a given simulation time, indexed by position.
376    ///
377    /// Returns one `LinkResult` per link in the same order as `link_ids()`.
378    /// Uses direct index access — O(L) with no string lookups.
379    pub fn all_link_results_at(&self, t: f64) -> Result<Vec<LinkResult>, SessionError> {
380        let network = self.require_loaded_network()?;
381        let snapshot = self
382            .snapshot_near(t)
383            .ok_or(SessionError::NoSnapshotAtTime { requested_t: t })?;
384
385        let mut results = Vec::with_capacity(network.links.len());
386        for (i, ls) in snapshot.link_states.iter().enumerate() {
387            let link = &network.links[i];
388
389            let is_closed = matches!(
390                ls.status,
391                LinkStatus::Closed | LinkStatus::XHead | LinkStatus::TempClosed
392            );
393
394            let flow = if is_closed { 0.0 } else { ls.flow };
395
396            let velocity = if let LinkKind::Pipe(pipe) = &link.kind {
397                let area = std::f64::consts::PI * (pipe.diameter / 2.0).powi(2);
398                if area > 0.0 {
399                    ls.flow.abs() / area
400                } else {
401                    0.0
402                }
403            } else {
404                0.0
405            };
406
407            let head_loss = if let LinkKind::Pipe(pipe) = &link.kind {
408                let from_idx = link.base.from_idx();
409                let to_idx = link.base.to_idx();
410                let head_drop =
411                    (snapshot.node_states[from_idx].head - snapshot.node_states[to_idx].head).abs();
412                if pipe.length > 0.0 {
413                    head_drop / pipe.length
414                } else {
415                    0.0
416                }
417            } else {
418                0.0
419            };
420
421            let status = link_status_to_f64(ls.status);
422
423            results.push(LinkResult {
424                flow,
425                velocity,
426                head_loss,
427                status,
428            });
429        }
430        Ok(results)
431    }
432
433    /// Return the node IDs in the order they were indexed at load time.
434    pub fn node_ids(&self) -> Vec<&str> {
435        match &self.network {
436            Some(n) => n.nodes.iter().map(|nd| nd.base.id.as_str()).collect(),
437            None => vec![],
438        }
439    }
440
441    /// Return the link IDs in the order they were indexed at load time.
442    pub fn link_ids(&self) -> Vec<&str> {
443        match &self.network {
444            Some(n) => n.links.iter().map(|lk| lk.base.id.as_str()).collect(),
445            None => vec![],
446        }
447    }
448
449    /// Return the pump link IDs in the order they appear in the network.
450    pub fn pump_ids(&self) -> Vec<&str> {
451        match &self.network {
452            Some(n) => n
453                .links
454                .iter()
455                .filter(|l| matches!(l.kind, LinkKind::Pump(_)))
456                .map(|l| l.base.id.as_str())
457                .collect(),
458            None => vec![],
459        }
460    }
461
462    /// Return the declared `FlowUnits` of the loaded network.
463    pub fn flow_units(&self) -> Option<FlowUnits> {
464        self.network.as_ref().map(|n| n.options.flow_units)
465    }
466
467    /// Return energy statistics for a pump link (§8.2.1).
468    pub fn get_pump_energy(&self, pump_id: &str) -> Result<&PumpEnergy, SessionError> {
469        let network = self.require_loaded_network()?;
470        let link_index = link_index_by_id(network, pump_id)
471            .ok_or_else(|| SessionError::UnknownId(pump_id.to_string()))?;
472        // Verify it is a pump.
473        if !matches!(network.links[link_index].kind, LinkKind::Pump(_)) {
474            return Err(SessionError::UnknownId(pump_id.to_string()));
475        }
476        let accounting_state =
477            self.accounting
478                .as_ref()
479                .ok_or_else(|| SessionError::InvalidPhase {
480                    expected: "HydraulicsDone".into(),
481                    actual: self.phase.name().to_string(),
482                })?;
483        Ok(&accounting_state.pump_energy[link_index])
484    }
485
486    /// Return the global mass balance from the quality engine (§8.2.1).
487    pub fn get_mass_balance(&self) -> Result<&MassBalance, SessionError> {
488        let qs = self
489            .quality_state
490            .as_ref()
491            .ok_or_else(|| SessionError::InvalidPhase {
492                expected: "QualityDone".into(),
493                actual: self.phase.name().to_string(),
494            })?;
495        Ok(&qs.mass_balance)
496    }
497
498    /// Return the global volumetric flow balance from accounting (§8.2.1).
499    pub fn get_flow_balance(&self) -> Result<&FlowBalance, SessionError> {
500        let acc = self
501            .accounting
502            .as_ref()
503            .ok_or_else(|| SessionError::InvalidPhase {
504                expected: "Loaded".into(),
505                actual: self.phase.name().to_string(),
506            })?;
507        Ok(&acc.flow_balance)
508    }
509
510    /// Return the total tank volume at the end of the simulation (m³).
511    ///
512    /// Sums `NodeState::volume` from the live state vector for all tank nodes.
513    pub fn final_tank_volume(&self) -> Result<f64, SessionError> {
514        let network = self.require_loaded_network()?;
515        let vol: f64 = network
516            .nodes
517            .iter()
518            .enumerate()
519            .filter_map(|(i, n)| {
520                if matches!(n.kind, NodeKind::Tank(_)) {
521                    Some(self.node_states[i].volume)
522                } else {
523                    None
524                }
525            })
526            .sum();
527        Ok(vol)
528    }
529
530    /// Return the complete flow balance summary with derived values.
531    pub fn flow_balance_summary(&self) -> Result<FlowBalanceSummary, SessionError> {
532        let fb = self.get_flow_balance()?;
533        let final_vol = self.final_tank_volume()?;
534        Ok(fb.summarize(final_vol))
535    }
536
537    /// Borrow the list of simulation warnings.
538    pub fn warnings(&self) -> &[SimWarning] {
539        &self.warnings
540    }
541}