use super::errors::AnalysisComputeError;
use crate::io::out_reader;
#[derive(Debug, Clone, Copy)]
pub struct ServiceComplianceThresholds {
pub min_pressure: f64,
pub max_pressure: Option<f64>,
}
impl ServiceComplianceThresholds {
pub fn min_only(min_pressure: f64) -> Self {
Self {
min_pressure,
max_pressure: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ServiceComplianceNode {
pub node_index: usize,
pub sample_count: usize,
pub within_limits_count: usize,
pub below_min_count: usize,
pub above_max_count: usize,
pub longest_violation_streak: usize,
pub pressure_deficit_integral: f64,
pub pressure_excess_integral: f64,
pub worst_below_min: f64,
pub worst_above_max: f64,
}
impl ServiceComplianceNode {
pub fn violating_sample_count(&self) -> usize {
self.sample_count.saturating_sub(self.within_limits_count)
}
pub fn violation_ratio(&self) -> f64 {
if self.sample_count == 0 {
0.0
} else {
self.violating_sample_count() as f64 / self.sample_count as f64
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ServiceComplianceSummary {
pub period_count: usize,
pub node_count: usize,
pub total_samples: usize,
pub within_limits_samples: usize,
pub violating_samples: usize,
pub below_min_samples: usize,
pub above_max_samples: usize,
pub pressure_deficit_integral: f64,
pub pressure_excess_integral: f64,
pub worst_below_min: f64,
pub worst_above_max: f64,
pub max_node_violation_ratio: f64,
}
impl ServiceComplianceSummary {
pub fn compliance_ratio(&self) -> f64 {
if self.total_samples == 0 {
1.0
} else {
self.within_limits_samples as f64 / self.total_samples as f64
}
}
pub fn violation_ratio(&self) -> f64 {
1.0 - self.compliance_ratio()
}
}
#[derive(Debug, Clone)]
pub struct ServiceComplianceReport {
pub thresholds: ServiceComplianceThresholds,
pub report_step_seconds: f64,
pub period_count: usize,
pub nodes: Vec<ServiceComplianceNode>,
pub summary: ServiceComplianceSummary,
}
pub fn compute_service_compliance_from_out(
out_path: &std::path::Path,
thresholds: ServiceComplianceThresholds,
) -> Result<ServiceComplianceReport, AnalysisComputeError> {
validate_thresholds(thresholds)?;
let meta = out_reader::read_metadata_checked(out_path)
.map_err(|e| AnalysisComputeError::OutRead(e.to_string()))?;
if meta.n_periods == 0 {
return Err(AnalysisComputeError::NoSnapshots);
}
let dt = if meta.report_step > 0.0 {
meta.report_step
} else {
1.0
};
let mut nodes = vec![ServiceComplianceNode::default(); meta.n_nodes];
for (i, node) in nodes.iter_mut().enumerate() {
node.node_index = i;
}
let mut current_streaks = vec![0usize; meta.n_nodes];
for period in 0..meta.n_periods {
let period_results = out_reader::read_period(out_path, &meta, period)
.map_err(AnalysisComputeError::OutRead)?;
for (i, pressure) in period_results.node_pressure.iter().enumerate() {
observe_pressure_sample(
&mut nodes[i],
&mut current_streaks[i],
*pressure as f64,
thresholds,
dt,
);
}
}
let mut summary = ServiceComplianceSummary {
period_count: meta.n_periods,
node_count: meta.n_nodes,
total_samples: meta.n_nodes.saturating_mul(meta.n_periods),
..ServiceComplianceSummary::default()
};
for node in &nodes {
summary.within_limits_samples += node.within_limits_count;
summary.below_min_samples += node.below_min_count;
summary.above_max_samples += node.above_max_count;
summary.pressure_deficit_integral += node.pressure_deficit_integral;
summary.pressure_excess_integral += node.pressure_excess_integral;
summary.worst_below_min = summary.worst_below_min.max(node.worst_below_min);
summary.worst_above_max = summary.worst_above_max.max(node.worst_above_max);
summary.max_node_violation_ratio =
summary.max_node_violation_ratio.max(node.violation_ratio());
}
summary.violating_samples = summary
.total_samples
.saturating_sub(summary.within_limits_samples);
Ok(ServiceComplianceReport {
thresholds,
report_step_seconds: dt,
period_count: meta.n_periods,
nodes,
summary,
})
}
fn validate_thresholds(
thresholds: ServiceComplianceThresholds,
) -> Result<(), AnalysisComputeError> {
if !thresholds.min_pressure.is_finite() {
return Err(AnalysisComputeError::InvalidInput(
"minimum pressure threshold must be finite".to_string(),
));
}
if let Some(max_pressure) = thresholds.max_pressure {
if !max_pressure.is_finite() {
return Err(AnalysisComputeError::InvalidInput(
"maximum pressure threshold must be finite".to_string(),
));
}
if max_pressure <= thresholds.min_pressure {
return Err(AnalysisComputeError::InvalidInput(
"maximum pressure threshold must be greater than minimum pressure threshold"
.to_string(),
));
}
}
Ok(())
}
fn observe_pressure_sample(
node: &mut ServiceComplianceNode,
current_streak: &mut usize,
pressure: f64,
thresholds: ServiceComplianceThresholds,
dt_seconds: f64,
) {
node.sample_count += 1;
let mut violation = false;
if pressure < thresholds.min_pressure {
let deficit = thresholds.min_pressure - pressure;
node.below_min_count += 1;
node.pressure_deficit_integral += deficit * dt_seconds;
node.worst_below_min = node.worst_below_min.max(deficit);
violation = true;
}
if let Some(max_pressure) = thresholds.max_pressure {
if pressure > max_pressure {
let excess = pressure - max_pressure;
node.above_max_count += 1;
node.pressure_excess_integral += excess * dt_seconds;
node.worst_above_max = node.worst_above_max.max(excess);
violation = true;
}
}
if violation {
*current_streak += 1;
node.longest_violation_streak = node.longest_violation_streak.max(*current_streak);
} else {
node.within_limits_count += 1;
*current_streak = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compliance_sample_updates_counts_streaks_and_integrals() {
let thresholds = ServiceComplianceThresholds {
min_pressure: 30.0,
max_pressure: Some(80.0),
};
let dt = 3600.0;
let mut node = ServiceComplianceNode {
node_index: 0,
..ServiceComplianceNode::default()
};
let mut streak = 0usize;
let samples = [25.0, 20.0, 35.0, 90.0, 85.0, 40.0];
for pressure in samples {
observe_pressure_sample(&mut node, &mut streak, pressure, thresholds, dt);
}
assert_eq!(node.sample_count, 6);
assert_eq!(node.within_limits_count, 2);
assert_eq!(node.below_min_count, 2);
assert_eq!(node.above_max_count, 2);
assert_eq!(node.longest_violation_streak, 2);
assert_eq!(node.violating_sample_count(), 4);
assert!((node.violation_ratio() - (4.0 / 6.0)).abs() < 1e-12);
assert!((node.pressure_deficit_integral - 15.0 * dt).abs() < 1e-12);
assert!((node.pressure_excess_integral - 15.0 * dt).abs() < 1e-12);
assert!((node.worst_below_min - 10.0).abs() < 1e-12);
assert!((node.worst_above_max - 10.0).abs() < 1e-12);
}
#[test]
fn invalid_thresholds_are_rejected() {
let bad = ServiceComplianceThresholds {
min_pressure: 30.0,
max_pressure: Some(30.0),
};
let err = validate_thresholds(bad).expect_err("expected invalid threshold error");
assert!(matches!(err, AnalysisComputeError::InvalidInput(_)));
}
}