hydra_engine_wds/analysis/
demand_reliability.rs1use 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#[derive(Debug, Clone, Copy)]
10pub struct DemandReliabilityOptions {
11 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#[derive(Debug, Clone)]
30pub struct DemandReliabilityNode {
31 pub node_index: usize,
33 pub node_id: String,
35 pub required_volume: f64,
37 pub delivered_volume: f64,
39 pub unmet_volume: f64,
41 pub surplus_volume: f64,
44 pub deficit_periods: usize,
46 pub longest_deficit_streak: usize,
48 pub max_deficit_rate: f64,
50}
51
52impl DemandReliabilityNode {
53 pub fn served_volume(&self) -> f64 {
55 (self.required_volume - self.unmet_volume).max(0.0)
56 }
57
58 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#[derive(Debug, Clone, Default)]
72pub struct DemandReliabilitySummary {
73 pub node_count: usize,
75 pub period_count: usize,
77 pub required_volume: f64,
79 pub delivered_volume: f64,
81 pub unmet_volume: f64,
83 pub surplus_volume: f64,
85 pub deficit_periods: usize,
87 pub max_node_deficit_rate: f64,
89}
90
91impl DemandReliabilitySummary {
92 pub fn served_volume(&self) -> f64 {
94 (self.required_volume - self.unmet_volume).max(0.0)
95 }
96
97 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#[derive(Debug, Clone)]
109pub struct DemandReliabilityReport {
110 pub demand_model: DemandModel,
112 pub report_step_seconds: f64,
114 pub period_count: usize,
116 pub nodes: Vec<DemandReliabilityNode>,
118 pub summary: DemandReliabilitySummary,
120}
121
122pub 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
152pub 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}