use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum SlfError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("no uncertain inputs registered — call add_uncertain_input() first")]
NoInputs,
#[error("base load size {0} does not match n_buses {1}")]
LoadSizeMismatch(usize, usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StochasticLfConfig {
pub n_samples: usize,
pub confidence_level: f64,
pub method: StochasticMethod,
pub seed: u64,
}
impl Default for StochasticLfConfig {
fn default() -> Self {
Self {
n_samples: 1000,
confidence_level: 0.95,
method: StochasticMethod::MonteCarlo,
seed: 42,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StochasticMethod {
MonteCarlo,
LatinHypercubeSampling,
PointEstimate2m,
Linearized,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UncertainInput {
pub bus: usize,
pub variable: InputVariable,
pub distribution: Distribution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputVariable {
LoadP,
LoadQ,
GenP,
GenQ,
WindSpeed,
SolarIrradiance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Distribution {
Normal {
mean: f64,
std_dev: f64,
},
Uniform {
low: f64,
high: f64,
},
Weibull {
scale: f64,
shape: f64,
},
Beta {
alpha: f64,
beta: f64,
},
LogNormal {
mu: f64,
sigma: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusStatistics {
pub bus: usize,
pub v_mean: f64,
pub v_std: f64,
pub v_min: f64,
pub v_max: f64,
pub v_p5: f64,
pub v_p95: f64,
pub prob_violation_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchStatistics {
pub branch_id: usize,
pub flow_mean_mw: f64,
pub flow_std_mw: f64,
pub flow_p95_mw: f64,
pub prob_overload: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StochasticLfResult {
pub bus_stats: Vec<BusStatistics>,
pub branch_stats: Vec<BranchStatistics>,
pub n_converged: usize,
pub n_diverged: usize,
pub computational_time_note: String,
}
#[derive(Debug, Clone)]
pub struct StochasticLfSolver {
config: StochasticLfConfig,
uncertain_inputs: Vec<UncertainInput>,
n_buses: usize,
base_load_mw: Vec<f64>,
base_load_mvar: Vec<f64>,
}
impl StochasticLfSolver {
pub fn new(config: StochasticLfConfig, n_buses: usize) -> Self {
Self {
config,
uncertain_inputs: Vec::new(),
n_buses,
base_load_mw: vec![0.0; n_buses],
base_load_mvar: vec![0.0; n_buses],
}
}
pub fn add_uncertain_input(&mut self, input: UncertainInput) {
self.uncertain_inputs.push(input);
}
pub fn set_base_loads(&mut self, p_mw: Vec<f64>, q_mvar: Vec<f64>) {
self.base_load_mw = p_mw;
self.base_load_mvar = q_mvar;
}
pub fn solve(&self) -> Result<StochasticLfResult, SlfError> {
if self.uncertain_inputs.is_empty() {
return Err(SlfError::NoInputs);
}
if self.config.n_samples == 0 {
return Err(SlfError::InvalidConfig("n_samples must be > 0".into()));
}
if self.base_load_mw.len() != self.base_load_mvar.len() {
return Err(SlfError::LoadSizeMismatch(
self.base_load_mw.len(),
self.base_load_mvar.len(),
));
}
match self.config.method {
StochasticMethod::MonteCarlo => self.solve_mc(false),
StochasticMethod::LatinHypercubeSampling => self.solve_mc(true),
StochasticMethod::PointEstimate2m => self.solve_point_estimate(),
StochasticMethod::Linearized => self.solve_linearized(),
}
}
fn solve_mc(&self, use_lhs: bool) -> Result<StochasticLfResult, SlfError> {
let n = self.config.n_samples;
let mut rng = self.config.seed;
let lhs_u: Vec<Vec<f64>> = if use_lhs {
self.uncertain_inputs
.iter()
.map(|_| Self::lhs_sample(n, &mut rng))
.collect()
} else {
Vec::new()
};
let n_branches = self.n_buses.saturating_sub(1); let mut v_samples: Vec<Vec<f64>> = vec![Vec::with_capacity(n); self.n_buses];
let mut flow_samples: Vec<Vec<f64>> = vec![Vec::with_capacity(n); n_branches];
for s in 0..n {
let mut delta_p = vec![0.0_f64; self.n_buses];
for (ii, inp) in self.uncertain_inputs.iter().enumerate() {
let u = if use_lhs {
lhs_u
.get(ii)
.and_then(|v| v.get(s))
.copied()
.unwrap_or_else(|| Self::lcg_uniform(&mut rng))
} else {
Self::lcg_uniform(&mut rng)
};
let sampled = self.sample_distribution_from_u(&inp.distribution, u, &mut rng);
let base = match inp.variable {
InputVariable::LoadP
| InputVariable::WindSpeed
| InputVariable::SolarIrradiance => {
self.base_load_mw.get(inp.bus).copied().unwrap_or(0.0)
}
InputVariable::GenP => -self.base_load_mw.get(inp.bus).copied().unwrap_or(0.0),
InputVariable::LoadQ | InputVariable::GenQ => {
self.base_load_mvar.get(inp.bus).copied().unwrap_or(0.0)
}
};
if inp.bus < self.n_buses {
delta_p[inp.bus] += sampled - base;
}
}
let mut cum_p = 0.0_f64;
for (b, vs) in v_samples.iter_mut().enumerate() {
cum_p += self.base_load_mw.get(b).copied().unwrap_or(0.0) + delta_p[b];
let v = (1.0 - 0.01 * cum_p).clamp(0.5, 1.5);
vs.push(v);
}
for (br, fs) in flow_samples.iter_mut().enumerate() {
let downstream_load: f64 = (br + 1..self.n_buses)
.map(|b| self.base_load_mw.get(b).copied().unwrap_or(0.0) + delta_p[b])
.sum();
fs.push(downstream_load);
}
}
let bus_stats: Vec<BusStatistics> = (0..self.n_buses)
.map(|b| compute_bus_stats(b, &v_samples[b]))
.collect();
let base_branch0_flow: f64 = self.base_load_mw.iter().sum::<f64>();
let rating_proxy = (base_branch0_flow * 1.1).max(1.0);
let branch_stats: Vec<BranchStatistics> = (0..n_branches)
.map(|br| compute_branch_stats(br, &flow_samples[br], rating_proxy))
.collect();
let method_label = if use_lhs { "LHS" } else { "Monte Carlo" };
Ok(StochasticLfResult {
n_converged: n,
n_diverged: 0,
bus_stats,
branch_stats,
computational_time_note: format!(
"{method_label}: {n} samples, {nb} buses, DC linearized model",
nb = self.n_buses
),
})
}
fn solve_point_estimate(&self) -> Result<StochasticLfResult, SlfError> {
let m = self.uncertain_inputs.len();
let k = 3.0_f64.sqrt();
let mut v_samples: Vec<Vec<f64>> = vec![Vec::new(); self.n_buses];
let mut flow_samples: Vec<Vec<f64>> = vec![Vec::new(); self.n_buses.saturating_sub(1)];
self.evaluate_at_deltas(&vec![0.0; self.n_buses], &mut v_samples, &mut flow_samples);
for inp in &self.uncertain_inputs {
if inp.bus >= self.n_buses {
continue;
}
let (mean_val, std_val) = distribution_mean_std(&inp.distribution);
for &sign in &[1.0_f64, -1.0_f64] {
let delta_p_val = sign * k * std_val;
let mut delta_p = vec![0.0_f64; self.n_buses];
delta_p[inp.bus] = delta_p_val;
if matches!(inp.variable, InputVariable::GenP | InputVariable::GenQ) {
delta_p[inp.bus] = -delta_p_val;
}
let _ = mean_val; self.evaluate_at_deltas(&delta_p, &mut v_samples, &mut flow_samples);
}
}
let n_evals = 1 + 2 * m;
let bus_stats: Vec<BusStatistics> = (0..self.n_buses)
.map(|b| compute_bus_stats(b, &v_samples[b]))
.collect();
let base_flow: f64 = self.base_load_mw.iter().sum::<f64>();
let rating_proxy = (base_flow * 1.1).max(1.0);
let branch_stats: Vec<BranchStatistics> = (0..self.n_buses.saturating_sub(1))
.map(|br| compute_branch_stats(br, &flow_samples[br], rating_proxy))
.collect();
Ok(StochasticLfResult {
n_converged: n_evals,
n_diverged: 0,
bus_stats,
branch_stats,
computational_time_note: format!(
"Point Estimate 2m+1: {} evaluations ({m} inputs)",
n_evals
),
})
}
fn evaluate_at_deltas(
&self,
delta_p: &[f64],
v_samples: &mut [Vec<f64>],
flow_samples: &mut [Vec<f64>],
) {
let mut cum_p = 0.0_f64;
for (b, vs) in v_samples.iter_mut().enumerate() {
cum_p += self.base_load_mw.get(b).copied().unwrap_or(0.0)
+ delta_p.get(b).copied().unwrap_or(0.0);
let v = (1.0 - 0.01 * cum_p).clamp(0.5, 1.5);
vs.push(v);
}
for (br, fs) in flow_samples.iter_mut().enumerate() {
let downstream: f64 = (br + 1..self.n_buses)
.map(|b| {
self.base_load_mw.get(b).copied().unwrap_or(0.0)
+ delta_p.get(b).copied().unwrap_or(0.0)
})
.sum();
fs.push(downstream);
}
}
fn solve_linearized(&self) -> Result<StochasticLfResult, SlfError> {
let mut v_mean = vec![0.0_f64; self.n_buses];
let mut v_var = vec![0.0_f64; self.n_buses];
let mut cum = 0.0_f64;
for (b, vm) in v_mean.iter_mut().enumerate() {
cum += self.base_load_mw.get(b).copied().unwrap_or(0.0);
*vm = (1.0 - 0.01 * cum).clamp(0.5, 1.5);
}
for inp in &self.uncertain_inputs {
if inp.bus >= self.n_buses {
continue;
}
let (_, std_val) = distribution_mean_std(&inp.distribution);
let var_input = std_val * std_val;
for vv in v_var.iter_mut().take(self.n_buses).skip(inp.bus) {
*vv += 0.01 * 0.01 * var_input;
}
}
let bus_stats: Vec<BusStatistics> = (0..self.n_buses)
.map(|b| {
let mean = v_mean[b];
let std = v_var[b].sqrt();
let z95 = 1.645_f64;
let v_p5 = mean - z95 * std;
let v_p95 = mean + z95 * std;
let p_low = gaussian_tail_prob((mean - 0.95) / (std + 1e-12));
let p_high = gaussian_tail_prob((1.05 - mean) / (std + 1e-12));
let prob_violation = (p_low + p_high).clamp(0.0, 1.0);
BusStatistics {
bus: b,
v_mean: mean,
v_std: std,
v_min: v_p5.min(mean),
v_max: v_p95.max(mean),
v_p5,
v_p95,
prob_violation_pu: prob_violation,
}
})
.collect();
let n_branches = self.n_buses.saturating_sub(1);
let base_flow: f64 = self.base_load_mw.iter().sum::<f64>();
let rating_proxy = (base_flow * 1.1).max(1.0);
let branch_stats: Vec<BranchStatistics> = (0..n_branches)
.map(|br| {
let flow_mean: f64 = (br + 1..self.n_buses)
.map(|b| self.base_load_mw.get(b).copied().unwrap_or(0.0))
.sum();
let mut flow_var = 0.0_f64;
for inp in &self.uncertain_inputs {
if inp.bus > br && inp.bus < self.n_buses {
let (_, std_val) = distribution_mean_std(&inp.distribution);
flow_var += std_val * std_val;
}
}
let flow_std = flow_var.sqrt();
let flow_p95 = flow_mean + 1.645 * flow_std;
let prob_overload =
gaussian_tail_prob((rating_proxy - flow_mean) / (flow_std + 1e-12));
BranchStatistics {
branch_id: br,
flow_mean_mw: flow_mean,
flow_std_mw: flow_std,
flow_p95_mw: flow_p95,
prob_overload,
}
})
.collect();
Ok(StochasticLfResult {
n_converged: 1,
n_diverged: 0,
bus_stats,
branch_stats,
computational_time_note: "Linearized: 1 DC solve + analytical variance propagation"
.to_string(),
})
}
fn lcg_uniform(state: &mut u64) -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(*state >> 11) as f64 / (1u64 << 53) as f64
}
fn sample_distribution_from_u(&self, dist: &Distribution, u: f64, rng: &mut u64) -> f64 {
match dist {
Distribution::Normal { mean, std_dev } => {
let u2 = Self::lcg_uniform(rng);
Self::box_muller(u, u2) * std_dev + mean
}
Distribution::LogNormal { mu, sigma } => {
let u2 = Self::lcg_uniform(rng);
let z = Self::box_muller(u, u2);
(z * sigma + mu).exp()
}
Distribution::Uniform { low, high } => low + u * (high - low),
Distribution::Weibull { scale, shape } => {
let u_safe = u.clamp(1e-10, 1.0 - 1e-10);
scale * (-(1.0 - u_safe).ln()).powf(1.0 / shape)
}
Distribution::Beta { alpha, beta } => {
beta_quantile_approx(*alpha, *beta, u)
}
}
}
fn box_muller(u1: f64, u2: f64) -> f64 {
let u1 = u1.clamp(1e-10, 1.0 - 1e-10);
let u2 = u2.clamp(1e-10, 1.0 - 1e-10);
(-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos()
}
fn lhs_sample(n: usize, rng: &mut u64) -> Vec<f64> {
let mut result: Vec<f64> = (0..n)
.map(|i| {
let stratum_low = i as f64 / n as f64;
let u_within = Self::lcg_uniform(rng);
stratum_low + u_within / n as f64
})
.collect();
for i in (1..n).rev() {
let j_float = Self::lcg_uniform(rng) * (i + 1) as f64;
let j = (j_float as usize).min(i);
result.swap(i, j);
}
result
}
}
pub fn sample_distribution(dist: &Distribution, rng: &mut u64) -> f64 {
let solver = StochasticLfSolver::new(StochasticLfConfig::default(), 1);
let u = StochasticLfSolver::lcg_uniform(rng);
solver.sample_distribution_from_u(dist, u, rng)
}
fn compute_bus_stats(bus: usize, samples: &[f64]) -> BusStatistics {
if samples.is_empty() {
return BusStatistics {
bus,
v_mean: 1.0,
v_std: 0.0,
v_min: 1.0,
v_max: 1.0,
v_p5: 1.0,
v_p95: 1.0,
prob_violation_pu: 0.0,
};
}
let n = samples.len() as f64;
let mean = samples.iter().sum::<f64>() / n;
let var = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n.max(1.0);
let std = var.sqrt();
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let v_min = sorted.first().copied().unwrap_or(mean);
let v_max = sorted.last().copied().unwrap_or(mean);
let p5_idx = ((0.05 * n) as usize).min(sorted.len().saturating_sub(1));
let p95_idx = ((0.95 * n) as usize).min(sorted.len().saturating_sub(1));
let v_p5 = sorted[p5_idx];
let v_p95 = sorted[p95_idx];
let violations = samples
.iter()
.filter(|&&v| !(0.95..=1.05).contains(&v))
.count();
let prob_violation = violations as f64 / n;
BusStatistics {
bus,
v_mean: mean,
v_std: std,
v_min,
v_max,
v_p5,
v_p95,
prob_violation_pu: prob_violation,
}
}
fn compute_branch_stats(branch_id: usize, samples: &[f64], rating_mw: f64) -> BranchStatistics {
if samples.is_empty() {
return BranchStatistics {
branch_id,
flow_mean_mw: 0.0,
flow_std_mw: 0.0,
flow_p95_mw: 0.0,
prob_overload: 0.0,
};
}
let n = samples.len() as f64;
let mean = samples.iter().sum::<f64>() / n;
let var = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n.max(1.0);
let std = var.sqrt();
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let p95_idx = ((0.95 * n) as usize).min(sorted.len().saturating_sub(1));
let flow_p95 = sorted[p95_idx];
let overloads = samples.iter().filter(|&&f| f.abs() > rating_mw).count();
let prob_overload = overloads as f64 / n;
BranchStatistics {
branch_id,
flow_mean_mw: mean,
flow_std_mw: std,
flow_p95_mw: flow_p95,
prob_overload,
}
}
fn distribution_mean_std(dist: &Distribution) -> (f64, f64) {
match dist {
Distribution::Normal { mean, std_dev } => (*mean, *std_dev),
Distribution::Uniform { low, high } => {
let mean = (low + high) / 2.0;
let std = (high - low) / (12.0_f64.sqrt());
(mean, std)
}
Distribution::Weibull { scale, shape } => {
use core::f64::consts::PI;
let gamma1 = gamma_approx(1.0 + 1.0 / shape);
let gamma2 = gamma_approx(1.0 + 2.0 / shape);
let mean = scale * gamma1;
let var = scale * scale * (gamma2 - gamma1 * gamma1);
let _ = PI;
(mean, var.sqrt())
}
Distribution::Beta { alpha, beta } => {
let mean = alpha / (alpha + beta);
let var = alpha * beta / ((alpha + beta).powi(2) * (alpha + beta + 1.0));
(mean, var.sqrt())
}
Distribution::LogNormal { mu, sigma } => {
let mean = (mu + sigma * sigma / 2.0).exp();
let var = ((sigma * sigma).exp() - 1.0) * (2.0 * mu + sigma * sigma).exp();
(mean, var.sqrt())
}
}
}
fn gamma_approx(x: f64) -> f64 {
if x < 0.5 {
return 1.0;
}
let coeffs = [
0.999_999_999_999_809_9,
676.520_368_121_885_1,
-1_259.139_216_722_402_8,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_6e-7,
];
let z = x - 1.0;
let mut sum = coeffs[0];
for (i, &c) in coeffs[1..].iter().enumerate() {
sum += c / (z + i as f64 + 1.0);
}
let t = z + 7.5;
(2.0 * core::f64::consts::PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * sum
}
fn gaussian_tail_prob(z: f64) -> f64 {
0.5 * erfc_approx(z / core::f64::consts::SQRT_2)
}
fn erfc_approx(x: f64) -> f64 {
if x < 0.0 {
return 2.0 - erfc_approx(-x);
}
let t = 1.0 / (1.0 + 0.3275911 * x);
let poly = t
* (0.254829592
+ t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
poly * (-x * x).exp()
}
fn beta_quantile_approx(alpha: f64, beta_param: f64, u: f64) -> f64 {
let mean = alpha / (alpha + beta_param);
let std =
(alpha * beta_param / ((alpha + beta_param).powi(2) * (alpha + beta_param + 1.0))).sqrt();
let z = 2.0 * u - 1.0; (mean + std * z * 2.0).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_solver(n_samples: usize, method: StochasticMethod) -> StochasticLfSolver {
let cfg = StochasticLfConfig {
n_samples,
confidence_level: 0.95,
method,
seed: 12345,
};
let mut solver = StochasticLfSolver::new(cfg, 4);
solver.set_base_loads(vec![0.0, 1.0, 2.0, 1.5], vec![0.0, 0.3, 0.6, 0.4]);
solver
}
#[test]
fn test_deterministic_case() {
let mut solver = make_solver(200, StochasticMethod::MonteCarlo);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 1.0,
std_dev: 0.0,
},
});
let result = solver.solve().expect("solve must succeed");
let stats = &result.bus_stats[1];
assert!(
stats.v_std < 1e-10,
"With std_dev=0, voltage std must be ~0, got {}",
stats.v_std
);
assert!(
(stats.v_p95 - stats.v_p5).abs() < 1e-9,
"P5 and P95 must be equal for zero variance"
);
}
#[test]
fn test_normal_distribution() {
let mut solver = make_solver(1000, StochasticMethod::MonteCarlo);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 1.0,
std_dev: 0.2,
},
});
let result = solver.solve().expect("solve must succeed");
let stats = &result.bus_stats[1];
let base_v = 1.0 - 0.01 * (0.0 + 1.0); assert!(
(stats.v_mean - base_v).abs() < 0.05,
"Mean voltage should be close to base: mean={}, base={}",
stats.v_mean,
base_v
);
assert!(
stats.v_std > 0.0,
"Nonzero std_dev must produce nonzero voltage std"
);
assert!(result.n_converged == 1000);
}
#[test]
fn test_violation_probability_increases_with_uncertainty() {
let make_with_std = |std: f64| -> f64 {
let cfg = StochasticLfConfig {
n_samples: 500,
confidence_level: 0.95,
method: StochasticMethod::MonteCarlo,
seed: 99,
};
let mut solver = StochasticLfSolver::new(cfg, 2);
solver.set_base_loads(vec![0.0, 0.5], vec![0.0, 0.1]);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 0.5,
std_dev: std,
},
});
solver
.solve()
.expect("solve")
.bus_stats
.last()
.map(|s| s.prob_violation_pu)
.unwrap_or(0.0)
};
let low = make_with_std(0.01); let high = make_with_std(2.0); assert!(
high > low,
"Higher uncertainty must produce > violation probability: low={low}, high={high}"
);
}
#[test]
fn test_lhs_vs_mc_similar_mean() {
let add_input = |s: &mut StochasticLfSolver| {
s.add_uncertain_input(UncertainInput {
bus: 2,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 2.0,
std_dev: 0.3,
},
});
};
let mut mc_solver = make_solver(500, StochasticMethod::MonteCarlo);
add_input(&mut mc_solver);
let mc_result = mc_solver.solve().expect("MC solve");
let mut lhs_solver = make_solver(200, StochasticMethod::LatinHypercubeSampling);
add_input(&mut lhs_solver);
let lhs_result = lhs_solver.solve().expect("LHS solve");
let mc_mean = mc_result.bus_stats[2].v_mean;
let lhs_mean = lhs_result.bus_stats[2].v_mean;
assert!(
(mc_mean - lhs_mean).abs() < 0.02,
"LHS and MC means should be close: mc={mc_mean}, lhs={lhs_mean}"
);
}
#[test]
fn test_percentiles_ordering() {
let mut solver = make_solver(500, StochasticMethod::MonteCarlo);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Uniform {
low: 0.5,
high: 2.0,
},
});
let result = solver.solve().expect("solve");
for stats in &result.bus_stats {
assert!(
stats.v_p5 <= stats.v_mean + 1e-9,
"P5 must be <= mean at bus {}: p5={}, mean={}",
stats.bus,
stats.v_p5,
stats.v_mean
);
assert!(
stats.v_p95 >= stats.v_mean - 1e-9,
"P95 must be >= mean at bus {}: p95={}, mean={}",
stats.bus,
stats.v_p95,
stats.v_mean
);
}
}
#[test]
fn test_linearized_method() {
let mut solver = make_solver(1, StochasticMethod::Linearized);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 1.0,
std_dev: 0.5,
},
});
let result = solver.solve().expect("linearized solve");
assert_eq!(result.n_converged, 1);
assert!(
result.bus_stats[1].v_std > 0.0,
"Linearized must propagate variance"
);
assert!(result.bus_stats[1].v_p5 < result.bus_stats[1].v_p95);
}
#[test]
fn test_point_estimate_evaluations() {
let mut solver = make_solver(1, StochasticMethod::PointEstimate2m);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 1.0,
std_dev: 0.2,
},
});
solver.add_uncertain_input(UncertainInput {
bus: 2,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 2.0,
std_dev: 0.3,
},
});
let result = solver.solve().expect("PEM solve");
assert_eq!(result.n_converged, 5, "2m+1=5 evaluations for m=2");
}
#[test]
fn test_sample_uniform_in_range() {
let dist = Distribution::Uniform {
low: 3.0,
high: 7.0,
};
let mut rng = 42u64;
for _ in 0..200 {
let v = sample_distribution(&dist, &mut rng);
assert!(
(3.0..=7.0).contains(&v),
"Uniform sample {v} outside [3.0, 7.0]"
);
}
}
#[test]
fn test_sample_normal_zero_std_returns_mean() {
let dist = Distribution::Normal {
mean: 5.5,
std_dev: 0.0,
};
let mut rng = 1u64;
for _ in 0..50 {
let v = sample_distribution(&dist, &mut rng);
assert!(
(v - 5.5).abs() < 1e-12,
"Normal with std_dev=0 must return mean; got {v}"
);
}
}
#[test]
fn test_sample_weibull_positive() {
let dist = Distribution::Weibull {
scale: 2.0,
shape: 1.5,
};
let mut rng = 77u64;
for _ in 0..200 {
let v = sample_distribution(&dist, &mut rng);
assert!(v > 0.0, "Weibull sample must be positive; got {v}");
}
}
#[test]
fn test_sample_beta_in_unit_interval() {
let dist = Distribution::Beta {
alpha: 2.0,
beta: 3.0,
};
let mut rng = 55u64;
for _ in 0..200 {
let v = sample_distribution(&dist, &mut rng);
assert!((0.0..=1.0).contains(&v), "Beta sample {v} outside [0, 1]");
}
}
#[test]
fn test_sample_lognormal_positive() {
let dist = Distribution::LogNormal {
mu: 0.0,
sigma: 0.5,
};
let mut rng = 33u64;
for _ in 0..200 {
let v = sample_distribution(&dist, &mut rng);
assert!(v > 0.0, "LogNormal sample must be positive; got {v}");
}
}
#[test]
fn test_sample_determinism_same_seed() {
let dist = Distribution::Normal {
mean: 1.0,
std_dev: 0.3,
};
let mut rng_a = 9999u64;
let mut rng_b = 9999u64;
for _ in 0..100 {
let a = sample_distribution(&dist, &mut rng_a);
let b = sample_distribution(&dist, &mut rng_b);
assert_eq!(
a, b,
"Same seed must yield identical samples; got a={a}, b={b}"
);
}
}
#[test]
fn test_slf_error_variants() {
let e1 = SlfError::InvalidConfig("bad config".to_string());
let e2 = SlfError::NoInputs;
let e3 = SlfError::LoadSizeMismatch(3, 5);
assert!(
matches!(e1, SlfError::InvalidConfig(_)),
"Expected InvalidConfig variant"
);
assert!(
matches!(e2, SlfError::NoInputs),
"Expected NoInputs variant"
);
assert!(
matches!(e3, SlfError::LoadSizeMismatch(3, 5)),
"Expected LoadSizeMismatch(3,5) variant"
);
}
#[test]
fn test_solve_no_inputs_returns_error() {
let cfg = StochasticLfConfig {
n_samples: 10,
confidence_level: 0.95,
method: StochasticMethod::MonteCarlo,
seed: 1,
};
let mut solver = StochasticLfSolver::new(cfg, 3);
solver.set_base_loads(vec![0.0, 1.0, 1.0], vec![0.0, 0.2, 0.2]);
let err = solver.solve().expect_err("expected NoInputs error");
assert!(
matches!(err, SlfError::NoInputs),
"Expected NoInputs, got {err:?}"
);
}
#[test]
fn test_solve_load_size_mismatch_returns_error() {
let cfg = StochasticLfConfig {
n_samples: 10,
confidence_level: 0.95,
method: StochasticMethod::MonteCarlo,
seed: 2,
};
let mut solver = StochasticLfSolver::new(cfg, 3);
solver.set_base_loads(vec![0.0, 1.0, 1.0], vec![0.0, 0.2]);
solver.add_uncertain_input(UncertainInput {
bus: 1,
variable: InputVariable::LoadP,
distribution: Distribution::Normal {
mean: 1.0,
std_dev: 0.1,
},
});
let err = solver.solve().expect_err("expected LoadSizeMismatch error");
assert!(
matches!(err, SlfError::LoadSizeMismatch(_, _)),
"Expected LoadSizeMismatch, got {err:?}"
);
}
#[test]
fn test_config_default_fields() {
let cfg = StochasticLfConfig::default();
assert!(
cfg.n_samples > 0,
"Default n_samples must be positive; got {}",
cfg.n_samples
);
assert!(
cfg.confidence_level > 0.0 && cfg.confidence_level < 1.0,
"Default confidence_level must be in (0,1); got {}",
cfg.confidence_level
);
assert!(
matches!(cfg.method, StochasticMethod::MonteCarlo),
"Default method must be MonteCarlo"
);
}
}