use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimeResolution {
FifteenMinutes,
HalfHourly,
Hourly,
Daily,
}
impl TimeResolution {
pub fn steps_per_day(&self) -> usize {
match self {
Self::FifteenMinutes => 96,
Self::HalfHourly => 48,
Self::Hourly => 24,
Self::Daily => 1,
}
}
pub fn dt_hours(&self) -> f64 {
match self {
Self::FifteenMinutes => 0.25,
Self::HalfHourly => 0.5,
Self::Hourly => 1.0,
Self::Daily => 24.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BusTimeSeriesType {
Load,
SolarGeneration {
installed_mw: f64,
},
WindGeneration {
installed_mw: f64,
},
Storage {
charge_negative: bool,
},
HydroGeneration,
FixedInjection,
}
impl BusTimeSeriesType {
pub fn is_renewable(&self) -> bool {
matches!(
self,
Self::SolarGeneration { .. } | Self::WindGeneration { .. }
)
}
pub fn is_storage(&self) -> bool {
matches!(self, Self::Storage { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusTimeSeries {
pub bus_id: usize,
pub p_mw: Vec<f64>,
pub q_mvar: Vec<f64>,
pub series_type: BusTimeSeriesType,
}
impl BusTimeSeries {
pub fn len(&self) -> usize {
self.p_mw.len()
}
pub fn is_empty(&self) -> bool {
self.p_mw.is_empty()
}
pub fn p_at(&self, t: usize) -> f64 {
self.p_mw.get(t).copied().unwrap_or(0.0)
}
pub fn q_at(&self, t: usize) -> f64 {
self.q_mvar.get(t).copied().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratorProfile {
pub generator_id: usize,
pub bus: usize,
pub p_dispatch_mw: Vec<f64>,
pub q_dispatch_mvar: Vec<f64>,
pub p_max_mw: f64,
pub p_min_mw: f64,
pub cost_per_mwh: f64,
}
impl GeneratorProfile {
pub fn p_at(&self, t: usize) -> f64 {
self.p_dispatch_mw
.get(t)
.copied()
.unwrap_or(0.0)
.clamp(self.p_min_mw, self.p_max_mw)
}
pub fn q_at(&self, t: usize) -> f64 {
self.q_dispatch_mvar.get(t).copied().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesNetwork {
pub n_buses: usize,
pub g_matrix: Vec<Vec<f64>>,
pub b_matrix: Vec<Vec<f64>>,
pub bus_series: Vec<BusTimeSeries>,
pub generators: Vec<GeneratorProfile>,
pub branch_ratings_mva: Vec<f64>,
pub branches: Vec<(usize, usize)>,
pub slack_bus: usize,
pub base_mva: f64,
}
impl TimeSeriesNetwork {
pub fn validate(&self) -> Result<()> {
if self.n_buses == 0 {
return Err(OxiGridError::InvalidNetwork(
"network must have at least one bus".into(),
));
}
if self.g_matrix.len() != self.n_buses || self.b_matrix.len() != self.n_buses {
return Err(OxiGridError::InvalidNetwork(
"Y-bus matrix dimensions must equal n_buses".into(),
));
}
if self.slack_bus >= self.n_buses {
return Err(OxiGridError::InvalidNetwork(
"slack_bus index out of range".into(),
));
}
if self.branch_ratings_mva.len() != self.branches.len() {
return Err(OxiGridError::InvalidNetwork(
"branch_ratings_mva length must equal branches length".into(),
));
}
Ok(())
}
#[allow(clippy::needless_range_loop)]
fn build_b_prime(&self) -> Vec<Vec<f64>> {
let n = self.n_buses;
let mut bp = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..n {
if i == j {
continue;
}
let bij = self.b_matrix[i][j];
if bij.abs() > 1e-12 {
let inv_x = -bij;
bp[i][j] -= inv_x;
bp[i][i] += inv_x;
}
}
}
bp
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeStepResult {
pub timestep: usize,
pub time_hours: f64,
pub converged: bool,
pub voltage_magnitude: Vec<f64>,
pub voltage_angle: Vec<f64>,
pub branch_loading_pct: Vec<f64>,
pub total_generation_mw: f64,
pub total_load_mw: f64,
pub total_losses_mw: f64,
pub renewable_generation_mw: f64,
pub renewable_curtailment_mw: f64,
pub storage_soc: Vec<f64>,
pub overloaded_branches: Vec<usize>,
pub voltage_violations: Vec<(usize, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesStatistics {
pub n_timesteps: usize,
pub n_converged: usize,
pub convergence_rate: f64,
pub max_voltage_pu: f64,
pub min_voltage_pu: f64,
pub avg_voltage_pu: f64,
pub peak_load_mw: f64,
pub avg_load_mw: f64,
pub load_factor: f64,
pub total_energy_twh: f64,
pub renewable_fraction_pct: f64,
pub total_curtailment_mwh: f64,
pub total_losses_mwh: f64,
pub max_branch_loading_pct: f64,
pub avg_branch_loading_pct: f64,
pub n_overload_hours: usize,
pub n_voltage_violation_hours: usize,
pub hosting_capacity_estimate_mw: f64,
}
impl Default for TimeSeriesStatistics {
fn default() -> Self {
Self {
n_timesteps: 0,
n_converged: 0,
convergence_rate: 0.0,
max_voltage_pu: 1.0,
min_voltage_pu: 1.0,
avg_voltage_pu: 1.0,
peak_load_mw: 0.0,
avg_load_mw: 0.0,
load_factor: 0.0,
total_energy_twh: 0.0,
renewable_fraction_pct: 0.0,
total_curtailment_mwh: 0.0,
total_losses_mwh: 0.0,
max_branch_loading_pct: 0.0,
avg_branch_loading_pct: 0.0,
n_overload_hours: 0,
n_voltage_violation_hours: 0,
hosting_capacity_estimate_mw: 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesResult {
pub timestep_results: Vec<TimeStepResult>,
pub statistics: TimeSeriesStatistics,
pub duration_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StorageStrategy {
PeakShaving {
threshold_mw: f64,
},
PriceArbitrage {
price_profile: Vec<f64>,
},
VoltageSupport {
target_pu: f64,
},
ScheduledDispatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesConfig {
pub n_timesteps: usize,
pub resolution: TimeResolution,
pub voltage_lower_pu: f64,
pub voltage_upper_pu: f64,
pub max_pf_iterations: usize,
pub pf_tolerance: f64,
pub enable_curtailment: bool,
pub storage_dispatch_strategy: StorageStrategy,
}
impl Default for TimeSeriesConfig {
fn default() -> Self {
Self {
n_timesteps: 8760,
resolution: TimeResolution::Hourly,
voltage_lower_pu: 0.95,
voltage_upper_pu: 1.05,
max_pf_iterations: 20,
pf_tolerance: 1e-4,
enable_curtailment: true,
storage_dispatch_strategy: StorageStrategy::ScheduledDispatch,
}
}
}
#[derive(Debug, Clone)]
struct StorageUnit {
series_idx: usize,
bus_id: usize,
soc: f64,
capacity_mwh: f64,
power_mw: f64,
}
impl StorageUnit {
const ETA_CHARGE: f64 = 0.95;
const ETA_DISCHARGE: f64 = 0.95;
fn apply_power(&mut self, p_mw: f64, dt_hours: f64) -> f64 {
let p_clamped = p_mw.clamp(-self.power_mw, self.power_mw);
if p_clamped >= 0.0 {
let energy_out = p_clamped * dt_hours / Self::ETA_DISCHARGE;
let max_out = self.soc * self.capacity_mwh;
let actual_energy = energy_out.min(max_out);
self.soc -= actual_energy / self.capacity_mwh;
self.soc = self.soc.clamp(0.0, 1.0);
actual_energy * Self::ETA_DISCHARGE / dt_hours
} else {
let energy_in = (-p_clamped) * dt_hours * Self::ETA_CHARGE;
let max_in = (1.0 - self.soc) * self.capacity_mwh;
let actual_energy = energy_in.min(max_in);
self.soc += actual_energy / self.capacity_mwh;
self.soc = self.soc.clamp(0.0, 1.0);
-(actual_energy / Self::ETA_CHARGE / dt_hours)
}
}
}
pub struct TimeSeriesSimulator {
pub network: TimeSeriesNetwork,
pub config: TimeSeriesConfig,
pub storage_soc: Vec<f64>,
storage_units: Vec<StorageUnit>,
}
impl TimeSeriesSimulator {
pub fn new(network: TimeSeriesNetwork, config: TimeSeriesConfig) -> Self {
let mut storage_units = Vec::new();
for (idx, bts) in network.bus_series.iter().enumerate() {
if bts.series_type.is_storage() {
let power_mw = bts
.p_mw
.iter()
.map(|&p| p.abs())
.fold(f64::NAN, f64::max)
.max(1.0);
let capacity_mwh = power_mw * 4.0; storage_units.push(StorageUnit {
series_idx: idx,
bus_id: bts.bus_id,
soc: 0.5,
capacity_mwh,
power_mw,
});
}
}
let soc_vec: Vec<f64> = storage_units.iter().map(|s| s.soc).collect();
Self {
network,
config,
storage_soc: soc_vec,
storage_units,
}
}
pub fn run(&mut self) -> Result<TimeSeriesResult> {
self.network.validate()?;
if self.config.n_timesteps == 0 {
return Err(OxiGridError::InvalidParameter(
"n_timesteps must be > 0".into(),
));
}
let median_price = self.compute_median_price();
let dt = self.config.resolution.dt_hours();
let n_t = self.config.n_timesteps;
let mut results: Vec<TimeStepResult> = Vec::with_capacity(n_t);
for t in 0..n_t {
let time_hours = t as f64 * dt;
let (mut p_inj, q_inj) = self.get_bus_injections(t);
let storage_p = self.dispatch_storage(t, &p_inj, median_price);
for (sidx, su) in self.storage_units.iter().enumerate() {
if su.bus_id < p_inj.len() {
p_inj[su.bus_id] += storage_p[sidx];
}
}
let (angles, converged) = match self.solve_dc_powerflow(&p_inj) {
Ok(a) => (a, true),
Err(_) => (vec![0.0; self.network.n_buses], false),
};
let mut voltages = self.estimate_voltages(&p_inj, &q_inj);
let branch_flows = self.compute_branch_flows(&angles);
let branch_loading = self.compute_branch_loading(&branch_flows);
let curtailment_mw = if self.config.enable_curtailment
&& voltages.iter().any(|&v| v > self.config.voltage_upper_pu)
{
let c = self.apply_curtailment(&mut p_inj, &voltages);
voltages = self.estimate_voltages(&p_inj, &q_inj);
c
} else {
0.0
};
let (total_gen, total_load, ren_gen) = self.compute_generation_load(t, &storage_p);
let renewable_gen_after = (ren_gen - curtailment_mw).max(0.0);
let overloaded: Vec<usize> = branch_loading
.iter()
.enumerate()
.filter(|(_, &l)| l > 100.0)
.map(|(i, _)| i)
.collect();
let v_violations: Vec<(usize, f64)> = voltages
.iter()
.enumerate()
.filter(|(_, &v)| {
v < self.config.voltage_lower_pu || v > self.config.voltage_upper_pu
})
.map(|(i, &v)| (i, v))
.collect();
for (sidx, su) in self.storage_units.iter().enumerate() {
if sidx < self.storage_soc.len() {
self.storage_soc[sidx] = su.soc;
}
}
let soc_snap: Vec<f64> = self.storage_soc.clone();
results.push(TimeStepResult {
timestep: t,
time_hours,
converged,
voltage_magnitude: voltages,
voltage_angle: angles,
branch_loading_pct: branch_loading,
total_generation_mw: total_gen,
total_load_mw: total_load,
total_losses_mw: 0.0, renewable_generation_mw: renewable_gen_after,
renewable_curtailment_mw: curtailment_mw,
storage_soc: soc_snap,
overloaded_branches: overloaded,
voltage_violations: v_violations,
});
}
let statistics = Self::compute_statistics(&results, dt);
Ok(TimeSeriesResult {
timestep_results: results,
statistics,
duration_s: 0.0, })
}
pub fn estimate_hosting_capacity(
&mut self,
test_bus: usize,
max_search_mw: f64,
) -> Result<f64> {
if test_bus >= self.network.n_buses {
return Err(OxiGridError::InvalidParameter(format!(
"test_bus {test_bus} out of range (n_buses={})",
self.network.n_buses
)));
}
if max_search_mw <= 0.0 {
return Err(OxiGridError::InvalidParameter(
"max_search_mw must be positive".into(),
));
}
let dt = self.config.resolution.dt_hours();
let n_t = self.config.n_timesteps;
let violation_limit = (n_t as f64 * 0.05).ceil() as usize;
let mut lo = 0.0_f64;
let mut hi = max_search_mw;
let mut best = 0.0_f64;
for _ in 0..20 {
let mid = (lo + hi) / 2.0;
let violations = self.count_violations_with_injection(test_bus, mid, dt, n_t)?;
if violations <= violation_limit {
best = mid;
lo = mid;
} else {
hi = mid;
}
}
Ok(best)
}
fn get_bus_injections(&self, t: usize) -> (Vec<f64>, Vec<f64>) {
let n = self.network.n_buses;
let mut p = vec![0.0_f64; n];
let mut q = vec![0.0_f64; n];
for bts in &self.network.bus_series {
let bus = bts.bus_id;
if bus >= n {
continue;
}
let p_val = bts.p_at(t);
let q_val = bts.q_at(t);
match &bts.series_type {
BusTimeSeriesType::Load => {
p[bus] -= p_val; q[bus] -= q_val;
}
BusTimeSeriesType::Storage { .. } => {
}
_ => {
p[bus] += p_val;
q[bus] += q_val;
}
}
}
for gen in &self.network.generators {
let bus = gen.bus;
if bus >= n {
continue;
}
p[bus] += gen.p_at(t);
q[bus] += gen.q_at(t);
}
(p, q)
}
fn solve_dc_powerflow(&self, p_injections: &[f64]) -> Result<Vec<f64>> {
let n = self.network.n_buses;
let slack = self.network.slack_bus;
let base = self.network.base_mva;
let bp = self.network.build_b_prime();
let non_slack: Vec<usize> = (0..n).filter(|&i| i != slack).collect();
let m = non_slack.len();
if m == 0 {
return Ok(vec![0.0; n]);
}
let mut a = vec![vec![0.0_f64; m]; m];
for (ri, &i) in non_slack.iter().enumerate() {
for (rj, &j) in non_slack.iter().enumerate() {
a[ri][rj] = bp[i][j];
}
}
let mut rhs: Vec<f64> = non_slack
.iter()
.map(|&i| p_injections.get(i).copied().unwrap_or(0.0) / base)
.collect();
Self::gaussian_solve(&mut a, &mut rhs)?;
let mut angles = vec![0.0_f64; n];
for (ri, &i) in non_slack.iter().enumerate() {
angles[i] = rhs[ri];
}
Ok(angles)
}
#[allow(clippy::ptr_arg, clippy::needless_range_loop)]
fn gaussian_solve(a: &mut Vec<Vec<f64>>, b: &mut Vec<f64>) -> Result<()> {
let m = b.len();
for col in 0..m {
let mut max_val = a[col][col].abs();
let mut max_row = col;
for row in (col + 1)..m {
if a[row][col].abs() > max_val {
max_val = a[row][col].abs();
max_row = row;
}
}
if max_val < 1e-14 {
return Err(OxiGridError::LinearAlgebra(
"B' matrix is singular — network may be islanded".into(),
));
}
a.swap(col, max_row);
b.swap(col, max_row);
let pivot = a[col][col];
for row in (col + 1)..m {
let factor = a[row][col] / pivot;
for c in col..m {
let val = a[col][c];
a[row][c] -= factor * val;
}
b[row] -= factor * b[col];
}
}
for row in (0..m).rev() {
let mut sum = b[row];
for c in (row + 1)..m {
sum -= a[row][c] * b[c];
}
b[row] = sum / a[row][row];
}
Ok(())
}
fn compute_branch_flows(&self, angles: &[f64]) -> Vec<f64> {
self.network
.branches
.iter()
.map(|&(from, to)| {
let theta_i = angles.get(from).copied().unwrap_or(0.0);
let theta_j = angles.get(to).copied().unwrap_or(0.0);
let bij = self
.network
.b_matrix
.get(from)
.and_then(|row| row.get(to))
.copied()
.unwrap_or(0.0);
let inv_x = -bij; inv_x * (theta_i - theta_j) * self.network.base_mva
})
.collect()
}
fn compute_branch_loading(&self, branch_flows: &[f64]) -> Vec<f64> {
branch_flows
.iter()
.enumerate()
.map(|(i, &flow)| {
let rating = self
.network
.branch_ratings_mva
.get(i)
.copied()
.unwrap_or(f64::INFINITY);
if rating > 0.0 {
flow.abs() / rating * 100.0
} else {
0.0
}
})
.collect()
}
fn estimate_voltages(&self, _p_injections: &[f64], q_injections: &[f64]) -> Vec<f64> {
let n = self.network.n_buses;
let base = self.network.base_mva;
(0..n)
.map(|i| {
let q_pu = q_injections.get(i).copied().unwrap_or(0.0) / base;
let bii = self
.network
.b_matrix
.get(i)
.and_then(|row| row.get(i))
.copied()
.unwrap_or(-1.0);
let dv = if bii.abs() > 1e-6 { -q_pu / bii } else { 0.0 };
(1.0 + dv).clamp(0.5, 1.5)
})
.collect()
}
fn dispatch_storage(&mut self, t: usize, p_inj: &[f64], median_price: f64) -> Vec<f64> {
match &self.config.storage_dispatch_strategy.clone() {
StorageStrategy::PeakShaving { threshold_mw } => {
let total_load = p_inj.iter().filter(|&&v| v < 0.0).map(|&v| -v).sum::<f64>();
self.dispatch_storage_peak_shaving(total_load, *threshold_mw, t)
}
StorageStrategy::PriceArbitrage { price_profile } => {
let price = price_profile.get(t).copied().unwrap_or(0.0);
self.dispatch_storage_price_arbitrage(price, median_price, t)
}
StorageStrategy::VoltageSupport { .. } | StorageStrategy::ScheduledDispatch => {
self.dispatch_storage_scheduled(t)
}
}
}
fn dispatch_storage_peak_shaving(
&mut self,
total_load: f64,
threshold: f64,
t: usize,
) -> Vec<f64> {
let dt = self.config.resolution.dt_hours();
let n_storage = self.storage_units.len();
let mut out = vec![0.0_f64; n_storage];
if total_load > threshold {
let deficit = (total_load - threshold) / (n_storage.max(1) as f64);
for (sidx, su) in self.storage_units.iter_mut().enumerate() {
let actual = su.apply_power(deficit, dt);
out[sidx] = actual;
if sidx < self.storage_soc.len() {
self.storage_soc[sidx] = su.soc;
}
}
} else {
let surplus = (threshold - total_load) / (n_storage.max(1) as f64);
let charge = -surplus;
for (sidx, su) in self.storage_units.iter_mut().enumerate() {
let actual = su.apply_power(charge, dt);
out[sidx] = actual;
if sidx < self.storage_soc.len() {
self.storage_soc[sidx] = su.soc;
}
}
}
let _ = t;
out
}
fn dispatch_storage_price_arbitrage(
&mut self,
price: f64,
median_price: f64,
_t: usize,
) -> Vec<f64> {
let dt = self.config.resolution.dt_hours();
let n_storage = self.storage_units.len();
let mut out = vec![0.0_f64; n_storage];
let p_cmd = if price > median_price {
1.0 } else {
-1.0
};
for (sidx, su) in self.storage_units.iter_mut().enumerate() {
let cmd_mw = p_cmd * su.power_mw;
let actual = su.apply_power(cmd_mw, dt);
out[sidx] = actual;
if sidx < self.storage_soc.len() {
self.storage_soc[sidx] = su.soc;
}
}
out
}
fn dispatch_storage_scheduled(&mut self, t: usize) -> Vec<f64> {
let dt = self.config.resolution.dt_hours();
let n_storage = self.storage_units.len();
let mut out = vec![0.0_f64; n_storage];
let scheduled: Vec<f64> = self
.storage_units
.iter()
.map(|su| {
self.network
.bus_series
.get(su.series_idx)
.map(|bts| bts.p_at(t))
.unwrap_or(0.0)
})
.collect();
for (sidx, su) in self.storage_units.iter_mut().enumerate() {
let cmd = scheduled.get(sidx).copied().unwrap_or(0.0);
let actual = su.apply_power(cmd, dt);
out[sidx] = actual;
if sidx < self.storage_soc.len() {
self.storage_soc[sidx] = su.soc;
}
}
out
}
fn apply_curtailment(&self, p_injections: &mut [f64], voltages: &[f64]) -> f64 {
let upper = self.config.voltage_upper_pu;
let mut total_curtailed = 0.0_f64;
for bts in &self.network.bus_series {
if !bts.series_type.is_renewable() {
continue;
}
let bus = bts.bus_id;
if bus >= voltages.len() || bus >= p_injections.len() {
continue;
}
let v = voltages[bus];
if v > upper {
let over = (v - upper) / upper;
let reduction = p_injections[bus] * over.min(1.0);
let reduction = reduction.max(0.0);
total_curtailed += reduction;
p_injections[bus] -= reduction;
}
}
total_curtailed
}
fn compute_generation_load(&self, t: usize, storage_p: &[f64]) -> (f64, f64, f64) {
let mut gen = 0.0_f64;
let mut load = 0.0_f64;
let mut ren = 0.0_f64;
for bts in &self.network.bus_series {
let p = bts.p_at(t);
match &bts.series_type {
BusTimeSeriesType::Load => load += p.max(0.0),
BusTimeSeriesType::SolarGeneration { .. }
| BusTimeSeriesType::WindGeneration { .. }
| BusTimeSeriesType::HydroGeneration => {
gen += p.max(0.0);
ren += p.max(0.0);
}
BusTimeSeriesType::FixedInjection => {
if p >= 0.0 {
gen += p;
} else {
load += -p;
}
}
BusTimeSeriesType::Storage { .. } => {}
}
}
for g in &self.network.generators {
gen += g.p_at(t).max(0.0);
}
for &sp in storage_p {
if sp > 0.0 {
gen += sp;
} else {
load += -sp;
}
}
(gen, load, ren)
}
fn compute_statistics(results: &[TimeStepResult], dt_hours: f64) -> TimeSeriesStatistics {
let n = results.len();
if n == 0 {
return TimeSeriesStatistics::default();
}
let n_converged = results.iter().filter(|r| r.converged).count();
let convergence_rate = n_converged as f64 / n as f64;
let mut v_max = f64::NEG_INFINITY;
let mut v_min = f64::INFINITY;
let mut v_sum = 0.0_f64;
let mut v_count = 0usize;
for r in results {
for &vm in &r.voltage_magnitude {
if vm > v_max {
v_max = vm;
}
if vm < v_min {
v_min = vm;
}
v_sum += vm;
v_count += 1;
}
}
let avg_voltage = if v_count > 0 {
v_sum / v_count as f64
} else {
1.0
};
let loads: Vec<f64> = results.iter().map(|r| r.total_load_mw).collect();
let peak_load = loads.iter().cloned().fold(f64::NAN, f64::max);
let peak_load = if peak_load.is_nan() { 0.0 } else { peak_load };
let avg_load = loads.iter().sum::<f64>() / n as f64;
let load_factor = if peak_load > 0.0 {
avg_load / peak_load
} else {
0.0
};
let total_energy_twh = avg_load * n as f64 * dt_hours / 1e6;
let total_ren: f64 = results.iter().map(|r| r.renewable_generation_mw).sum();
let total_gen: f64 = results.iter().map(|r| r.total_generation_mw).sum();
let ren_frac = if total_gen > 0.0 {
total_ren / total_gen * 100.0
} else {
0.0
};
let total_curtailment_mwh: f64 = results
.iter()
.map(|r| r.renewable_curtailment_mw * dt_hours)
.sum();
let total_losses_mwh: f64 = results.iter().map(|r| r.total_losses_mw * dt_hours).sum();
let all_loadings: Vec<f64> = results
.iter()
.flat_map(|r| r.branch_loading_pct.iter().cloned())
.collect();
let max_branch_loading = all_loadings
.iter()
.cloned()
.fold(f64::NAN, f64::max)
.max(0.0);
let max_branch_loading = if max_branch_loading.is_nan() {
0.0
} else {
max_branch_loading
};
let avg_branch_loading = if all_loadings.is_empty() {
0.0
} else {
all_loadings.iter().sum::<f64>() / all_loadings.len() as f64
};
let n_overload_hours = results
.iter()
.filter(|r| !r.overloaded_branches.is_empty())
.count();
let n_voltage_violation_hours = results
.iter()
.filter(|r| !r.voltage_violations.is_empty())
.count();
TimeSeriesStatistics {
n_timesteps: n,
n_converged,
convergence_rate,
max_voltage_pu: if v_max.is_infinite() { 1.0 } else { v_max },
min_voltage_pu: if v_min.is_infinite() { 1.0 } else { v_min },
avg_voltage_pu: avg_voltage,
peak_load_mw: peak_load,
avg_load_mw: avg_load,
load_factor,
total_energy_twh,
renewable_fraction_pct: ren_frac,
total_curtailment_mwh,
total_losses_mwh,
max_branch_loading_pct: max_branch_loading,
avg_branch_loading_pct: avg_branch_loading,
n_overload_hours,
n_voltage_violation_hours,
hosting_capacity_estimate_mw: 0.0,
}
}
fn compute_median_price(&self) -> f64 {
if let StorageStrategy::PriceArbitrage { price_profile } =
&self.config.storage_dispatch_strategy
{
if price_profile.is_empty() {
return 0.0;
}
let mut sorted = price_profile.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
let mid = sorted.len() / 2;
if sorted.len() % 2 == 0 {
(sorted[mid - 1] + sorted[mid]) / 2.0
} else {
sorted[mid]
}
} else {
0.0
}
}
fn count_violations_with_injection(
&self,
test_bus: usize,
extra_mw: f64,
dt: f64,
n_t: usize,
) -> Result<usize> {
let _ = dt;
let mut violations = 0usize;
for t in 0..n_t {
let (mut p_inj, q_inj) = self.get_bus_injections(t);
if test_bus < p_inj.len() {
p_inj[test_bus] += extra_mw;
}
let voltages = self.estimate_voltages(&p_inj, &q_inj);
let has_v_viol = voltages
.iter()
.any(|&v| v < self.config.voltage_lower_pu || v > self.config.voltage_upper_pu);
let angles = match self.solve_dc_powerflow(&p_inj) {
Ok(a) => a,
Err(_) => {
violations += 1;
continue;
}
};
let branch_flows = self.compute_branch_flows(&angles);
let branch_loading = self.compute_branch_loading(&branch_flows);
let has_overload = branch_loading.iter().any(|&l| l > 100.0);
if has_v_viol || has_overload {
violations += 1;
}
}
Ok(violations)
}
}
#[derive(Debug, Default)]
pub struct ScenarioAnalysis {
pub scenarios: Vec<(String, TimeSeriesResult)>,
}
impl ScenarioAnalysis {
pub fn new() -> Self {
Self::default()
}
pub fn add_scenario(&mut self, name: String, result: TimeSeriesResult) {
self.scenarios.push((name, result));
}
pub fn compare(&self) -> Vec<(String, TimeSeriesStatistics)> {
self.scenarios
.iter()
.map(|(name, res)| (name.clone(), res.statistics.clone()))
.collect()
}
pub fn optimal_scenario(&self) -> Option<&str> {
if self.scenarios.is_empty() {
return None;
}
let max_ren = self
.scenarios
.iter()
.map(|(_, r)| r.statistics.renewable_fraction_pct)
.fold(f64::NAN, f64::max)
.max(1.0);
let max_curtailment = self
.scenarios
.iter()
.map(|(_, r)| r.statistics.total_curtailment_mwh)
.fold(f64::NAN, f64::max)
.max(1.0);
let max_losses = self
.scenarios
.iter()
.map(|(_, r)| r.statistics.total_losses_mwh)
.fold(f64::NAN, f64::max)
.max(1.0);
let mut best_score = f64::NEG_INFINITY;
let mut best_name: Option<&str> = None;
for (name, res) in &self.scenarios {
let s = &res.statistics;
let ren_frac = (s.renewable_fraction_pct / max_ren).clamp(0.0, 1.0);
let curt_frac = (s.total_curtailment_mwh / max_curtailment).clamp(0.0, 1.0);
let loss_frac = (s.total_losses_mwh / max_losses).clamp(0.0, 1.0);
let score = 0.4 * ren_frac + 0.3 * (1.0 - curt_frac) + 0.3 * (1.0 - loss_frac);
if score > best_score {
best_score = score;
best_name = Some(name.as_str());
}
}
best_name
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesProfile {
pub load_profiles: Vec<Vec<f64>>,
pub renewable_profiles: Vec<Vec<f64>>,
pub price_profile: Vec<f64>,
}
impl TimeSeriesProfile {
pub fn flat(
n_buses: usize,
n_gens: usize,
n_timesteps: usize,
load_mw: f64,
renewable_mw: f64,
price: f64,
) -> Self {
Self {
load_profiles: vec![vec![load_mw; n_timesteps]; n_buses],
renewable_profiles: vec![vec![renewable_mw; n_timesteps]; n_gens],
price_profile: vec![price; n_timesteps],
}
}
pub fn n_timesteps(&self) -> usize {
self.price_profile.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_f64(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(self.0 >> 32) as f64 / u32::MAX as f64
}
fn next_in(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next_f64() * (hi - lo)
}
}
fn three_bus_network() -> TimeSeriesNetwork {
let n = 3;
let base = 100.0;
let mut b = vec![vec![0.0_f64; n]; n];
let g = vec![vec![0.0_f64; n]; n];
b[0][1] = -10.0;
b[1][0] = -10.0;
b[0][0] += 10.0;
b[1][1] += 10.0;
b[1][2] = -5.0;
b[2][1] = -5.0;
b[1][1] += 5.0;
b[2][2] += 5.0;
TimeSeriesNetwork {
n_buses: n,
g_matrix: g,
b_matrix: b,
bus_series: vec![],
generators: vec![],
branch_ratings_mva: vec![100.0, 100.0],
branches: vec![(0, 1), (1, 2)],
slack_bus: 0,
base_mva: base,
}
}
fn two_bus_network_with_profiles(n_t: usize) -> TimeSeriesNetwork {
let n = 2;
let base = 100.0;
let mut b = vec![vec![0.0_f64; n]; n];
let g = vec![vec![0.0_f64; n]; n];
b[0][1] = -10.0;
b[1][0] = -10.0;
b[0][0] += 10.0;
b[1][1] += 10.0;
let load_series = BusTimeSeries {
bus_id: 1,
p_mw: vec![50.0; n_t],
q_mvar: vec![10.0; n_t],
series_type: BusTimeSeriesType::Load,
};
TimeSeriesNetwork {
n_buses: n,
g_matrix: g,
b_matrix: b,
bus_series: vec![load_series],
generators: vec![],
branch_ratings_mva: vec![200.0],
branches: vec![(0, 1)],
slack_bus: 0,
base_mva: base,
}
}
#[test]
fn test_time_resolution_steps_per_day() {
assert_eq!(TimeResolution::FifteenMinutes.steps_per_day(), 96);
assert_eq!(TimeResolution::HalfHourly.steps_per_day(), 48);
assert_eq!(TimeResolution::Hourly.steps_per_day(), 24);
assert_eq!(TimeResolution::Daily.steps_per_day(), 1);
}
#[test]
fn test_time_resolution_dt_hours() {
let eps = 1e-12;
assert!((TimeResolution::FifteenMinutes.dt_hours() - 0.25).abs() < eps);
assert!((TimeResolution::HalfHourly.dt_hours() - 0.5).abs() < eps);
assert!((TimeResolution::Hourly.dt_hours() - 1.0).abs() < eps);
assert!((TimeResolution::Daily.dt_hours() - 24.0).abs() < eps);
}
#[test]
fn test_bus_time_series_creation() {
let bts = BusTimeSeries {
bus_id: 3,
p_mw: vec![10.0, 20.0, 30.0],
q_mvar: vec![2.0, 4.0, 6.0],
series_type: BusTimeSeriesType::Load,
};
assert_eq!(bts.bus_id, 3);
assert_eq!(bts.len(), 3);
assert!(!bts.is_empty());
assert!((bts.p_at(1) - 20.0).abs() < 1e-10);
assert!((bts.q_at(2) - 6.0).abs() < 1e-10);
assert!((bts.p_at(99) - 0.0).abs() < 1e-10); }
#[test]
fn test_generator_profile_creation() {
let gp = GeneratorProfile {
generator_id: 0,
bus: 1,
p_dispatch_mw: vec![50.0, 60.0, 70.0],
q_dispatch_mvar: vec![5.0, 6.0, 7.0],
p_max_mw: 100.0,
p_min_mw: 0.0,
cost_per_mwh: 40.0,
};
assert!((gp.p_at(0) - 50.0).abs() < 1e-10);
assert!((gp.p_at(2) - 70.0).abs() < 1e-10);
let gp2 = GeneratorProfile {
p_dispatch_mw: vec![150.0],
p_max_mw: 100.0,
p_min_mw: 0.0,
..gp.clone()
};
assert!((gp2.p_at(0) - 100.0).abs() < 1e-10);
}
#[test]
fn test_timeseries_network_creation() {
let net = three_bus_network();
assert_eq!(net.n_buses, 3);
assert_eq!(net.branches.len(), 2);
assert_eq!(net.branch_ratings_mva.len(), 2);
net.validate().expect("3-bus network should be valid");
}
#[test]
fn test_timeseries_config_default() {
let cfg = TimeSeriesConfig::default();
assert_eq!(cfg.n_timesteps, 8760);
assert_eq!(cfg.resolution, TimeResolution::Hourly);
assert!((cfg.voltage_lower_pu - 0.95).abs() < 1e-10);
assert!((cfg.voltage_upper_pu - 1.05).abs() < 1e-10);
assert!(cfg.enable_curtailment);
assert_eq!(cfg.max_pf_iterations, 20);
}
#[test]
fn test_get_bus_injections_load() {
let net = two_bus_network_with_profiles(5);
let cfg = TimeSeriesConfig {
n_timesteps: 5,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let (p, q) = sim.get_bus_injections(0);
assert!((p[1] - (-50.0)).abs() < 1e-9, "p[1]={}", p[1]);
assert!((q[1] - (-10.0)).abs() < 1e-9, "q[1]={}", q[1]);
assert!((p[0] - 0.0).abs() < 1e-9);
}
#[test]
fn test_get_bus_injections_generation() {
let mut net = two_bus_network_with_profiles(3);
net.bus_series.push(BusTimeSeries {
bus_id: 0,
p_mw: vec![30.0; 3],
q_mvar: vec![0.0; 3],
series_type: BusTimeSeriesType::SolarGeneration { installed_mw: 30.0 },
});
let cfg = TimeSeriesConfig {
n_timesteps: 3,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let (p, _) = sim.get_bus_injections(0);
assert!((p[0] - 30.0).abs() < 1e-9);
assert!((p[1] - (-50.0)).abs() < 1e-9);
}
#[test]
fn test_dc_powerflow_2bus() {
let net = two_bus_network_with_profiles(1);
let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let p_inj = vec![50.0, -50.0]; let angles = sim
.solve_dc_powerflow(&p_inj)
.expect("DC PF should converge");
assert!((angles[0] - 0.0).abs() < 1e-8, "slack angle must be 0");
let expected_theta1 = -0.05;
assert!(
(angles[1] - expected_theta1).abs() < 1e-6,
"θ1 = {:.6}, expected {:.6}",
angles[1],
expected_theta1
);
}
#[test]
fn test_dc_powerflow_3bus() {
let net = three_bus_network();
let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let p_inj = vec![100.0, -60.0, -40.0];
let angles = sim
.solve_dc_powerflow(&p_inj)
.expect("3-bus DC PF should converge");
assert_eq!(angles.len(), 3);
assert!((angles[0] - 0.0).abs() < 1e-8, "slack angle must be 0");
let p0_check = 10.0 * (angles[0] - angles[1]) * 100.0;
let p1_check =
10.0 * (angles[1] - angles[0]) * 100.0 + 5.0 * (angles[1] - angles[2]) * 100.0;
let p2_check = 5.0 * (angles[2] - angles[1]) * 100.0;
assert!(
(p0_check - 100.0).abs() < 1.0,
"P0 mismatch: {:.2}",
p0_check
);
assert!(
(p1_check - (-60.0)).abs() < 1.0,
"P1 mismatch: {:.2}",
p1_check
);
assert!(
(p2_check - (-40.0)).abs() < 1.0,
"P2 mismatch: {:.2}",
p2_check
);
}
#[test]
fn test_branch_flows_from_angles() {
let net = two_bus_network_with_profiles(1);
let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let angles = vec![0.0, -0.05];
let flows = sim.compute_branch_flows(&angles);
assert_eq!(flows.len(), 1);
assert!(
(flows[0] - 50.0).abs() < 1e-6,
"branch flow = {:.4}",
flows[0]
);
}
#[test]
fn test_branch_loading_within_rating() {
let net = two_bus_network_with_profiles(1);
let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let flows = vec![50.0];
let loading = sim.compute_branch_loading(&flows);
assert_eq!(loading.len(), 1);
assert!(
(loading[0] - 25.0).abs() < 1e-6,
"loading = {:.2}%",
loading[0]
);
assert!(loading[0] <= 100.0, "should not be overloaded");
}
#[test]
fn test_branch_loading_overloaded() {
let mut net = two_bus_network_with_profiles(1);
net.branch_ratings_mva = vec![30.0]; let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let flows = vec![50.0];
let loading = sim.compute_branch_loading(&flows);
assert!(
loading[0] > 100.0,
"should be overloaded: {:.1}%",
loading[0]
);
}
#[test]
fn test_voltage_estimation_flat() {
let net = two_bus_network_with_profiles(1);
let cfg = TimeSeriesConfig {
n_timesteps: 1,
..Default::default()
};
let sim = TimeSeriesSimulator::new(net, cfg);
let p_inj = vec![0.0, 0.0];
let q_inj = vec![0.0, 0.0];
let voltages = sim.estimate_voltages(&p_inj, &q_inj);
for &v in &voltages {
assert!((v - 1.0).abs() < 1e-9, "flat Q → V=1.0, got {}", v);
}
}
#[test]
fn test_storage_dispatch_peak_shaving_discharge() {
let mut net = two_bus_network_with_profiles(5);
net.bus_series.push(BusTimeSeries {
bus_id: 1,
p_mw: vec![0.0; 5],
q_mvar: vec![0.0; 5],
series_type: BusTimeSeriesType::Storage {
charge_negative: true,
},
});
let cfg = TimeSeriesConfig {
n_timesteps: 5,
storage_dispatch_strategy: StorageStrategy::PeakShaving { threshold_mw: 40.0 },
..Default::default()
};
let mut sim = TimeSeriesSimulator::new(net, cfg);
let _p_inj = [0.0, -60.0]; let power = sim.dispatch_storage_peak_shaving(60.0, 40.0, 0);
assert_eq!(power.len(), 1);
assert!(
power[0] > 0.0,
"should discharge (positive), got {:.4}",
power[0]
);
}
#[test]
fn test_storage_dispatch_peak_shaving_charge() {
let mut net = two_bus_network_with_profiles(5);
net.bus_series.push(BusTimeSeries {
bus_id: 1,
p_mw: vec![0.0; 5],
q_mvar: vec![0.0; 5],
series_type: BusTimeSeriesType::Storage {
charge_negative: true,
},
});
let cfg = TimeSeriesConfig {
n_timesteps: 5,
storage_dispatch_strategy: StorageStrategy::PeakShaving {
threshold_mw: 100.0,
},
..Default::default()
};
let mut sim = TimeSeriesSimulator::new(net, cfg);
let power = sim.dispatch_storage_peak_shaving(30.0, 100.0, 0);
assert_eq!(power.len(), 1);
assert!(
power[0] < 0.0,
"should charge (negative), got {:.4}",
power[0]
);
}
#[test]
fn test_storage_soc_update() {
let mut net = two_bus_network_with_profiles(5);
net.bus_series.push(BusTimeSeries {
bus_id: 1,
p_mw: vec![10.0; 5], q_mvar: vec![0.0; 5],
series_type: BusTimeSeriesType::Storage {
charge_negative: true,
},
});
let cfg = TimeSeriesConfig {
n_timesteps: 5,
storage_dispatch_strategy: StorageStrategy::ScheduledDispatch,
..Default::default()
};
let mut sim = TimeSeriesSimulator::new(net, cfg);
let initial_soc = sim.storage_soc[0];
sim.dispatch_storage_scheduled(0);
assert!(
sim.storage_soc[0] < initial_soc,
"SoC should decrease after discharge: {} < {}",
sim.storage_soc[0],
initial_soc
);
}
#[test]
fn test_run_24h_simulation() {
let n_t = 24;
let mut lcg = Lcg::new(42);
let mut net = three_bus_network();
let load_profile: Vec<f64> = (0..n_t).map(|_| lcg.next_in(20.0, 80.0)).collect();
net.bus_series.push(BusTimeSeries {
bus_id: 2,
p_mw: load_profile,
q_mvar: vec![5.0; n_t],
series_type: BusTimeSeriesType::Load,
});
let solar_profile: Vec<f64> = (0..n_t)
.map(|h| {
if (6..=18).contains(&h) {
lcg.next_in(10.0, 50.0)
} else {
0.0
}
})
.collect();
net.bus_series.push(BusTimeSeries {
bus_id: 1,
p_mw: solar_profile,
q_mvar: vec![0.0; n_t],
series_type: BusTimeSeriesType::SolarGeneration { installed_mw: 50.0 },
});
let cfg = TimeSeriesConfig {
n_timesteps: n_t,
resolution: TimeResolution::Hourly,
..Default::default()
};
let mut sim = TimeSeriesSimulator::new(net, cfg);
let result = sim.run().expect("24-hour simulation should succeed");
assert_eq!(result.timestep_results.len(), n_t);
assert_eq!(result.statistics.n_converged, n_t);
assert!(
(result.statistics.convergence_rate - 1.0).abs() < 1e-9,
"convergence_rate={}",
result.statistics.convergence_rate
);
}
#[test]
fn test_compute_statistics_load_factor() {
let make_result = |t: usize, load: f64| TimeStepResult {
timestep: t,
time_hours: t as f64,
converged: true,
voltage_magnitude: vec![1.0, 1.0],
voltage_angle: vec![0.0, 0.0],
branch_loading_pct: vec![50.0],
total_generation_mw: load,
total_load_mw: load,
total_losses_mw: 0.0,
renewable_generation_mw: 0.0,
renewable_curtailment_mw: 0.0,
storage_soc: vec![],
overloaded_branches: vec![],
voltage_violations: vec![],
};
let results = vec![
make_result(0, 80.0),
make_result(1, 40.0),
make_result(2, 80.0),
];
let stats = TimeSeriesSimulator::compute_statistics(&results, 1.0);
assert!((stats.peak_load_mw - 80.0).abs() < 1e-6);
let expected_lf = (200.0 / 3.0) / 80.0;
assert!(
(stats.load_factor - expected_lf).abs() < 1e-6,
"load_factor={:.4} expected={:.4}",
stats.load_factor,
expected_lf
);
}
#[test]
fn test_compute_statistics_renewable_fraction() {
let make_result = |t: usize, gen: f64, ren: f64| TimeStepResult {
timestep: t,
time_hours: t as f64,
converged: true,
voltage_magnitude: vec![1.0],
voltage_angle: vec![0.0],
branch_loading_pct: vec![],
total_generation_mw: gen,
total_load_mw: gen,
total_losses_mw: 0.0,
renewable_generation_mw: ren,
renewable_curtailment_mw: 0.0,
storage_soc: vec![],
overloaded_branches: vec![],
voltage_violations: vec![],
};
let results = vec![make_result(0, 100.0, 40.0), make_result(1, 100.0, 40.0)];
let stats = TimeSeriesSimulator::compute_statistics(&results, 1.0);
assert!(
(stats.renewable_fraction_pct - 40.0).abs() < 1e-6,
"ren_frac={:.4}",
stats.renewable_fraction_pct
);
}
#[test]
fn test_scenario_analysis_compare() {
let make_stats = |ren: f64| TimeSeriesStatistics {
renewable_fraction_pct: ren,
..Default::default()
};
let make_result = |ren: f64| TimeSeriesResult {
timestep_results: vec![],
statistics: make_stats(ren),
duration_s: 0.0,
};
let mut analysis = ScenarioAnalysis::new();
analysis.add_scenario("Base".into(), make_result(20.0));
analysis.add_scenario("HighRen".into(), make_result(60.0));
let compared = analysis.compare();
assert_eq!(compared.len(), 2);
assert_eq!(compared[0].0, "Base");
assert_eq!(compared[1].0, "HighRen");
assert!((compared[1].1.renewable_fraction_pct - 60.0).abs() < 1e-9);
}
#[test]
fn test_hosting_capacity_estimation() {
let n_t = 8;
let net = two_bus_network_with_profiles(n_t);
let cfg = TimeSeriesConfig {
n_timesteps: n_t,
resolution: TimeResolution::HalfHourly,
..Default::default()
};
let mut sim = TimeSeriesSimulator::new(net, cfg);
let hc = sim
.estimate_hosting_capacity(1, 200.0)
.expect("hosting capacity should succeed");
assert!(hc >= 0.0, "hosting capacity must be non-negative: {}", hc);
assert!(
hc <= 200.0,
"hosting capacity must not exceed search range: {}",
hc
);
}
}