use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
struct Lcg {
state: u64,
}
impl Lcg {
fn new(seed: u64) -> Self {
Lcg {
state: seed.wrapping_add(1),
} }
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.state
}
fn next_usize(&mut self, n: usize) -> usize {
if n == 0 {
return 0;
}
(self.next_u64() % n as u64) as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum PlacementStrategy {
Uniform,
WorstCase,
BestCase,
Random,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbabilisticHcConfig {
pub n_monte_carlo: usize,
pub dg_power_factor: f64,
pub voltage_limit_pu: (f64, f64),
pub thermal_limit_pct: f64,
pub step_mw: f64,
pub max_penetration_mw: f64,
pub placement_strategy: PlacementStrategy,
pub seed: u64,
}
impl Default for ProbabilisticHcConfig {
fn default() -> Self {
Self {
n_monte_carlo: 1000,
dg_power_factor: 1.0,
voltage_limit_pu: (0.95, 1.05),
thermal_limit_pct: 1.0,
step_mw: 0.1,
max_penetration_mw: 10.0,
placement_strategy: PlacementStrategy::Uniform,
seed: 42,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbabilisticHcResult {
pub hc_deterministic_mw: f64,
pub hc_p50_mw: f64,
pub hc_p90_mw: f64,
pub hc_p10_mw: f64,
pub voltage_violation_probability: f64,
pub thermal_violation_probability: f64,
pub weakest_bus: usize,
pub hc_by_bus: Vec<(usize, f64)>,
}
#[derive(Debug, Clone)]
pub struct FeederModel {
pub n_load_buses: usize,
pub r_cumulative: Vec<f64>,
pub x_cumulative: Vec<f64>,
pub branch_rating_mw: Vec<f64>,
pub bus_load_mw: Vec<f64>,
pub v_base_pu: f64,
}
impl FeederModel {
fn voltage_with_dg(&self, dg_bus: usize, p_dg_mw: f64) -> Vec<f64> {
let n = self.n_load_buses;
(0..n)
.map(|i| {
let base_drop: f64 = (0..=i)
.map(|j| {
if j == dg_bus {
-self.r_cumulative[j] * p_dg_mw / self.v_base_pu
} else {
self.r_cumulative[j] * self.bus_load_mw[j] / self.v_base_pu
}
})
.sum();
self.v_base_pu - base_drop
})
.collect()
}
fn branch_loading_with_dg(&self, dg_bus: usize, p_dg_mw: f64) -> Vec<f64> {
let n = self.n_load_buses;
(0..n)
.map(|seg| {
let total_downstream: f64 = (seg..n).map(|j| self.bus_load_mw[j]).sum::<f64>();
let dg_downstream = if dg_bus >= seg { p_dg_mw } else { 0.0 };
(total_downstream - dg_downstream).max(0.0)
})
.collect()
}
fn hc_at_bus(&self, bus: usize, config: &ProbabilisticHcConfig) -> f64 {
let mut p = 0.0_f64;
loop {
p += config.step_mw;
if p > config.max_penetration_mw {
return config.max_penetration_mw;
}
let voltages = self.voltage_with_dg(bus, p);
let loadings = self.branch_loading_with_dg(bus, p);
let v_viol = voltages
.iter()
.any(|&v| v < config.voltage_limit_pu.0 || v > config.voltage_limit_pu.1);
let rated = self
.branch_rating_mw
.get(bus)
.copied()
.unwrap_or(f64::INFINITY);
let t_viol = loadings
.iter()
.any(|&l| l > rated * config.thermal_limit_pct);
if v_viol || t_viol {
return (p - config.step_mw).max(0.0);
}
}
}
}
pub fn probabilistic_hosting_capacity(
model: &FeederModel,
config: &ProbabilisticHcConfig,
) -> Result<ProbabilisticHcResult> {
if model.n_load_buses == 0 {
return Err(OxiGridError::InvalidParameter(
"FeederModel has no load buses".into(),
));
}
if config.step_mw <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"step_mw must be positive".into(),
));
}
if config.max_penetration_mw < config.step_mw {
return Err(OxiGridError::InvalidParameter(
"max_penetration_mw must be >= step_mw".into(),
));
}
let n = model.n_load_buses;
let hc_by_bus: Vec<(usize, f64)> = (0..n)
.map(|bus| (bus, model.hc_at_bus(bus, config)))
.collect();
let (weakest_bus, hc_deterministic_mw) = hc_by_bus
.iter()
.copied()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, 0.0));
let best_bus = hc_by_bus
.iter()
.copied()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(b, _)| b)
.unwrap_or(0);
let mut lcg = Lcg::new(config.seed);
let mut mc_hcs: Vec<f64> = Vec::with_capacity(config.n_monte_carlo);
for _ in 0..config.n_monte_carlo {
let bus = match config.placement_strategy {
PlacementStrategy::Uniform | PlacementStrategy::Random => lcg.next_usize(n),
PlacementStrategy::WorstCase => weakest_bus,
PlacementStrategy::BestCase => best_bus,
};
mc_hcs.push(model.hc_at_bus(bus, config));
}
mc_hcs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let percentile = |p: f64| -> f64 {
let idx = ((p / 100.0) * mc_hcs.len() as f64) as usize;
mc_hcs
.get(idx.min(mc_hcs.len().saturating_sub(1)))
.copied()
.unwrap_or(0.0)
};
let hc_p10_mw = percentile(10.0);
let hc_p50_mw = percentile(50.0);
let hc_p90_mw = percentile(90.0);
let p50 = hc_p50_mw;
let mut v_viol_count = 0usize;
let mut t_viol_count = 0usize;
let n_probe = config.n_monte_carlo.min(200); let mut lcg2 = Lcg::new(config.seed.wrapping_add(999));
for _ in 0..n_probe {
let bus = match config.placement_strategy {
PlacementStrategy::Uniform | PlacementStrategy::Random => lcg2.next_usize(n),
PlacementStrategy::WorstCase => weakest_bus,
PlacementStrategy::BestCase => best_bus,
};
let voltages = model.voltage_with_dg(bus, p50);
let loadings = model.branch_loading_with_dg(bus, p50);
let rated = model
.branch_rating_mw
.get(bus)
.copied()
.unwrap_or(f64::INFINITY);
if voltages
.iter()
.any(|&v| v < config.voltage_limit_pu.0 || v > config.voltage_limit_pu.1)
{
v_viol_count += 1;
}
if loadings
.iter()
.any(|&l| l > rated * config.thermal_limit_pct)
{
t_viol_count += 1;
}
}
let voltage_violation_probability = v_viol_count as f64 / n_probe.max(1) as f64;
let thermal_violation_probability = t_viol_count as f64 / n_probe.max(1) as f64;
Ok(ProbabilisticHcResult {
hc_deterministic_mw,
hc_p50_mw,
hc_p90_mw,
hc_p10_mw,
voltage_violation_probability,
thermal_violation_probability,
weakest_bus,
hc_by_bus,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_feeder_model() -> FeederModel {
FeederModel {
n_load_buses: 4,
r_cumulative: vec![0.1, 0.2, 0.3, 0.4],
x_cumulative: vec![0.05, 0.10, 0.15, 0.20],
branch_rating_mw: vec![5.0, 5.0, 5.0, 5.0],
bus_load_mw: vec![0.5, 0.5, 0.5, 0.5],
v_base_pu: 1.0,
}
}
#[test]
fn test_uniform_p50_ge_deterministic() {
let model = simple_feeder_model();
let config = ProbabilisticHcConfig {
n_monte_carlo: 500,
placement_strategy: PlacementStrategy::Uniform,
step_mw: 0.2,
max_penetration_mw: 8.0,
seed: 123,
..Default::default()
};
let result = probabilistic_hosting_capacity(&model, &config).expect("hc analysis");
assert!(
result.hc_p50_mw >= result.hc_deterministic_mw - 0.2,
"P50={:.2} should be >= deterministic={:.2} (±step)",
result.hc_p50_mw,
result.hc_deterministic_mw
);
}
#[test]
fn test_worst_case_matches_deterministic() {
let model = simple_feeder_model();
let config = ProbabilisticHcConfig {
n_monte_carlo: 100,
placement_strategy: PlacementStrategy::WorstCase,
step_mw: 0.1,
max_penetration_mw: 10.0,
seed: 42,
..Default::default()
};
let result = probabilistic_hosting_capacity(&model, &config).expect("hc analysis");
assert!(
(result.hc_p50_mw - result.hc_deterministic_mw).abs() < 0.11,
"WorstCase: P50={:.3} should match deterministic={:.3}",
result.hc_p50_mw,
result.hc_deterministic_mw
);
}
#[test]
fn test_max_penetration_cap_respected() {
let model = simple_feeder_model();
let config = ProbabilisticHcConfig {
n_monte_carlo: 200,
placement_strategy: PlacementStrategy::BestCase,
step_mw: 0.5,
max_penetration_mw: 2.0, seed: 7,
..Default::default()
};
let result = probabilistic_hosting_capacity(&model, &config).expect("hc analysis");
assert!(
result.hc_p90_mw <= config.max_penetration_mw + config.step_mw,
"P90={:.2} should not exceed max_penetration_mw={:.2}",
result.hc_p90_mw,
config.max_penetration_mw
);
assert!(
result.hc_deterministic_mw <= config.max_penetration_mw + config.step_mw,
"Deterministic HC should not exceed max"
);
}
#[test]
fn test_hc_by_bus_populated() {
let model = simple_feeder_model();
let config = ProbabilisticHcConfig::default();
let result = probabilistic_hosting_capacity(&model, &config).expect("hc analysis");
assert_eq!(
result.hc_by_bus.len(),
model.n_load_buses,
"hc_by_bus should have entry per load bus"
);
for (bus_idx, hc) in &result.hc_by_bus {
assert!(*bus_idx < model.n_load_buses);
assert!(*hc >= 0.0 && *hc <= config.max_penetration_mw + config.step_mw);
}
}
#[test]
fn test_percentile_ordering() {
let model = simple_feeder_model();
let config = ProbabilisticHcConfig {
n_monte_carlo: 300,
placement_strategy: PlacementStrategy::Uniform,
seed: 99,
..Default::default()
};
let result = probabilistic_hosting_capacity(&model, &config).expect("hc analysis");
assert!(
result.hc_p10_mw <= result.hc_p50_mw + 1e-9,
"P10={:.3} should be <= P50={:.3}",
result.hc_p10_mw,
result.hc_p50_mw
);
assert!(
result.hc_p50_mw <= result.hc_p90_mw + 1e-9,
"P50={:.3} should be <= P90={:.3}",
result.hc_p50_mw,
result.hc_p90_mw
);
}
#[test]
fn test_empty_feeder_returns_error() {
let model = FeederModel {
n_load_buses: 0,
r_cumulative: vec![],
x_cumulative: vec![],
branch_rating_mw: vec![],
bus_load_mw: vec![],
v_base_pu: 1.0,
};
let config = ProbabilisticHcConfig::default();
let result = probabilistic_hosting_capacity(&model, &config);
assert!(result.is_err(), "Empty feeder should return error");
}
#[test]
fn test_lcg_reproducible() {
let mut lcg1 = Lcg::new(42);
let mut lcg2 = Lcg::new(42);
for _ in 0..100 {
assert_eq!(lcg1.next_u64(), lcg2.next_u64());
}
}
}