use crate::digitaltwin::twin::{GridDigitalTwin, TwinState};
use crate::error::{OxiGridError, Result};
use crate::network::PowerNetwork;
#[derive(Debug, Clone)]
pub enum TwinModification {
TripBranch { branch_idx: usize },
TripGenerator { gen_idx: usize },
LoadStep { bus: usize, delta_mw: f64 },
VoltageSetpoint { bus: usize, v_ref_pu: f64 },
}
impl TwinModification {
pub fn apply(&self, network: &PowerNetwork) -> Result<PowerNetwork> {
let mut net = network.clone();
match self {
TwinModification::TripBranch { branch_idx } => {
let br = net.branches.get_mut(*branch_idx).ok_or_else(|| {
OxiGridError::InvalidNetwork(format!("Branch index {branch_idx} out of range"))
})?;
br.status = false;
}
TwinModification::TripGenerator { gen_idx } => {
let gen = net.generators.get_mut(*gen_idx).ok_or_else(|| {
OxiGridError::InvalidNetwork(format!("Generator index {gen_idx} out of range"))
})?;
gen.status = false;
gen.pg = 0.0;
}
TwinModification::LoadStep { bus, delta_mw } => {
use crate::units::Power;
let bus_obj = net.buses.get_mut(*bus).ok_or_else(|| {
OxiGridError::InvalidNetwork(format!("Bus index {bus} out of range"))
})?;
bus_obj.pd = Power(bus_obj.pd.0 + delta_mw);
}
TwinModification::VoltageSetpoint { bus, v_ref_pu } => {
let bus_obj = net.buses.get_mut(*bus).ok_or_else(|| {
OxiGridError::InvalidNetwork(format!("Bus index {bus} out of range"))
})?;
bus_obj.vm = *v_ref_pu;
for gen in &mut net.generators {
if gen.bus_id == bus_obj.id {
gen.vg = *v_ref_pu;
}
}
}
}
Ok(net)
}
}
#[derive(Debug, Clone)]
pub struct GridKpi {
pub avg_voltage_pu: f64,
pub min_voltage_pu: f64,
pub max_voltage_pu: f64,
pub n_voltage_violations: usize,
pub avg_loading_pct: f64,
pub max_loading_pct: f64,
pub n_overloads: usize,
pub n_alerts_total: usize,
pub avg_frequency_hz: f64,
pub frequency_nadir_hz: f64,
pub availability_pct: f64,
}
pub struct GridReplay {
pub twin: GridDigitalTwin,
pub history: Vec<(i64, TwinState)>,
pub playback_speed: f64,
}
impl GridReplay {
pub fn new(twin: GridDigitalTwin) -> Self {
Self {
twin,
history: Vec::new(),
playback_speed: 1.0,
}
}
pub fn record(&mut self, timestamp_us: i64) {
let state = self.twin.snapshot();
self.history.push((timestamp_us, state));
}
pub fn replay_from<F>(&self, start_us: i64, end_us: Option<i64>, mut callback: F)
where
F: FnMut(i64, &TwinState),
{
for (ts, state) in &self.history {
if *ts < start_us {
continue;
}
if let Some(end) = end_us {
if *ts >= end {
break;
}
}
callback(*ts, state);
}
}
pub fn what_if(&self, start_us: i64, modification: TwinModification) -> Result<Vec<TwinState>> {
let start_idx = self
.history
.iter()
.position(|(ts, _)| *ts >= start_us)
.ok_or_else(|| {
OxiGridError::InvalidParameter(format!(
"No recorded state at or after timestamp {start_us}"
))
})?;
let (_, start_state) = &self.history[start_idx];
let modified_net = modification.apply(&self.twin.network)?;
let config = crate::digitaltwin::twin::TwinConfig {
run_se_on_scada: false, ..Default::default()
};
let mut shadow = GridDigitalTwin::new(modified_net, config);
shadow.state = start_state.clone();
let mut counterfactual = Vec::new();
counterfactual.push(start_state.clone());
for (ts, recorded_state) in self.history[start_idx + 1..].iter() {
use crate::digitaltwin::telemetry::{
ScadaMeasType, ScadaPoint, TelemetryBatch, TelemetrySource,
};
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, *ts);
for (bus_idx, &v) in recorded_state.voltage_magnitudes.iter().enumerate() {
batch.add_scada(ScadaPoint::new(
bus_idx as u32,
*ts,
ScadaMeasType::VoltageMagnitude,
bus_idx,
v,
0,
));
}
shadow.ingest_telemetry(&batch)?;
counterfactual.push(shadow.snapshot());
}
Ok(counterfactual)
}
pub fn compute_kpis(&self, start_us: i64, end_us: i64) -> GridKpi {
let window: Vec<&TwinState> = self
.history
.iter()
.filter(|(ts, _)| *ts >= start_us && *ts <= end_us)
.map(|(_, s)| s)
.collect();
if window.is_empty() {
return GridKpi {
avg_voltage_pu: 0.0,
min_voltage_pu: 0.0,
max_voltage_pu: 0.0,
n_voltage_violations: 0,
avg_loading_pct: 0.0,
max_loading_pct: 0.0,
n_overloads: 0,
n_alerts_total: 0,
avg_frequency_hz: 0.0,
frequency_nadir_hz: 0.0,
availability_pct: 0.0,
};
}
let mut sum_v = 0.0_f64;
let mut n_v = 0usize;
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
let mut n_violations = 0usize;
let mut sum_loading = 0.0_f64;
let mut n_loading = 0usize;
let mut max_loading = 0.0_f64;
let mut n_overloads = 0usize;
let mut sum_freq = 0.0_f64;
let mut min_freq = f64::INFINITY;
let mut n_all_energised = 0usize;
for state in &window {
for &v in &state.voltage_magnitudes {
sum_v += v;
n_v += 1;
if v < min_v {
min_v = v;
}
if v > max_v {
max_v = v;
}
if !(0.95..=1.05).contains(&v) {
n_violations += 1;
}
}
for &p in &state.branch_flows_mw {
let loading = p.abs();
sum_loading += loading;
n_loading += 1;
if loading > max_loading {
max_loading = loading;
}
if loading > 100.0 {
n_overloads += 1;
}
}
sum_freq += state.frequency_hz;
if state.frequency_hz < min_freq {
min_freq = state.frequency_hz;
}
let all_up = state.voltage_magnitudes.iter().all(|&v| v > 0.1);
if all_up {
n_all_energised += 1;
}
}
let avg_v = if n_v > 0 { sum_v / n_v as f64 } else { 0.0 };
let avg_loading = if n_loading > 0 {
sum_loading / n_loading as f64
} else {
0.0
};
let avg_freq = sum_freq / window.len() as f64;
let avail = 100.0 * n_all_energised as f64 / window.len() as f64;
GridKpi {
avg_voltage_pu: avg_v,
min_voltage_pu: if min_v.is_finite() { min_v } else { 0.0 },
max_voltage_pu: if max_v.is_finite() { max_v } else { 0.0 },
n_voltage_violations: n_violations,
avg_loading_pct: avg_loading,
max_loading_pct: max_loading,
n_overloads,
n_alerts_total: 0, avg_frequency_hz: avg_freq,
frequency_nadir_hz: if min_freq.is_finite() { min_freq } else { 0.0 },
availability_pct: avail,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::digitaltwin::telemetry::{
ScadaMeasType, ScadaPoint, TelemetryBatch, TelemetrySource,
};
use crate::digitaltwin::twin::TwinConfig;
use crate::network::branch::Branch;
use crate::network::bus::{Bus, BusType};
fn make_network() -> PowerNetwork {
let mut net = PowerNetwork::new(100.0);
let mut b1 = Bus::new(1, BusType::Slack);
b1.vm = 1.0;
let mut b2 = Bus::new(2, BusType::PQ);
b2.vm = 1.0;
net.buses = vec![b1, b2];
net.branches = vec![Branch {
from_bus: 1,
to_bus: 2,
r: 0.01,
x: 0.05,
b: 0.01,
rate_a: 100.0,
rate_b: 120.0,
rate_c: 150.0,
tap: 0.0,
shift: 0.0,
status: true,
}];
net
}
fn voltage_batch(ts: i64, v0: f64, v1: f64) -> TelemetryBatch {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, ts);
batch.add_scada(ScadaPoint::new(
0,
ts,
ScadaMeasType::VoltageMagnitude,
0,
v0,
0,
));
batch.add_scada(ScadaPoint::new(
1,
ts,
ScadaMeasType::VoltageMagnitude,
1,
v1,
0,
));
batch
}
#[test]
fn test_replay_record_playback() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
replay
.twin
.ingest_telemetry(&voltage_batch(0, 1.00, 1.00))
.expect("ingest 0");
replay.record(0);
replay
.twin
.ingest_telemetry(&voltage_batch(1_000_000, 1.01, 0.99))
.expect("ingest 1");
replay.record(1_000_000);
let mut visited = 0usize;
replay.replay_from(0, None, |_, _| visited += 1);
assert_eq!(visited, 2, "should replay both recorded states");
let mut visited2 = 0usize;
replay.replay_from(500_000, None, |_, _| visited2 += 1);
assert_eq!(visited2, 1);
}
#[test]
fn test_what_if_branch_trip() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
replay
.twin
.ingest_telemetry(&voltage_batch(0, 1.00, 1.00))
.expect("ok");
replay.record(0);
replay
.twin
.ingest_telemetry(&voltage_batch(1_000_000, 1.01, 0.99))
.expect("ok");
replay.record(1_000_000);
let states = replay
.what_if(0, TwinModification::TripBranch { branch_idx: 0 })
.expect("what-if branch trip");
assert!(!states.is_empty(), "what-if should produce states");
assert!(replay.twin.network.branches[0].status || !states.is_empty());
}
#[test]
fn test_grid_kpi_computation() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
for i in 0..5 {
let ts = i as i64 * 1_000_000;
replay
.twin
.ingest_telemetry(&voltage_batch(ts, 1.02, 0.98))
.expect("ingest");
replay.record(ts);
}
let kpis = replay.compute_kpis(0, 5_000_000);
assert!(
(kpis.avg_voltage_pu - 1.0).abs() < 0.05,
"avg V should be near 1.0"
);
assert!(kpis.min_voltage_pu > 0.0);
assert!(kpis.max_voltage_pu > 0.0);
assert_eq!(kpis.availability_pct, 100.0, "all buses energised");
assert!(kpis.avg_frequency_hz > 0.0);
}
#[test]
fn replay_new_has_empty_history() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let replay = GridReplay::new(twin);
assert!(
replay.history.is_empty(),
"new replay must start with no history"
);
assert_eq!(replay.playback_speed, 1.0, "default playback speed is 1.0");
}
#[test]
fn replay_record_increments_history_length() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
replay
.twin
.ingest_telemetry(&voltage_batch(0, 1.0, 1.0))
.expect("ingest");
replay.record(0);
assert_eq!(
replay.history.len(),
1,
"history should have 1 entry after first record"
);
replay
.twin
.ingest_telemetry(&voltage_batch(1_000_000, 1.01, 0.99))
.expect("ingest");
replay.record(1_000_000);
assert_eq!(
replay.history.len(),
2,
"history should have 2 entries after second record"
);
}
#[test]
fn replay_history_timestamps_are_ordered() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
for i in 0..5_i64 {
let ts = i * 500_000;
replay
.twin
.ingest_telemetry(&voltage_batch(ts, 1.0, 1.0))
.expect("ingest");
replay.record(ts);
}
let timestamps: Vec<i64> = replay.history.iter().map(|(ts, _)| *ts).collect();
let sorted = timestamps.windows(2).all(|w| w[0] <= w[1]);
assert!(sorted, "history timestamps must be non-decreasing");
}
#[test]
fn replay_from_with_end_bound_stops_at_end() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
for i in 0..4_i64 {
let ts = i * 1_000_000;
replay
.twin
.ingest_telemetry(&voltage_batch(ts, 1.0, 1.0))
.expect("ingest");
replay.record(ts);
}
let mut count = 0usize;
replay.replay_from(0, Some(2_000_000), |_, _| count += 1);
assert_eq!(
count, 2,
"replay_from with end=2M should visit exactly 2 states"
);
}
#[test]
fn kpi_empty_window_returns_zeros() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let replay = GridReplay::new(twin);
let kpis = replay.compute_kpis(0, 1_000_000);
assert_eq!(kpis.avg_voltage_pu, 0.0, "empty window avg V should be 0");
assert_eq!(
kpis.availability_pct, 0.0,
"empty window availability should be 0"
);
assert_eq!(kpis.n_voltage_violations, 0);
}
#[test]
fn what_if_load_step_produces_counterfactual() {
let net = make_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut replay = GridReplay::new(twin);
replay
.twin
.ingest_telemetry(&voltage_batch(0, 1.0, 1.0))
.expect("ingest 0");
replay.record(0);
replay
.twin
.ingest_telemetry(&voltage_batch(1_000_000, 1.01, 0.99))
.expect("ingest 1");
replay.record(1_000_000);
let states = replay
.what_if(
0,
TwinModification::LoadStep {
bus: 0,
delta_mw: 10.0,
},
)
.expect("what-if load step should succeed");
assert!(
!states.is_empty(),
"what-if load step must return a non-empty trajectory"
);
}
}