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"
);
}
#[test]
fn test_empty_network_returns_error() {
let cfg = IntegrationStudyConfig {
study_name: "Empty Network Test".to_string(),
target_renewable_pct: 50.0,
study_years: vec![2025],
metrics: vec![StudyMetric::HostingCapacity],
};
let platform = IntegrationStudyPlatform::new(cfg, 0, vec![]);
let result = platform.study_scenario(&low_pen_scenario());
match result {
Err(StudyError::EmptyNetwork) => {}
other => panic!("expected Err(StudyError::EmptyNetwork), got {:?}", other),
}
}
#[test]
fn test_zero_renewable_gives_clamped_scr_and_no_curtailment() {
let p = base_platform();
let scenario = RenewableScenario {
year: 2025,
solar_mw: vec![],
wind_mw: vec![],
battery_mwh: vec![],
retired_conventional_mw: 0.0,
load_growth_pct: 0.0,
};
let r = p
.study_scenario(&scenario)
.expect("study_scenario for zero-renewable case");
assert_eq!(
r.curtailment_pct, 0.0,
"zero renewable must have zero curtailment, got {}",
r.curtailment_pct
);
assert!(
(r.min_scr - 10.0).abs() < 1e-9,
"zero renewable SCR must be clamped to 10.0, got {}",
r.min_scr
);
assert!(
r.frequency_nadir_hz > 59.0,
"frequency nadir with zero renewables should be close to 60 Hz, got {}",
r.frequency_nadir_hz
);
}
#[test]
fn test_full_conventional_retirement_zeroes_inertia() {
let p = base_platform();
let scenario = RenewableScenario {
year: 2030,
solar_mw: vec![(0, 100.0)],
wind_mw: vec![],
battery_mwh: vec![],
retired_conventional_mw: 400.0,
load_growth_pct: 0.0,
};
let r = p
.study_scenario(&scenario)
.expect("study_scenario for full retirement");
assert!(
r.system_inertia_mjs.abs() < 1e-9,
"full conventional retirement must reduce inertia to zero, got {}",
r.system_inertia_mjs
);
}
#[test]
fn test_run_all_scenarios_result_count_matches_input() {
let p = base_platform();
let scenarios = vec![low_pen_scenario(), high_pen_scenario()];
let results = p
.run_all_scenarios(scenarios)
.expect("run_all_scenarios should succeed");
assert_eq!(
results.len(),
2,
"expected 2 results for 2 scenarios, got {}",
results.len()
);
}
#[test]
fn test_recommend_upgrades_empty_for_healthy_scenario() {
let p = base_platform();
let r = p
.study_scenario(&low_pen_scenario())
.expect("low pen study");
let recs = p.recommend_upgrades(&[r]);
assert!(
recs.is_empty(),
"no recommendations expected for a healthy low-penetration result, got {:?}",
recs
);
}
#[test]
fn test_hosting_capacity_capped_at_120_pct_of_load() {
let p = base_platform(); let scenario = RenewableScenario {
year: 2025,
solar_mw: vec![(0, 1000.0)], wind_mw: vec![(1, 500.0)], battery_mwh: vec![],
retired_conventional_mw: 0.0,
load_growth_pct: 0.0,
};
let r = p
.study_scenario(&scenario)
.expect("study_scenario for oversized renewables");
let expected_cap = 500.0_f64 * 1.2; assert!(
(r.hosting_capacity_mw - expected_cap).abs() < 1e-9,
"hosting capacity should be capped at {:.1} MW, got {:.6}",
expected_cap,
r.hosting_capacity_mw
);
}
#[test]
fn test_high_load_growth_reduces_curtailment() {
let p = base_platform();
let scenario_no_growth = RenewableScenario {
year: 2025,
solar_mw: vec![(0, 300.0)],
wind_mw: vec![(1, 200.0)],
battery_mwh: vec![],
retired_conventional_mw: 0.0,
load_growth_pct: 0.0,
};
let scenario_high_growth = RenewableScenario {
year: 2025,
solar_mw: vec![(0, 300.0)],
wind_mw: vec![(1, 200.0)],
battery_mwh: vec![],
retired_conventional_mw: 0.0,
load_growth_pct: 200.0,
};
let r_no_growth = p
.study_scenario(&scenario_no_growth)
.expect("study_scenario no-growth");
let r_high_growth = p
.study_scenario(&scenario_high_growth)
.expect("study_scenario high-growth");
assert!(
r_no_growth.curtailment_pct > 0.0,
"zero load growth with 500 MW renewables / 500 MW load should curtail, got {}",
r_no_growth.curtailment_pct
);
assert_eq!(
r_high_growth.curtailment_pct, 0.0,
"200 %% load growth should reduce renewable fraction enough to eliminate curtailment, got {}",
r_high_growth.curtailment_pct
);
}
}