use crate::error::{OxiGridError, Result};
#[cfg(feature = "powerflow")]
use crate::network::topology::PowerNetwork;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum GridStrength {
Strong,
Medium,
Weak,
VeryWeak,
}
impl GridStrength {
pub fn from_scr(scr: f64) -> Self {
if scr > 5.0 {
Self::Strong
} else if scr > 3.0 {
Self::Medium
} else if scr > 2.0 {
Self::Weak
} else {
Self::VeryWeak
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridStrengthAssessment {
pub scr: f64,
pub weighted_scr: f64,
pub effective_scr: f64,
pub grid_strength: GridStrength,
pub issues: Vec<String>,
}
#[cfg(feature = "powerflow")]
pub fn assess_grid_strength(
network: &PowerNetwork,
bus: usize,
renewable_mw: f64,
) -> GridStrengthAssessment {
let mut issues = Vec::new();
let scr = match network.admittance_matrix() {
Ok(ybus) => {
match network.bus_index(bus) {
Ok(idx) => {
let y_ii_mag = if let Some(y_val) = ybus.get(idx, idx) {
(y_val.re * y_val.re + y_val.im * y_val.im).sqrt()
} else {
1e-6 };
let z_th_pu = if y_ii_mag > 1e-12 {
1.0 / y_ii_mag
} else {
1e6
};
let sc_mva = network.base_mva / z_th_pu;
if renewable_mw > 1e-6 {
sc_mva / renewable_mw
} else {
f64::INFINITY
}
}
Err(_) => {
issues.push(format!("Bus {bus} not found in network"));
0.0
}
}
}
Err(e) => {
issues.push(format!("Y-bus construction failed: {e}"));
0.0
}
};
let weighted_scr = scr;
let effective_scr = scr;
let grid_strength = GridStrength::from_scr(scr);
match grid_strength {
GridStrength::VeryWeak => {
issues.push(
"Very weak grid (SCR ≤ 2): dedicated stability study mandatory before connection."
.to_string(),
);
issues
.push("Consider synchronous condenser or STATCOM for voltage support.".to_string());
}
GridStrength::Weak => {
issues.push("Weak grid (SCR 2–3): reactive compensation likely required.".to_string());
}
GridStrength::Medium => {
issues.push(
"Medium grid strength (SCR 3–5): monitor voltage stability under contingency."
.to_string(),
);
}
GridStrength::Strong => {}
}
GridStrengthAssessment {
scr,
weighted_scr,
effective_scr,
grid_strength,
issues,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationResult {
pub penetration_pct: f64,
pub hosting_capacity_mw: f64,
pub voltage_violations: usize,
pub thermal_violations: usize,
pub stability_margin: f64,
pub short_circuit_ratio: f64,
pub harmonic_thd_pct: f64,
pub curtailment_pct: f64,
pub grid_code_compliant: bool,
}
#[cfg(feature = "powerflow")]
pub struct IntegrationStudy {
pub penetration_levels: Vec<f64>,
pub network: PowerNetwork,
pub renewable_buses: Vec<usize>,
}
#[cfg(feature = "powerflow")]
impl IntegrationStudy {
pub fn new(network: PowerNetwork, renewable_buses: Vec<usize>) -> Self {
Self {
penetration_levels: vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
network,
renewable_buses,
}
}
pub fn hosting_capacity_analysis(&self) -> Result<Vec<IntegrationResult>> {
let total_load_mw = self.network.total_load_mw();
if total_load_mw < 1e-6 {
return Err(OxiGridError::InvalidNetwork(
"Network has zero total load; hosting capacity analysis requires non-zero load."
.to_string(),
));
}
let mut results = Vec::with_capacity(self.penetration_levels.len());
for &pct in &self.penetration_levels {
let renewable_mw = pct * total_load_mw;
let voltage_violations = self.estimate_voltage_violations(renewable_mw);
let thermal_violations = self.estimate_thermal_violations(renewable_mw);
let stability_margin = (0.5 - 0.4 * pct).max(0.0);
let scr = if let Some(&primary_bus) = self.renewable_buses.first() {
let assessment = assess_grid_strength(&self.network, primary_bus, renewable_mw);
assessment.scr
} else {
0.0
};
let harmonic_thd_pct = 1.0 + 2.0 * pct;
let curtailment_pct = if voltage_violations > 0 || thermal_violations > 0 {
20.0 * pct } else {
0.0
};
let grid_code_compliant = voltage_violations == 0 && thermal_violations == 0;
let hosting_capacity_mw =
self.estimate_hosting_capacity_mw(total_load_mw, renewable_mw);
results.push(IntegrationResult {
penetration_pct: pct * 100.0,
hosting_capacity_mw,
voltage_violations,
thermal_violations,
stability_margin,
short_circuit_ratio: scr,
harmonic_thd_pct,
curtailment_pct,
grid_code_compliant,
});
}
Ok(results)
}
fn estimate_voltage_violations(&self, renewable_mw: f64) -> usize {
let n_buses = self.renewable_buses.len();
if n_buses == 0 {
return 0;
}
let mw_per_bus = renewable_mw / n_buses as f64;
let base_mva = self.network.base_mva;
self.renewable_buses
.iter()
.filter(|&&bus_id| {
if let Ok(idx) = self.network.bus_index(bus_id) {
let bus = &self.network.buses[idx];
let r_pu = self.estimate_bus_impedance(bus_id);
let v_nominal = bus.vm;
let p_pu = mw_per_bus / base_mva;
let dv = p_pu * r_pu / (v_nominal * v_nominal);
let v_estimated = v_nominal + dv;
!(0.95..=1.05).contains(&v_estimated)
} else {
false
}
})
.count()
}
fn estimate_bus_impedance(&self, bus_id: usize) -> f64 {
let connected: Vec<f64> = self
.network
.branches
.iter()
.filter(|br| br.from_bus == bus_id || br.to_bus == bus_id)
.map(|br| br.r)
.collect();
if connected.is_empty() {
return 0.05; }
connected.iter().sum::<f64>() / connected.len() as f64
}
fn estimate_thermal_violations(&self, renewable_mw: f64) -> usize {
let n_buses = self.renewable_buses.len();
if n_buses == 0 {
return 0;
}
let mw_per_bus = renewable_mw / n_buses as f64;
let base_mva = self.network.base_mva;
let p_pu_per_bus = mw_per_bus / base_mva;
self.renewable_buses
.iter()
.filter(|&&bus_id| {
self.network.branches.iter().any(|br| {
(br.from_bus == bus_id || br.to_bus == bus_id)
&& br.rate_a > 1e-6
&& p_pu_per_bus > br.rate_a / base_mva
})
})
.count()
}
fn estimate_hosting_capacity_mw(&self, total_load_mw: f64, current_mw: f64) -> f64 {
let mut lo = 0.0_f64;
let mut hi = 2.0 * total_load_mw;
for _ in 0..30 {
let mid = (lo + hi) * 0.5;
let v_viol = self.estimate_voltage_violations(mid);
let t_viol = self.estimate_thermal_violations(mid);
if v_viol > 0 || t_viol > 0 {
hi = mid;
} else {
lo = mid;
}
}
if hi >= 2.0 * total_load_mw - 1e-3 {
current_mw.max(total_load_mw * 0.5)
} else {
lo
}
}
pub fn compute_scr(&self, connection_bus: usize, renewable_mw: f64) -> Result<f64> {
if renewable_mw < 1e-9 {
return Err(OxiGridError::InvalidParameter(
"renewable_mw must be > 0 to compute SCR".to_string(),
));
}
let assessment = assess_grid_strength(&self.network, connection_bus, renewable_mw);
Ok(assessment.scr)
}
pub fn inertia_assessment(
&self,
renewable_penetration_pct: f64,
generator_inertia: &[(usize, f64)],
) -> InertiaResult {
let conventional_fraction = (1.0 - renewable_penetration_pct).clamp(0.0, 1.0);
let total_inertia_mws: f64 = generator_inertia
.iter()
.filter_map(|&(gen_id, h)| {
self.network
.generators
.iter()
.find(|g| g.bus_id == gen_id && g.status)
.map(|g| h * g.mbase * conventional_fraction)
})
.sum();
let largest_infeed_mw = self
.network
.generators
.iter()
.filter(|g| g.status)
.map(|g| g.pg)
.fold(0.0_f64, f64::max);
let f0 = 50.0; let rocof = if total_inertia_mws > 1e-6 {
largest_infeed_mw / (2.0 * total_inertia_mws * f0)
} else {
f64::INFINITY
};
let rocof_max = 1.0; let min_inertia_required = largest_infeed_mw / (2.0 * rocof_max * f0);
let inertia_deficit = (min_inertia_required - total_inertia_mws).max(0.0);
let synthetic_inertia_per_mw = 3.0;
let renewable_mw = renewable_penetration_pct * self.network.total_load_mw();
let synthetic_inertia_available = renewable_mw * synthetic_inertia_per_mw;
let synthetic_needed = if inertia_deficit > 0.0 {
inertia_deficit.min(synthetic_inertia_available)
} else {
0.0
};
InertiaResult {
system_inertia_mws: total_inertia_mws,
rocof_at_largest_infeed_hz_per_s: rocof,
min_inertia_required_mws: min_inertia_required,
inertia_deficit_mws: inertia_deficit,
synthetic_inertia_needed_mws: synthetic_needed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InertiaResult {
pub system_inertia_mws: f64,
pub rocof_at_largest_infeed_hz_per_s: f64,
pub min_inertia_required_mws: f64,
pub inertia_deficit_mws: f64,
pub synthetic_inertia_needed_mws: f64,
}
pub fn scr_from_sc_mva(sc_mva: f64, renewable_mw: f64) -> Result<f64> {
if renewable_mw < 1e-9 {
return Err(OxiGridError::InvalidParameter(
"renewable_mw must be positive to compute SCR".to_string(),
));
}
Ok(sc_mva / renewable_mw)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_grid_strength_strong() {
assert_eq!(GridStrength::from_scr(6.0), GridStrength::Strong);
}
#[test]
fn test_grid_strength_medium() {
assert_eq!(GridStrength::from_scr(4.0), GridStrength::Medium);
}
#[test]
fn test_grid_strength_weak() {
assert_eq!(GridStrength::from_scr(2.5), GridStrength::Weak);
}
#[test]
fn test_grid_strength_very_weak() {
assert_eq!(GridStrength::from_scr(1.5), GridStrength::VeryWeak);
}
#[test]
fn test_scr_from_sc_mva() {
let scr = scr_from_sc_mva(500.0, 100.0).expect("SCR computation failed");
assert!((scr - 5.0).abs() < 1e-9, "SCR should be 5.0, got {scr}");
}
#[test]
fn test_scr_zero_renewable_errors() {
let result = scr_from_sc_mva(500.0, 0.0);
assert!(result.is_err(), "Zero renewable_mw should return an error");
}
#[cfg(feature = "powerflow")]
fn make_test_network() -> PowerNetwork {
use crate::network::branch::Branch;
use crate::network::bus::{Bus, BusType};
use crate::network::topology::{Generator, PowerNetwork};
use crate::units::{Power, ReactivePower, Voltage};
let mut net = PowerNetwork::new(100.0);
let mut bus1 = Bus::new(1, BusType::Slack);
bus1.base_kv = Voltage(110.0);
bus1.vm = 1.0;
bus1.pd = Power(0.0);
bus1.qd = ReactivePower(0.0);
net.buses.push(bus1);
let mut bus2 = Bus::new(2, BusType::PQ);
bus2.base_kv = Voltage(110.0);
bus2.vm = 1.0;
bus2.pd = Power(80.0);
bus2.qd = ReactivePower(20.0);
net.buses.push(bus2);
let mut bus3 = Bus::new(3, BusType::PQ);
bus3.base_kv = Voltage(110.0);
bus3.vm = 1.0;
bus3.pd = Power(20.0);
bus3.qd = ReactivePower(5.0);
net.buses.push(bus3);
let br12 = Branch {
from_bus: 1,
to_bus: 2,
r: 0.01,
x: 0.05,
b: 0.02,
rate_a: 150.0,
rate_b: 0.0,
rate_c: 0.0,
tap: 0.0,
shift: 0.0,
status: true,
};
net.branches.push(br12);
let br23 = Branch {
from_bus: 2,
to_bus: 3,
r: 0.02,
x: 0.08,
b: 0.01,
rate_a: 80.0,
rate_b: 0.0,
rate_c: 0.0,
tap: 0.0,
shift: 0.0,
status: true,
};
net.branches.push(br23);
let gen = Generator {
bus_id: 1,
pg: 100.0,
qg: 25.0,
qmax: 60.0,
qmin: -20.0,
vg: 1.0,
mbase: 100.0,
status: true,
pmax: 200.0,
pmin: 0.0,
};
net.generators.push(gen);
net
}
#[cfg(feature = "powerflow")]
#[test]
fn test_scr_computation() {
let net = make_test_network();
let study = IntegrationStudy::new(net, vec![3]);
let scr = study.compute_scr(3, 50.0).expect("SCR should succeed");
assert!(scr > 0.0, "SCR must be positive, got {scr}");
}
#[cfg(feature = "powerflow")]
#[test]
fn test_hosting_capacity_positive() {
let net = make_test_network();
let study = IntegrationStudy::new(net, vec![3]);
let results = study
.hosting_capacity_analysis()
.expect("Analysis should succeed");
assert!(!results.is_empty(), "Should return at least one result");
for r in &results {
assert!(
r.hosting_capacity_mw >= 0.0,
"Hosting capacity must be non-negative: got {}",
r.hosting_capacity_mw
);
assert!(r.penetration_pct >= 0.0 && r.penetration_pct <= 100.0);
}
}
#[cfg(feature = "powerflow")]
#[test]
fn test_grid_strength_strong_with_network() {
let net = make_test_network();
let assessment = assess_grid_strength(&net, 1, 10.0);
assert!(
assessment.scr > 0.0,
"SCR should be positive for a valid network"
);
}
#[cfg(feature = "powerflow")]
#[test]
fn test_inertia_assessment_decreases_with_penetration() {
let net = make_test_network();
let study = IntegrationStudy::new(net, vec![3]);
let inertia_low = study.inertia_assessment(0.2, &[(1, 5.0)]);
let inertia_high = study.inertia_assessment(0.8, &[(1, 5.0)]);
assert!(
inertia_low.system_inertia_mws >= inertia_high.system_inertia_mws,
"Higher RE penetration should reduce system inertia"
);
}
#[cfg(feature = "powerflow")]
#[test]
fn test_inertia_deficit_appears_at_high_penetration() {
let net = make_test_network();
let study = IntegrationStudy::new(net, vec![3]);
let result = study.inertia_assessment(0.9, &[(1, 0.1)]);
assert!(
result.inertia_deficit_mws >= 0.0,
"Inertia deficit must be non-negative"
);
}
}