use thiserror::Error;
#[derive(Debug, Error)]
pub enum StudyError {
#[error("invalid scenario: {0}")]
InvalidScenario(String),
#[error("computation error: {0}")]
ComputationError(String),
#[error("base network is empty — add buses before running a study")]
EmptyNetwork,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StudyMetric {
HostingCapacity,
Curtailment,
FrequencyResponse,
VoltageStability,
ProtectionImpact,
MarketImpact,
TransmissionCongestion,
}
#[derive(Debug, Clone)]
pub struct IntegrationStudyConfig {
pub study_name: String,
pub target_renewable_pct: f64,
pub study_years: Vec<usize>,
pub metrics: Vec<StudyMetric>,
}
#[derive(Debug, Clone)]
pub struct RenewableScenario {
pub year: usize,
pub solar_mw: Vec<(usize, f64)>,
pub wind_mw: Vec<(usize, f64)>,
pub battery_mwh: Vec<(usize, f64)>,
pub retired_conventional_mw: f64,
pub load_growth_pct: f64,
}
#[derive(Debug, Clone)]
pub struct StudyResult {
pub scenario: RenewableScenario,
pub hosting_capacity_mw: f64,
pub annual_curtailment_gwh: f64,
pub curtailment_pct: f64,
pub system_inertia_mjs: f64,
pub min_scr: f64,
pub frequency_nadir_hz: f64,
pub rocof_hz_per_s: f64,
pub voltage_violations: usize,
pub thermal_violations: usize,
pub congestion_cost_m_usd: f64,
pub integration_cost_m_usd: f64,
pub merit_score: f64,
}
pub struct IntegrationStudyPlatform {
#[allow(dead_code)]
config: IntegrationStudyConfig,
base_network_buses: usize,
base_network_load_mw: Vec<f64>,
conventional_generators: Vec<(f64, f64, f64)>,
}
impl IntegrationStudyPlatform {
pub fn new(config: IntegrationStudyConfig, n_buses: usize, base_load_mw: Vec<f64>) -> Self {
Self {
config,
base_network_buses: n_buses,
base_network_load_mw: base_load_mw,
conventional_generators: Vec::new(),
}
}
pub fn add_conventional_generator(&mut self, bus_pct: f64, p_mw: f64, h_s: f64) {
self.conventional_generators.push((bus_pct, p_mw, h_s));
}
pub fn study_scenario(&self, scenario: &RenewableScenario) -> Result<StudyResult, StudyError> {
if self.base_network_buses == 0 {
return Err(StudyError::EmptyNetwork);
}
let total_base_load: f64 = self.base_network_load_mw.iter().sum();
let load_growth_factor = 1.0 + scenario.load_growth_pct / 100.0;
let total_load = total_base_load * load_growth_factor;
let total_solar: f64 = scenario.solar_mw.iter().map(|(_, mw)| mw).sum();
let total_wind: f64 = scenario.wind_mw.iter().map(|(_, mw)| mw).sum();
let total_renewable_mw = total_solar + total_wind;
let hosting_capacity_mw = total_renewable_mw.min(total_load * 1.2);
let renewable_fraction = total_renewable_mw / total_load.max(1.0);
let curtailment_pct = if renewable_fraction > 0.8 {
((renewable_fraction - 0.8) * 50.0).min(30.0)
} else {
0.0
};
let annual_curtailment_gwh =
hosting_capacity_mw * (curtailment_pct / 100.0) * 8760.0 / 1000.0;
let total_conventional_mw: f64 =
self.conventional_generators.iter().map(|(_, p, _)| p).sum();
let retired_fraction = if total_conventional_mw > 1e-9 {
(scenario.retired_conventional_mw / total_conventional_mw).min(1.0)
} else {
0.0
};
let system_inertia_mjs: f64 = self
.conventional_generators
.iter()
.map(|(_, p, h)| p * h * (1.0 - retired_fraction))
.sum();
let remaining_conventional = total_conventional_mw * (1.0 - retired_fraction);
let min_scr = if total_renewable_mw > 1e-9 {
(remaining_conventional / total_renewable_mw).clamp(0.0, 10.0)
} else {
10.0 };
let total_mw = total_load.max(1.0);
let h_pu = system_inertia_mjs / total_mw; let h_pu = h_pu.max(0.1); let delta_p_mw = remaining_conventional * 0.10;
let rocof_hz_per_s = delta_p_mw / (2.0 * h_pu * total_mw / 100.0).max(1.0);
let frequency_nadir_hz = 60.0 - rocof_hz_per_s * 0.5;
let voltage_violations: usize = if renewable_fraction > 0.6 {
((renewable_fraction - 0.6) * 1000.0) as usize
} else {
0
};
let thermal_violations: usize = if renewable_fraction > 0.7 {
((renewable_fraction - 0.7) * 500.0) as usize
} else {
0
};
let congestion_cost_m_usd = thermal_violations as f64 * 0.01;
let integration_cost_m_usd = total_renewable_mw * 0.05;
let mut score = 100.0_f64;
score -= curtailment_pct * 1.5;
if system_inertia_mjs < 3000.0 {
score -= (3000.0 - system_inertia_mjs) * 0.005;
}
if min_scr < 3.0 {
score -= (3.0 - min_scr) * 5.0;
}
if voltage_violations > 100 {
score -= (voltage_violations as f64 - 100.0) * 0.01;
}
let merit_score = score.clamp(0.0, 100.0);
Ok(StudyResult {
scenario: scenario.clone(),
hosting_capacity_mw,
annual_curtailment_gwh,
curtailment_pct,
system_inertia_mjs,
min_scr,
frequency_nadir_hz,
rocof_hz_per_s,
voltage_violations,
thermal_violations,
congestion_cost_m_usd,
integration_cost_m_usd,
merit_score,
})
}
pub fn run_all_scenarios(
&self,
scenarios: Vec<RenewableScenario>,
) -> Result<Vec<StudyResult>, StudyError> {
scenarios.iter().map(|s| self.study_scenario(s)).collect()
}
pub fn recommend_upgrades(&self, results: &[StudyResult]) -> Vec<String> {
let mut recs: Vec<String> = Vec::new();
if results.iter().any(|r| r.curtailment_pct > 10.0) {
recs.push("Install battery storage to absorb excess renewable generation".to_string());
}
if results.iter().any(|r| r.system_inertia_mjs < 2000.0) {
recs.push(
"Add synchronous condensers or virtual inertia for frequency support".to_string(),
);
}
if results.iter().any(|r| r.min_scr < 2.0) {
recs.push(
"Strengthen transmission network near renewable interconnection points".to_string(),
);
}
if results.iter().any(|r| r.voltage_violations > 500) {
recs.push("Deploy dynamic VAR compensation (SVCs or STATCOMs)".to_string());
}
if results.iter().any(|r| r.thermal_violations > 200) {
recs.push("Upgrade transmission lines to handle increased renewable flows".to_string());
}
if results.iter().any(|r| r.congestion_cost_m_usd > 1.0) {
recs.push("Build new transmission capacity to reduce congestion".to_string());
}
recs
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_platform() -> IntegrationStudyPlatform {
let cfg = IntegrationStudyConfig {
study_name: "Test Study".to_string(),
target_renewable_pct: 50.0,
study_years: vec![2025, 2030],
metrics: vec![StudyMetric::HostingCapacity, StudyMetric::Curtailment],
};
let load = vec![100.0_f64; 5]; let mut platform = IntegrationStudyPlatform::new(cfg, 5, load);
platform.add_conventional_generator(0.2, 200.0, 5.0);
platform.add_conventional_generator(0.8, 200.0, 5.0);
platform
}
fn low_pen_scenario() -> RenewableScenario {
RenewableScenario {
year: 2025,
solar_mw: vec![(0, 50.0)],
wind_mw: vec![],
battery_mwh: vec![],
retired_conventional_mw: 0.0,
load_growth_pct: 0.0,
}
}
fn high_pen_scenario() -> RenewableScenario {
RenewableScenario {
year: 2030,
solar_mw: vec![(0, 300.0)],
wind_mw: vec![(1, 350.0)],
battery_mwh: vec![],
retired_conventional_mw: 150.0,
load_growth_pct: 5.0,
}
}
#[test]
fn test_low_penetration_high_inertia() {
let p = base_platform();
let r = p
.study_scenario(&low_pen_scenario())
.expect("study_scenario");
assert!(
r.system_inertia_mjs > 0.0,
"inertia should be positive, got {}",
r.system_inertia_mjs
);
assert_eq!(
r.curtailment_pct, 0.0,
"low penetration should have zero curtailment"
);
}
#[test]
fn test_high_penetration_curtailment_rises() {
let p = base_platform();
let r = p
.study_scenario(&high_pen_scenario())
.expect("study_scenario");
assert!(
r.curtailment_pct > 0.0,
"high penetration should have positive curtailment, got {}",
r.curtailment_pct
);
}
#[test]
fn test_frequency_nadir_below_nominal() {
let p = base_platform();
let r = p
.study_scenario(&high_pen_scenario())
.expect("study_scenario");
assert!(
r.frequency_nadir_hz < 60.0,
"nadir must be below 60 Hz, got {}",
r.frequency_nadir_hz
);
}
#[test]
fn test_merit_score_decreases_high_penetration() {
let p = base_platform();
let low = p.study_scenario(&low_pen_scenario()).expect("low");
let high = p.study_scenario(&high_pen_scenario()).expect("high");
assert!(
high.merit_score <= low.merit_score,
"high penetration merit ({}) should be ≤ low penetration merit ({})",
high.merit_score,
low.merit_score
);
}
#[test]
fn test_recommendations_generated_for_high_penetration() {
let p = base_platform();
let results = vec![p.study_scenario(&high_pen_scenario()).expect("high")];
let recs = p.recommend_upgrades(&results);
assert!(
!recs.is_empty(),
"high-penetration scenario should generate at least one recommendation"
);
}
}