Skip to main content

hydra_engine_wds/analysis/
service_compliance.rs

1use super::errors::AnalysisComputeError;
2use crate::io::out_reader;
3
4/// Pressure thresholds used to classify reporting-period samples as in-limit or
5/// out-of-limit in a service-compliance analysis.
6///
7/// All values are in the same pressure units as the `.out` file (metres of head
8/// for Hydra-generated output).
9#[derive(Debug, Clone, Copy)]
10pub struct ServiceComplianceThresholds {
11    /// Minimum acceptable pressure (m). Samples below this are counted as
12    /// `below_min` violations.
13    pub min_pressure: f64,
14    /// Optional maximum acceptable pressure (m). When `Some`, samples above
15    /// this are counted as `above_max` violations. `None` disables the upper
16    /// bound check.
17    pub max_pressure: Option<f64>,
18}
19
20impl ServiceComplianceThresholds {
21    /// Create thresholds with only a minimum pressure bound (no upper limit).
22    pub fn min_only(min_pressure: f64) -> Self {
23        Self {
24            min_pressure,
25            max_pressure: None,
26        }
27    }
28}
29
30/// Per-node service-compliance metrics for a single simulation run.
31#[derive(Debug, Clone, Default)]
32pub struct ServiceComplianceNode {
33    /// Zero-based index of this node in [`crate::Network::nodes`].
34    pub node_index: usize,
35    /// Total number of reporting-period pressure samples for this node.
36    pub sample_count: usize,
37    /// Number of samples with pressure within `[min_pressure, max_pressure]`.
38    pub within_limits_count: usize,
39    /// Number of samples below `min_pressure`.
40    pub below_min_count: usize,
41    /// Number of samples above `max_pressure` (always 0 when no upper limit is set).
42    pub above_max_count: usize,
43    /// Length of the longest consecutive run of out-of-limit samples.
44    pub longest_violation_streak: usize,
45    /// Integral of pressure deficit over time (m · periods).
46    ///
47    /// Accumulated as `sum(max(min_pressure − pressure, 0))` across all samples.
48    pub pressure_deficit_integral: f64,
49    /// Integral of pressure excess over time (m · periods).
50    ///
51    /// Accumulated as `sum(max(pressure − max_pressure, 0))` across all samples.
52    pub pressure_excess_integral: f64,
53    /// Worst (largest) observed pressure deficit: `max(min_pressure − pressure)` (m).
54    pub worst_below_min: f64,
55    /// Worst (largest) observed pressure excess: `max(pressure − max_pressure)` (m).
56    pub worst_above_max: f64,
57}
58
59impl ServiceComplianceNode {
60    /// Number of samples that fell outside the acceptable pressure range.
61    pub fn violating_sample_count(&self) -> usize {
62        self.sample_count.saturating_sub(self.within_limits_count)
63    }
64
65    /// Fraction of samples that were out-of-limit, in `[0, 1]`.
66    pub fn violation_ratio(&self) -> f64 {
67        if self.sample_count == 0 {
68            0.0
69        } else {
70            self.violating_sample_count() as f64 / self.sample_count as f64
71        }
72    }
73}
74
75/// Network-level service-compliance summary aggregated across all nodes.
76#[derive(Debug, Clone, Default)]
77pub struct ServiceComplianceSummary {
78    /// Number of reporting periods in the simulation.
79    pub period_count: usize,
80    /// Number of nodes included in the analysis.
81    pub node_count: usize,
82    /// Total number of (node, period) pressure samples.
83    pub total_samples: usize,
84    /// Number of samples within the acceptable pressure range.
85    pub within_limits_samples: usize,
86    /// Number of samples outside the acceptable pressure range.
87    pub violating_samples: usize,
88    /// Number of samples below `min_pressure`.
89    pub below_min_samples: usize,
90    /// Number of samples above `max_pressure`.
91    pub above_max_samples: usize,
92    /// Sum of per-node `pressure_deficit_integral` values.
93    pub pressure_deficit_integral: f64,
94    /// Sum of per-node `pressure_excess_integral` values.
95    pub pressure_excess_integral: f64,
96    /// Global worst pressure deficit across all nodes (m).
97    pub worst_below_min: f64,
98    /// Global worst pressure excess across all nodes (m).
99    pub worst_above_max: f64,
100    /// Highest per-node violation ratio observed across all nodes.
101    pub max_node_violation_ratio: f64,
102}
103
104impl ServiceComplianceSummary {
105    /// Fraction of all samples that were in-limit, in `[0, 1]`.
106    pub fn compliance_ratio(&self) -> f64 {
107        if self.total_samples == 0 {
108            1.0
109        } else {
110            self.within_limits_samples as f64 / self.total_samples as f64
111        }
112    }
113
114    /// Fraction of all samples that were out-of-limit: `1 − compliance_ratio`.
115    pub fn violation_ratio(&self) -> f64 {
116        1.0 - self.compliance_ratio()
117    }
118}
119
120/// Complete service-compliance report for a single simulation run.
121#[derive(Debug, Clone)]
122pub struct ServiceComplianceReport {
123    /// Pressure thresholds used to classify samples.
124    pub thresholds: ServiceComplianceThresholds,
125    /// Duration of each reporting period (seconds).
126    pub report_step_seconds: f64,
127    /// Total number of reporting periods in the `.out` file.
128    pub period_count: usize,
129    /// Per-node metrics, ordered to match [`crate::Network::nodes`].
130    pub nodes: Vec<ServiceComplianceNode>,
131    /// Network-level summary aggregated from all nodes.
132    pub summary: ServiceComplianceSummary,
133}
134
135/// Compute node-pressure service compliance metrics from a persisted `.out` file.
136///
137/// **Inputs** (via [`ServiceComplianceThresholds`]):
138/// - pressure samples by node and reporting period (read from `.out`)
139/// - reporting period duration
140/// - minimum pressure threshold
141/// - optional maximum pressure threshold
142///
143/// Pressure thresholds are interpreted in the same pressure units stored in the
144/// `.out` file. Uses a streaming pass over periods — all node pressures are
145/// never held in memory simultaneously.
146///
147/// **Outputs** (in [`ServiceComplianceReport`]):
148/// - per-node: in-limit sample count/ratio, below-minimum and above-maximum
149///   counts, longest continuous violation streak, pressure deficit/excess
150///   integrals over time
151/// - network-level summary statistics
152pub fn compute_service_compliance_from_out(
153    out_path: &std::path::Path,
154    thresholds: ServiceComplianceThresholds,
155) -> Result<ServiceComplianceReport, AnalysisComputeError> {
156    validate_thresholds(thresholds)?;
157
158    let meta = out_reader::read_metadata_checked(out_path)
159        .map_err(|e| AnalysisComputeError::OutRead(e.to_string()))?;
160
161    if meta.n_periods == 0 {
162        return Err(AnalysisComputeError::NoSnapshots);
163    }
164
165    let dt = if meta.report_step > 0.0 {
166        meta.report_step
167    } else {
168        1.0
169    };
170
171    let mut nodes = vec![ServiceComplianceNode::default(); meta.n_nodes];
172    for (i, node) in nodes.iter_mut().enumerate() {
173        node.node_index = i;
174    }
175    let mut current_streaks = vec![0usize; meta.n_nodes];
176
177    for period in 0..meta.n_periods {
178        let period_results = out_reader::read_period(out_path, &meta, period)
179            .map_err(AnalysisComputeError::OutRead)?;
180
181        for (i, pressure) in period_results.node_pressure.iter().enumerate() {
182            observe_pressure_sample(
183                &mut nodes[i],
184                &mut current_streaks[i],
185                *pressure as f64,
186                thresholds,
187                dt,
188            );
189        }
190    }
191
192    let mut summary = ServiceComplianceSummary {
193        period_count: meta.n_periods,
194        node_count: meta.n_nodes,
195        total_samples: meta.n_nodes.saturating_mul(meta.n_periods),
196        ..ServiceComplianceSummary::default()
197    };
198
199    for node in &nodes {
200        summary.within_limits_samples += node.within_limits_count;
201        summary.below_min_samples += node.below_min_count;
202        summary.above_max_samples += node.above_max_count;
203        summary.pressure_deficit_integral += node.pressure_deficit_integral;
204        summary.pressure_excess_integral += node.pressure_excess_integral;
205        summary.worst_below_min = summary.worst_below_min.max(node.worst_below_min);
206        summary.worst_above_max = summary.worst_above_max.max(node.worst_above_max);
207        summary.max_node_violation_ratio =
208            summary.max_node_violation_ratio.max(node.violation_ratio());
209    }
210
211    summary.violating_samples = summary
212        .total_samples
213        .saturating_sub(summary.within_limits_samples);
214
215    Ok(ServiceComplianceReport {
216        thresholds,
217        report_step_seconds: dt,
218        period_count: meta.n_periods,
219        nodes,
220        summary,
221    })
222}
223
224fn validate_thresholds(
225    thresholds: ServiceComplianceThresholds,
226) -> Result<(), AnalysisComputeError> {
227    if !thresholds.min_pressure.is_finite() {
228        return Err(AnalysisComputeError::InvalidInput(
229            "minimum pressure threshold must be finite".to_string(),
230        ));
231    }
232
233    if let Some(max_pressure) = thresholds.max_pressure {
234        if !max_pressure.is_finite() {
235            return Err(AnalysisComputeError::InvalidInput(
236                "maximum pressure threshold must be finite".to_string(),
237            ));
238        }
239        if max_pressure <= thresholds.min_pressure {
240            return Err(AnalysisComputeError::InvalidInput(
241                "maximum pressure threshold must be greater than minimum pressure threshold"
242                    .to_string(),
243            ));
244        }
245    }
246
247    Ok(())
248}
249
250fn observe_pressure_sample(
251    node: &mut ServiceComplianceNode,
252    current_streak: &mut usize,
253    pressure: f64,
254    thresholds: ServiceComplianceThresholds,
255    dt_seconds: f64,
256) {
257    node.sample_count += 1;
258
259    let mut violation = false;
260
261    if pressure < thresholds.min_pressure {
262        let deficit = thresholds.min_pressure - pressure;
263        node.below_min_count += 1;
264        node.pressure_deficit_integral += deficit * dt_seconds;
265        node.worst_below_min = node.worst_below_min.max(deficit);
266        violation = true;
267    }
268
269    if let Some(max_pressure) = thresholds.max_pressure {
270        if pressure > max_pressure {
271            let excess = pressure - max_pressure;
272            node.above_max_count += 1;
273            node.pressure_excess_integral += excess * dt_seconds;
274            node.worst_above_max = node.worst_above_max.max(excess);
275            violation = true;
276        }
277    }
278
279    if violation {
280        *current_streak += 1;
281        node.longest_violation_streak = node.longest_violation_streak.max(*current_streak);
282    } else {
283        node.within_limits_count += 1;
284        *current_streak = 0;
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn compliance_sample_updates_counts_streaks_and_integrals() {
294        let thresholds = ServiceComplianceThresholds {
295            min_pressure: 30.0,
296            max_pressure: Some(80.0),
297        };
298        let dt = 3600.0;
299        let mut node = ServiceComplianceNode {
300            node_index: 0,
301            ..ServiceComplianceNode::default()
302        };
303        let mut streak = 0usize;
304
305        let samples = [25.0, 20.0, 35.0, 90.0, 85.0, 40.0];
306        for pressure in samples {
307            observe_pressure_sample(&mut node, &mut streak, pressure, thresholds, dt);
308        }
309
310        assert_eq!(node.sample_count, 6);
311        assert_eq!(node.within_limits_count, 2);
312        assert_eq!(node.below_min_count, 2);
313        assert_eq!(node.above_max_count, 2);
314        assert_eq!(node.longest_violation_streak, 2);
315        assert_eq!(node.violating_sample_count(), 4);
316        assert!((node.violation_ratio() - (4.0 / 6.0)).abs() < 1e-12);
317        assert!((node.pressure_deficit_integral - 15.0 * dt).abs() < 1e-12);
318        assert!((node.pressure_excess_integral - 15.0 * dt).abs() < 1e-12);
319        assert!((node.worst_below_min - 10.0).abs() < 1e-12);
320        assert!((node.worst_above_max - 10.0).abs() < 1e-12);
321    }
322
323    #[test]
324    fn invalid_thresholds_are_rejected() {
325        let bad = ServiceComplianceThresholds {
326            min_pressure: 30.0,
327            max_pressure: Some(30.0),
328        };
329        let err = validate_thresholds(bad).expect_err("expected invalid threshold error");
330        assert!(matches!(err, AnalysisComputeError::InvalidInput(_)));
331    }
332}