use crate::error::{OxiGridError, Result};
use crate::network::topology::PowerNetwork;
use crate::optimize::restoration::black_start::{
BlackStartConfig, EnergizationPath, RestorationStep,
};
pub fn check_voltage_constraint(
path: &EnergizationPath,
nominal_voltage_kv: f64,
tolerance_pu: f64,
) -> bool {
let drop_per_km = 0.003 / nominal_voltage_kv.max(1.0); let estimated_drop_pu = drop_per_km * path.total_length_km;
estimated_drop_pu <= tolerance_pu
}
pub fn find_frequency_violations(steps: &[RestorationStep], tolerance_hz: f64) -> Vec<usize> {
let f0 = 50.0_f64;
steps
.iter()
.filter(|s| (s.frequency_hz - f0).abs() > tolerance_hz)
.map(|s| s.step_id)
.collect()
}
pub fn check_thermal_constraint(
network: &PowerNetwork,
path: &EnergizationPath,
pickup_mw: f64,
nominal_voltage_kv: f64,
) -> Result<()> {
let current_ka = pickup_mw / (1.732 * nominal_voltage_kv.max(1.0));
for &bi in &path.branch_sequence {
let branch = network.branches.get(bi).ok_or_else(|| {
OxiGridError::InvalidParameter(format!("Branch index {} out of range", bi))
})?;
if branch.rate_a > 0.0 {
let rating_ka = branch.rate_a / (1.732 * nominal_voltage_kv.max(1.0));
if current_ka > rating_ka {
return Err(OxiGridError::InvalidParameter(format!(
"Branch {} ({}→{}) overloaded: {:.3} kA > {:.3} kA rating",
bi, branch.from_bus, branch.to_bus, current_ka, rating_ka
)));
}
}
}
Ok(())
}
pub fn find_reserve_violations(steps: &[RestorationStep], reserve_margin_pct: f64) -> Vec<usize> {
steps
.iter()
.filter(|s| {
let required_reserve = s.available_generation_mw * reserve_margin_pct / 100.0;
let actual_reserve = s.available_generation_mw - s.connected_load_mw;
actual_reserve < required_reserve
})
.map(|s| s.step_id)
.collect()
}
pub fn path_within_crank_range(path: &EnergizationPath, config: &BlackStartConfig) -> bool {
config
.black_start_units
.iter()
.any(|bs| path.total_length_km <= bs.max_crank_distance_km)
}
pub fn find_backfeed_violations(steps: &[RestorationStep]) -> Vec<usize> {
use crate::optimize::restoration::black_start::RestorationAction;
use std::collections::HashSet;
let mut energized: HashSet<usize> = HashSet::new();
let mut violations = Vec::new();
for step in steps {
if let RestorationAction::EnergizePath { path } = &step.action {
if energized.contains(&path.to_bus) {
violations.push(step.step_id);
} else {
energized.insert(path.to_bus);
}
energized.insert(path.from_bus);
}
}
violations
}
#[derive(Debug)]
pub struct ConstraintReport {
pub frequency_violations: Vec<usize>,
pub reserve_violations: Vec<usize>,
pub backfeed_violations: Vec<usize>,
pub all_satisfied: bool,
}
pub fn audit_plan(steps: &[RestorationStep], config: &BlackStartConfig) -> ConstraintReport {
let freq_v = find_frequency_violations(steps, config.frequency_tolerance_hz);
let rsv_v = find_reserve_violations(steps, config.reserve_margin_pct);
let bf_v = find_backfeed_violations(steps);
let all_ok = freq_v.is_empty() && rsv_v.is_empty() && bf_v.is_empty();
ConstraintReport {
frequency_violations: freq_v,
reserve_violations: rsv_v,
backfeed_violations: bf_v,
all_satisfied: all_ok,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::branch::Branch;
use crate::network::topology::PowerNetwork;
use crate::optimize::restoration::black_start::{
BlackStartConfig, BlackStartUnit, EnergizationPath, RestorationAction, RestorationStep,
};
fn make_path(
from_bus: usize,
to_bus: usize,
branch_seq: Vec<usize>,
km: f64,
) -> EnergizationPath {
EnergizationPath {
from_bus,
to_bus,
branch_sequence: branch_seq,
total_length_km: km,
charging_current_mvar: 0.0,
can_energize_at_t: 0.0,
}
}
fn make_step(
id: usize,
freq_hz: f64,
gen_mw: f64,
load_mw: f64,
action: RestorationAction,
) -> RestorationStep {
RestorationStep {
step_id: id,
time_min: id as f64,
action,
available_generation_mw: gen_mw,
connected_load_mw: load_mw,
frequency_hz: freq_hz,
notes: String::new(),
}
}
fn make_branch(from: usize, to: usize, rate_a: f64) -> Branch {
Branch {
from_bus: from,
to_bus: to,
r: 0.01,
x: 0.1,
b: 0.0,
rate_a,
rate_b: 0.0,
rate_c: 0.0,
tap: 0.0,
shift: 0.0,
status: true,
}
}
fn make_bs_unit(max_crank_km: f64) -> BlackStartUnit {
BlackStartUnit {
gen_id: 0,
bus: 1,
p_rated_mw: 100.0,
p_min_mw: 10.0,
ramp_rate_mw_per_min: 5.0,
crank_time_min: 15.0,
max_crank_distance_km: max_crank_km,
auxiliary_load_mw: 2.0,
priority: 1,
}
}
#[test]
fn test_check_voltage_constraint_within_tolerance() {
let path = make_path(1, 2, vec![0], 5.0); assert!(check_voltage_constraint(&path, 110.0, 0.05));
}
#[test]
fn test_check_voltage_constraint_exceeds_tolerance() {
let path = make_path(1, 2, vec![0], 10_000.0); assert!(!check_voltage_constraint(&path, 0.0, 0.05));
}
#[test]
fn test_find_frequency_violations_identifies_bad_steps() {
let action = RestorationAction::RampGenerator {
gen_id: 0,
target_mw: 50.0,
};
let steps = vec![
make_step(1, 50.0, 100.0, 60.0, action.clone()), make_step(2, 49.0, 100.0, 60.0, action.clone()), make_step(3, 50.3, 100.0, 60.0, action.clone()), ];
let violations = find_frequency_violations(&steps, 0.5);
assert_eq!(violations, vec![2]);
}
#[test]
fn test_find_frequency_violations_empty_when_all_ok() {
let action = RestorationAction::RampGenerator {
gen_id: 0,
target_mw: 50.0,
};
let steps = vec![
make_step(1, 50.0, 100.0, 60.0, action.clone()),
make_step(2, 50.4, 100.0, 60.0, action.clone()),
];
let violations = find_frequency_violations(&steps, 0.5);
assert!(violations.is_empty());
}
#[test]
fn test_check_thermal_constraint_within_rating_ok() {
let mut net = PowerNetwork::new(100.0);
net.branches.push(make_branch(1, 2, 1000.0)); let path = make_path(1, 2, vec![0], 10.0);
let result = check_thermal_constraint(&net, &path, 1.0, 110.0);
assert!(result.is_ok());
}
#[test]
fn test_check_thermal_constraint_overload_err() {
let mut net = PowerNetwork::new(100.0);
net.branches.push(make_branch(1, 2, 0.001)); let path = make_path(1, 2, vec![0], 10.0);
let result = check_thermal_constraint(&net, &path, 500.0, 110.0);
assert!(result.is_err());
}
#[test]
fn test_check_thermal_constraint_out_of_range_err() {
let net = PowerNetwork::new(100.0); let path = make_path(1, 2, vec![99], 10.0); let result = check_thermal_constraint(&net, &path, 10.0, 110.0);
assert!(result.is_err());
}
#[test]
fn test_check_thermal_constraint_unlimited_rating_skipped() {
let mut net = PowerNetwork::new(100.0);
net.branches.push(make_branch(1, 2, 0.0)); let path = make_path(1, 2, vec![0], 10.0);
let result = check_thermal_constraint(&net, &path, 1_000_000.0, 110.0);
assert!(result.is_ok());
}
#[test]
fn test_find_reserve_violations_identifies_violations() {
let action = RestorationAction::RampGenerator {
gen_id: 0,
target_mw: 50.0,
};
let steps = vec![
make_step(1, 50.0, 100.0, 90.0, action.clone()),
make_step(2, 50.0, 100.0, 60.0, action.clone()),
];
let violations = find_reserve_violations(&steps, 20.0);
assert_eq!(violations, vec![1]);
}
#[test]
fn test_path_within_crank_range_empty_units() {
let path = make_path(1, 2, vec![], 50.0);
let config = BlackStartConfig::default(); assert!(!path_within_crank_range(&path, &config));
}
#[test]
fn test_path_within_crank_range_within_range() {
let path = make_path(1, 2, vec![], 40.0);
let mut config = BlackStartConfig::default();
config.black_start_units.push(make_bs_unit(100.0));
assert!(path_within_crank_range(&path, &config));
}
#[test]
fn test_find_backfeed_violations_detects_duplicate() {
let path_a = make_path(1, 2, vec![], 10.0);
let path_b = make_path(1, 2, vec![], 10.0); let steps = vec![
make_step(
1,
50.0,
100.0,
0.0,
RestorationAction::EnergizePath { path: path_a },
),
make_step(
2,
50.0,
100.0,
0.0,
RestorationAction::EnergizePath { path: path_b },
),
];
let violations = find_backfeed_violations(&steps);
assert_eq!(violations, vec![2]);
}
#[test]
fn test_find_backfeed_violations_clean_sequence() {
let steps = vec![
make_step(
1,
50.0,
100.0,
0.0,
RestorationAction::EnergizePath {
path: make_path(1, 2, vec![], 10.0),
},
),
make_step(
2,
50.0,
100.0,
0.0,
RestorationAction::EnergizePath {
path: make_path(2, 3, vec![], 10.0),
},
),
];
let violations = find_backfeed_violations(&steps);
assert!(violations.is_empty());
}
#[test]
fn test_audit_plan_all_satisfied_clean() {
let config = BlackStartConfig {
frequency_tolerance_hz: 0.5,
reserve_margin_pct: 20.0,
..BlackStartConfig::default()
};
let steps = vec![
make_step(
1,
50.0,
100.0,
60.0,
RestorationAction::EnergizePath {
path: make_path(1, 2, vec![], 10.0),
},
),
make_step(
2,
50.2,
100.0,
65.0,
RestorationAction::EnergizePath {
path: make_path(2, 3, vec![], 10.0),
},
),
];
let report = audit_plan(&steps, &config);
assert!(report.all_satisfied);
assert!(report.frequency_violations.is_empty());
assert!(report.reserve_violations.is_empty());
assert!(report.backfeed_violations.is_empty());
}
#[test]
fn test_audit_plan_multiple_violations() {
let config = BlackStartConfig {
frequency_tolerance_hz: 0.5,
reserve_margin_pct: 20.0,
..BlackStartConfig::default()
};
let dup_path = make_path(1, 2, vec![], 5.0);
let steps = vec![
make_step(
1,
48.0,
100.0,
95.0,
RestorationAction::EnergizePath {
path: make_path(1, 2, vec![], 5.0),
},
),
make_step(
2,
50.0,
100.0,
90.0,
RestorationAction::EnergizePath { path: dup_path },
),
];
let report = audit_plan(&steps, &config);
assert!(!report.all_satisfied);
assert!(!report.frequency_violations.is_empty());
assert!(!report.reserve_violations.is_empty());
assert!(!report.backfeed_violations.is_empty());
}
}