hydra_engine_wds/analysis/
service_compliance.rs1use super::errors::AnalysisComputeError;
2use crate::io::out_reader;
3
4#[derive(Debug, Clone, Copy)]
10pub struct ServiceComplianceThresholds {
11 pub min_pressure: f64,
14 pub max_pressure: Option<f64>,
18}
19
20impl ServiceComplianceThresholds {
21 pub fn min_only(min_pressure: f64) -> Self {
23 Self {
24 min_pressure,
25 max_pressure: None,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Default)]
32pub struct ServiceComplianceNode {
33 pub node_index: usize,
35 pub sample_count: usize,
37 pub within_limits_count: usize,
39 pub below_min_count: usize,
41 pub above_max_count: usize,
43 pub longest_violation_streak: usize,
45 pub pressure_deficit_integral: f64,
49 pub pressure_excess_integral: f64,
53 pub worst_below_min: f64,
55 pub worst_above_max: f64,
57}
58
59impl ServiceComplianceNode {
60 pub fn violating_sample_count(&self) -> usize {
62 self.sample_count.saturating_sub(self.within_limits_count)
63 }
64
65 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#[derive(Debug, Clone, Default)]
77pub struct ServiceComplianceSummary {
78 pub period_count: usize,
80 pub node_count: usize,
82 pub total_samples: usize,
84 pub within_limits_samples: usize,
86 pub violating_samples: usize,
88 pub below_min_samples: usize,
90 pub above_max_samples: usize,
92 pub pressure_deficit_integral: f64,
94 pub pressure_excess_integral: f64,
96 pub worst_below_min: f64,
98 pub worst_above_max: f64,
100 pub max_node_violation_ratio: f64,
102}
103
104impl ServiceComplianceSummary {
105 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 pub fn violation_ratio(&self) -> f64 {
116 1.0 - self.compliance_ratio()
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct ServiceComplianceReport {
123 pub thresholds: ServiceComplianceThresholds,
125 pub report_step_seconds: f64,
127 pub period_count: usize,
129 pub nodes: Vec<ServiceComplianceNode>,
131 pub summary: ServiceComplianceSummary,
133}
134
135pub 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}