Skip to main content

hydra_engine_wds/analysis/
demand_reliability.rs

1use super::errors::AnalysisComputeError;
2use crate::io::out_reader;
3use crate::io::units::make_ucf;
4use crate::{DemandModel, FlowUnits, Network, NodeKind};
5use std::collections::HashMap;
6use std::io::Read;
7
8/// Options that control the demand-reliability computation.
9#[derive(Debug, Clone, Copy)]
10pub struct DemandReliabilityOptions {
11    /// Absolute flow volume (m³) below which a per-period shortfall is ignored.
12    ///
13    /// Prevents floating-point noise from counting sub-threshold rounding errors
14    /// as real deficits. Default: `1e-9`.
15    pub deficit_tolerance: f64,
16}
17
18impl Default for DemandReliabilityOptions {
19    fn default() -> Self {
20        Self {
21            deficit_tolerance: 1.0e-9,
22        }
23    }
24}
25
26/// Per-junction demand-reliability metrics for a single simulation run.
27///
28/// Volumes are in m³ (internal SI units).
29#[derive(Debug, Clone)]
30pub struct DemandReliabilityNode {
31    /// Zero-based index of this node in [`Network::nodes`].
32    pub node_index: usize,
33    /// String ID of the node as it appears in the INP file.
34    pub node_id: String,
35    /// Total volume of water demanded over all reporting periods (m³).
36    pub required_volume: f64,
37    /// Total volume of water actually delivered over all reporting periods (m³).
38    pub delivered_volume: f64,
39    /// Total unmet demand volume over all reporting periods (m³).
40    pub unmet_volume: f64,
41    /// Total surplus delivered beyond demand (m³); non-zero under PDA when head
42    /// exceeds the required-pressure threshold.
43    pub surplus_volume: f64,
44    /// Number of reporting periods in which delivered demand was below required.
45    pub deficit_periods: usize,
46    /// Length of the longest consecutive run of deficit periods.
47    pub longest_deficit_streak: usize,
48    /// Maximum observed instantaneous deficit rate (m³/s) across all periods.
49    pub max_deficit_rate: f64,
50}
51
52impl DemandReliabilityNode {
53    /// Volume of demand that was actually served: `max(required − unmet, 0)` (m³).
54    pub fn served_volume(&self) -> f64 {
55        (self.required_volume - self.unmet_volume).max(0.0)
56    }
57
58    /// Ratio of served to required volume in `[0, 1]` (1.0 when required ≤ 0).
59    pub fn reliability_ratio(&self) -> f64 {
60        if self.required_volume <= 0.0 {
61            1.0
62        } else {
63            self.served_volume() / self.required_volume
64        }
65    }
66}
67
68/// Network-level demand-reliability summary aggregated across all junctions.
69///
70/// Volumes are in m³ (internal SI units).
71#[derive(Debug, Clone, Default)]
72pub struct DemandReliabilitySummary {
73    /// Number of junctions included in the analysis.
74    pub node_count: usize,
75    /// Number of reporting periods in the simulation.
76    pub period_count: usize,
77    /// Total required demand volume across all junctions and periods (m³).
78    pub required_volume: f64,
79    /// Total delivered demand volume across all junctions and periods (m³).
80    pub delivered_volume: f64,
81    /// Total unmet demand volume across all junctions and periods (m³).
82    pub unmet_volume: f64,
83    /// Total surplus delivered volume across all junctions and periods (m³).
84    pub surplus_volume: f64,
85    /// Total number of (junction, period) pairs with a deficit.
86    pub deficit_periods: usize,
87    /// Highest `max_deficit_rate` observed across all junctions (m³/s).
88    pub max_node_deficit_rate: f64,
89}
90
91impl DemandReliabilitySummary {
92    /// Network-wide served volume: `max(required − unmet, 0)` (m³).
93    pub fn served_volume(&self) -> f64 {
94        (self.required_volume - self.unmet_volume).max(0.0)
95    }
96
97    /// Network-wide reliability ratio in `[0, 1]` (1.0 when required ≤ 0).
98    pub fn reliability_ratio(&self) -> f64 {
99        if self.required_volume <= 0.0 {
100            1.0
101        } else {
102            self.served_volume() / self.required_volume
103        }
104    }
105}
106
107/// Complete demand-reliability report for a single simulation run.
108#[derive(Debug, Clone)]
109pub struct DemandReliabilityReport {
110    /// Demand model used during the simulation (`DDA` or `PDA`).
111    pub demand_model: DemandModel,
112    /// Duration of each reporting period (seconds).
113    pub report_step_seconds: f64,
114    /// Total number of reporting periods in the `.out` file.
115    pub period_count: usize,
116    /// Per-junction metrics, ordered to match [`Network::nodes`].
117    pub nodes: Vec<DemandReliabilityNode>,
118    /// Network-level summary aggregated from all junctions.
119    pub summary: DemandReliabilitySummary,
120}
121
122/// Compute delivered-vs-required demand reliability metrics from a persisted
123/// `.out` file and the corresponding loaded network.
124///
125/// **Inputs**:
126/// - delivered demand samples by node and reporting period (read from `.out`)
127/// - required demand recomputed from network demand categories and patterns
128///   at each reporting time
129/// - reporting period duration
130/// - optional deficit tolerance (via [`DemandReliabilityOptions`])
131///
132/// Delivered demand is read from `.out` and converted into internal flow units.
133/// Uses streaming period reads — all per-period values are never materialised
134/// in memory simultaneously.
135///
136/// **Outputs** (in [`DemandReliabilityReport`]):
137/// - per-junction: required, delivered, unmet, and surplus demand volumes;
138///   reliability ratio; deficit period count and longest deficit streak;
139///   maximum observed deficit rate
140/// - network-level summary statistics
141pub fn compute_demand_reliability_from_out(
142    out_path: &std::path::Path,
143    network: &Network,
144) -> Result<DemandReliabilityReport, AnalysisComputeError> {
145    compute_demand_reliability_from_out_with_options(
146        out_path,
147        network,
148        DemandReliabilityOptions::default(),
149    )
150}
151
152/// Like [`compute_demand_reliability_from_out`] but with explicit [`DemandReliabilityOptions`].
153pub fn compute_demand_reliability_from_out_with_options(
154    out_path: &std::path::Path,
155    network: &Network,
156    options: DemandReliabilityOptions,
157) -> Result<DemandReliabilityReport, AnalysisComputeError> {
158    validate_options(options)?;
159
160    let meta = out_reader::read_metadata_checked(out_path)
161        .map_err(|e| AnalysisComputeError::OutRead(e.to_string()))?;
162
163    if meta.n_periods == 0 {
164        return Err(AnalysisComputeError::NoSnapshots);
165    }
166
167    if meta.n_nodes != network.nodes.len() {
168        return Err(AnalysisComputeError::InvalidInput(format!(
169            "node count mismatch: .out has {} nodes, network has {} nodes",
170            meta.n_nodes,
171            network.nodes.len()
172        )));
173    }
174
175    let dt = if meta.report_step > 0.0 {
176        meta.report_step
177    } else {
178        1.0
179    };
180
181    let flow_units_code = read_flow_units_code(out_path)?;
182    let flow_units = flow_units_from_code(flow_units_code).ok_or_else(|| {
183        AnalysisComputeError::InvalidInput(format!(
184            "unsupported flow units code in .out header: {flow_units_code}"
185        ))
186    })?;
187    let out_to_internal_flow = 1.0 / make_ucf(flow_units, 1.0).flow;
188
189    let owned_pattern_index = if network.pattern_index.is_empty() && !network.patterns.is_empty() {
190        Some(build_pattern_index(network))
191    } else {
192        None
193    };
194    let pattern_index = owned_pattern_index
195        .as_ref()
196        .unwrap_or(&network.pattern_index);
197
198    let junction_indices: Vec<usize> = network
199        .nodes
200        .iter()
201        .enumerate()
202        .filter_map(|(i, node)| {
203            if matches!(node.kind, NodeKind::Junction(_)) {
204                Some(i)
205            } else {
206                None
207            }
208        })
209        .collect();
210
211    let mut nodes: Vec<DemandReliabilityNode> = junction_indices
212        .iter()
213        .map(|&index| DemandReliabilityNode {
214            node_index: index,
215            node_id: network.nodes[index].base.id.clone(),
216            required_volume: 0.0,
217            delivered_volume: 0.0,
218            unmet_volume: 0.0,
219            surplus_volume: 0.0,
220            deficit_periods: 0,
221            longest_deficit_streak: 0,
222            max_deficit_rate: 0.0,
223        })
224        .collect();
225    let mut current_streaks = vec![0usize; nodes.len()];
226
227    for period in 0..meta.n_periods {
228        let t = meta.report_start + period as f64 * dt;
229        let period_results = out_reader::read_period(out_path, &meta, period)
230            .map_err(AnalysisComputeError::OutRead)?;
231
232        for (j, (node_stats, node_index)) in
233            nodes.iter_mut().zip(junction_indices.iter()).enumerate()
234        {
235            let junction = match &network.nodes[*node_index].kind {
236                NodeKind::Junction(junction) => junction,
237                _ => continue,
238            };
239
240            let required = junction
241                .total_demand(t, &network.options, &network.patterns, pattern_index)
242                .max(0.0);
243
244            let delivered_output = period_results.node_demand[*node_index] as f64;
245            let delivered_internal = (delivered_output * out_to_internal_flow).max(0.0);
246
247            observe_demand_sample(
248                node_stats,
249                &mut current_streaks[j],
250                required,
251                delivered_internal,
252                dt,
253                options.deficit_tolerance,
254            );
255        }
256    }
257
258    let mut summary = DemandReliabilitySummary {
259        node_count: nodes.len(),
260        period_count: meta.n_periods,
261        ..DemandReliabilitySummary::default()
262    };
263
264    for node in &nodes {
265        summary.required_volume += node.required_volume;
266        summary.delivered_volume += node.delivered_volume;
267        summary.unmet_volume += node.unmet_volume;
268        summary.surplus_volume += node.surplus_volume;
269        summary.deficit_periods += node.deficit_periods;
270        summary.max_node_deficit_rate = summary.max_node_deficit_rate.max(node.max_deficit_rate);
271    }
272
273    Ok(DemandReliabilityReport {
274        demand_model: network.options.demand_model,
275        report_step_seconds: dt,
276        period_count: meta.n_periods,
277        nodes,
278        summary,
279    })
280}
281
282fn validate_options(options: DemandReliabilityOptions) -> Result<(), AnalysisComputeError> {
283    if !options.deficit_tolerance.is_finite() || options.deficit_tolerance < 0.0 {
284        return Err(AnalysisComputeError::InvalidInput(
285            "deficit tolerance must be a finite value >= 0".to_string(),
286        ));
287    }
288    Ok(())
289}
290
291fn observe_demand_sample(
292    node: &mut DemandReliabilityNode,
293    current_streak: &mut usize,
294    required_rate: f64,
295    delivered_rate: f64,
296    dt_seconds: f64,
297    deficit_tolerance: f64,
298) {
299    let required = required_rate.max(0.0);
300    let delivered = delivered_rate.max(0.0);
301
302    let deficit = (required - delivered).max(0.0);
303    let surplus = (delivered - required).max(0.0);
304
305    node.required_volume += required * dt_seconds;
306    node.delivered_volume += delivered * dt_seconds;
307    node.unmet_volume += deficit * dt_seconds;
308    node.surplus_volume += surplus * dt_seconds;
309
310    if deficit > deficit_tolerance {
311        node.deficit_periods += 1;
312        *current_streak += 1;
313        node.longest_deficit_streak = node.longest_deficit_streak.max(*current_streak);
314        node.max_deficit_rate = node.max_deficit_rate.max(deficit);
315    } else {
316        *current_streak = 0;
317    }
318}
319
320fn build_pattern_index(network: &Network) -> HashMap<String, usize> {
321    network
322        .patterns
323        .iter()
324        .enumerate()
325        .map(|(i, p)| (p.id.clone(), i))
326        .collect()
327}
328
329fn read_flow_units_code(path: &std::path::Path) -> Result<i32, AnalysisComputeError> {
330    let mut file = std::fs::File::open(path)
331        .map_err(|e| AnalysisComputeError::OutRead(format!("failed to open .out file: {e}")))?;
332
333    let mut header = [0u8; 44];
334    file.read_exact(&mut header)
335        .map_err(|e| AnalysisComputeError::OutRead(format!("failed to read .out header: {e}")))?;
336
337    Ok(i32::from_le_bytes(header[40..44].try_into().unwrap()))
338}
339
340fn flow_units_from_code(code: i32) -> Option<FlowUnits> {
341    match code {
342        0 => Some(FlowUnits::Cfs),
343        1 => Some(FlowUnits::Gpm),
344        2 => Some(FlowUnits::Mgd),
345        3 => Some(FlowUnits::Imgd),
346        4 => Some(FlowUnits::Afd),
347        5 => Some(FlowUnits::Lps),
348        6 => Some(FlowUnits::Lpm),
349        7 => Some(FlowUnits::Mld),
350        8 => Some(FlowUnits::Cmh),
351        9 => Some(FlowUnits::Cmd),
352        10 => Some(FlowUnits::Cms),
353        _ => None,
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn demand_sample_tracks_unmet_and_streaks() {
363        let mut node = DemandReliabilityNode {
364            node_index: 1,
365            node_id: "J1".to_string(),
366            required_volume: 0.0,
367            delivered_volume: 0.0,
368            unmet_volume: 0.0,
369            surplus_volume: 0.0,
370            deficit_periods: 0,
371            longest_deficit_streak: 0,
372            max_deficit_rate: 0.0,
373        };
374        let mut streak = 0usize;
375        let dt = 60.0;
376
377        observe_demand_sample(&mut node, &mut streak, 10.0, 8.0, dt, 0.0);
378        observe_demand_sample(&mut node, &mut streak, 10.0, 6.0, dt, 0.0);
379        observe_demand_sample(&mut node, &mut streak, 10.0, 10.0, dt, 0.0);
380
381        assert!((node.required_volume - 1800.0).abs() < 1e-12);
382        assert!((node.delivered_volume - 1440.0).abs() < 1e-12);
383        assert!((node.unmet_volume - 360.0).abs() < 1e-12);
384        assert_eq!(node.deficit_periods, 2);
385        assert_eq!(node.longest_deficit_streak, 2);
386        assert!((node.max_deficit_rate - 4.0).abs() < 1e-12);
387        assert!((node.reliability_ratio() - 0.8).abs() < 1e-12);
388    }
389
390    #[test]
391    fn flow_units_codes_map_correctly() {
392        assert_eq!(flow_units_from_code(0), Some(FlowUnits::Cfs));
393        assert_eq!(flow_units_from_code(1), Some(FlowUnits::Gpm));
394        assert_eq!(flow_units_from_code(10), Some(FlowUnits::Cms));
395        assert_eq!(flow_units_from_code(11), None);
396    }
397
398    #[test]
399    fn invalid_options_are_rejected() {
400        let bad = DemandReliabilityOptions {
401            deficit_tolerance: -1.0,
402        };
403        let err = validate_options(bad).expect_err("expected invalid option");
404        assert!(matches!(err, AnalysisComputeError::InvalidInput(_)));
405    }
406}