use crate::digitaltwin::alert::{AlertEngine, AlertThresholds, TwinAlert};
use crate::digitaltwin::telemetry::TelemetryBatch;
use crate::error::{OxiGridError, Result};
use crate::network::PowerNetwork;
use crate::powerflow::state_estimation::{DcStateEstimator, Measurement};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataQuality {
Good,
Estimated,
Interpolated,
Stale,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StateSource {
Scada,
Pmu,
StateEstimation,
PowerFlow,
Hybrid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwinState {
pub voltage_magnitudes: Vec<f64>,
pub voltage_angles: Vec<f64>,
pub branch_flows_mw: Vec<f64>,
pub branch_flows_mvar: Vec<f64>,
pub generation_mw: Vec<f64>,
pub load_mw: Vec<f64>,
pub frequency_hz: f64,
pub timestamp_us: i64,
pub data_quality: Vec<DataQuality>,
pub state_source: StateSource,
}
impl TwinState {
pub fn flat_start(n_buses: usize, n_branches: usize, n_generators: usize) -> Self {
Self {
voltage_magnitudes: vec![1.0; n_buses],
voltage_angles: vec![0.0; n_buses],
branch_flows_mw: vec![0.0; n_branches],
branch_flows_mvar: vec![0.0; n_branches],
generation_mw: vec![0.0; n_generators],
load_mw: vec![0.0; n_buses],
frequency_hz: 50.0,
timestamp_us: 0,
data_quality: vec![DataQuality::Missing; n_buses],
state_source: StateSource::PowerFlow,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwinConfig {
pub update_rate_hz: f64,
pub staleness_threshold_s: f64,
pub run_se_on_scada: bool,
pub run_pf_on_se: bool,
pub alert_thresholds: AlertThresholds,
}
impl Default for TwinConfig {
fn default() -> Self {
Self {
update_rate_hz: 50.0,
staleness_threshold_s: 5.0,
run_se_on_scada: true,
run_pf_on_se: false, alert_thresholds: AlertThresholds::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwinDivergence {
pub max_voltage_error_pu: f64,
pub max_angle_error_deg: f64,
pub max_flow_error_mw: f64,
pub rms_voltage_error: f64,
pub diverged_buses: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyChange {
pub detected_at_us: i64,
pub changed_branches: Vec<usize>,
pub change_type: TopologyChangeType,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum TopologyChangeType {
LineTrip,
LineClosure,
GeneratorTrip,
TransformerTap,
}
pub struct GridDigitalTwin {
pub network: PowerNetwork,
pub state: TwinState,
pub config: TwinConfig,
pub update_count: u64,
pub last_update_us: i64,
alert_engine: AlertEngine,
prev_state: Option<TwinState>,
}
impl GridDigitalTwin {
pub fn new(network: PowerNetwork, config: TwinConfig) -> Self {
let n_buses = network.buses.len();
let n_branches = network.branches.len();
let n_gens = network.generators.len();
let alert_engine = AlertEngine::new(config.alert_thresholds.clone());
Self {
state: TwinState::flat_start(n_buses, n_branches, n_gens),
config,
update_count: 0,
last_update_us: 0,
alert_engine,
prev_state: None,
network,
}
}
pub fn ingest_telemetry(&mut self, telemetry: &TelemetryBatch) -> Result<Vec<TwinAlert>> {
let n_buses = self.network.buses.len();
let n_branches = self.network.branches.len();
let n_gens = self.network.generators.len();
let scan_time = telemetry.scan_time_us;
let measurements = telemetry.to_se_measurements(self.network.base_mva);
let mut new_state = if let Some(prev) = &self.prev_state {
prev.clone()
} else {
TwinState::flat_start(n_buses, n_branches, n_gens)
};
new_state.timestamp_us = scan_time;
new_state.data_quality = vec![DataQuality::Stale; n_buses];
use crate::powerflow::state_estimation::MeasurementType;
let mut has_voltage_data = false;
for m in &measurements {
if m.bus >= n_buses {
continue;
}
match m.mtype {
MeasurementType::VoltageMagnitude => {
new_state.voltage_magnitudes[m.bus] = m.value;
new_state.data_quality[m.bus] = DataQuality::Good;
has_voltage_data = true;
}
MeasurementType::PowerInjection => {
let mw = m.value * self.network.base_mva;
if mw < 0.0 && m.bus < new_state.load_mw.len() {
new_state.load_mw[m.bus] = -mw;
}
}
_ => {}
}
}
if self.config.run_se_on_scada && !measurements.is_empty() {
if let Ok(se_result) = self.run_dc_se(&measurements) {
for (idx, &angle) in se_result.iter().enumerate() {
if idx < n_buses {
new_state.voltage_angles[idx] = angle;
if new_state.data_quality[idx] == DataQuality::Stale {
new_state.data_quality[idx] = DataQuality::Estimated;
}
}
}
}
}
self.interpolate_missing(&mut new_state);
if let Some(frame) = telemetry.pmu_frames.first() {
if frame.is_ok() {
new_state.frequency_hz = frame.freq_hz;
}
} else if !telemetry.scada_points.is_empty() {
use crate::digitaltwin::telemetry::ScadaMeasType;
for pt in &telemetry.scada_points {
if pt.measurement_type == ScadaMeasType::Frequency && pt.is_good() {
new_state.frequency_hz = pt.value;
}
}
}
self.compute_branch_flows(&mut new_state);
new_state.state_source = if has_voltage_data && !telemetry.pmu_frames.is_empty() {
StateSource::Hybrid
} else if !telemetry.pmu_frames.is_empty() {
StateSource::Pmu
} else if self.config.run_se_on_scada {
StateSource::StateEstimation
} else {
StateSource::Scada
};
let staleness_threshold_us = (self.config.staleness_threshold_s * 1_000_000.0) as i64;
for q in &mut new_state.data_quality {
if *q == DataQuality::Missing {
}
}
if let Some(prev) = &self.prev_state {
let age_us = scan_time - prev.timestamp_us;
if age_us > staleness_threshold_us {
for (idx, q) in new_state.data_quality.iter_mut().enumerate() {
if *q == DataQuality::Good {
} else if idx < prev.data_quality.len()
&& prev.data_quality[idx] == DataQuality::Good
{
*q = DataQuality::Stale;
}
}
}
}
let prev = std::mem::replace(&mut self.state, new_state);
self.prev_state = Some(prev);
self.update_count += 1;
self.last_update_us = scan_time;
let alerts = self.alert_engine.check_state(&self.state, scan_time);
Ok(alerts)
}
pub fn snapshot(&self) -> TwinState {
self.state.clone()
}
pub fn predict_state(&self, horizon_s: f64) -> TwinState {
let Some(prev) = &self.prev_state else {
return self.state.clone();
};
let dt_us = self.state.timestamp_us - prev.timestamp_us;
if dt_us <= 0 {
return self.state.clone();
}
let dt_s = dt_us as f64 / 1_000_000.0;
let horizon_ratio = horizon_s / dt_s;
let n_buses = self.state.voltage_magnitudes.len();
let mut predicted = self.state.clone();
predicted.timestamp_us = self.state.timestamp_us + (horizon_s * 1_000_000.0) as i64;
for i in 0..n_buses {
let dv = self.state.voltage_magnitudes[i] - prev.voltage_magnitudes[i];
let da = self.state.voltage_angles[i] - prev.voltage_angles[i];
predicted.voltage_magnitudes[i] = self.state.voltage_magnitudes[i] + dv * horizon_ratio;
predicted.voltage_angles[i] = self.state.voltage_angles[i] + da * horizon_ratio;
}
let df = self.state.frequency_hz - prev.frequency_hz;
predicted.frequency_hz = self.state.frequency_hz + df * horizon_ratio;
for q in &mut predicted.data_quality {
if *q == DataQuality::Good {
*q = DataQuality::Interpolated;
}
}
predicted.state_source = StateSource::Hybrid;
predicted
}
pub fn compare_to_reference(&self, reference: &TwinState) -> TwinDivergence {
let n = self
.state
.voltage_magnitudes
.len()
.min(reference.voltage_magnitudes.len());
let mut max_v_err = 0.0_f64;
let mut max_a_err = 0.0_f64;
let mut sum_sq_v = 0.0_f64;
let mut diverged_buses = Vec::new();
for i in 0..n {
let v_err = (self.state.voltage_magnitudes[i] - reference.voltage_magnitudes[i]).abs();
let a_err = (self.state.voltage_angles[i] - reference.voltage_angles[i])
.abs()
.to_degrees();
max_v_err = max_v_err.max(v_err);
max_a_err = max_a_err.max(a_err);
sum_sq_v += v_err * v_err;
if v_err > 0.05 {
diverged_buses.push(i);
}
}
let rms_v = if n > 0 {
(sum_sq_v / n as f64).sqrt()
} else {
0.0
};
let m_flow = self
.state
.branch_flows_mw
.len()
.min(reference.branch_flows_mw.len());
let max_flow_err = (0..m_flow)
.map(|i| (self.state.branch_flows_mw[i] - reference.branch_flows_mw[i]).abs())
.fold(0.0_f64, f64::max);
TwinDivergence {
max_voltage_error_pu: max_v_err,
max_angle_error_deg: max_a_err,
max_flow_error_mw: max_flow_err,
rms_voltage_error: rms_v,
diverged_buses,
}
}
pub fn detect_topology_change(&self, prev_state: &TwinState) -> Option<TopologyChange> {
let n_br = self
.state
.branch_flows_mw
.len()
.min(prev_state.branch_flows_mw.len());
let mut changed: Vec<usize> = Vec::new();
let mut n_trips = 0usize;
let mut n_closures = 0usize;
for i in 0..n_br {
let prev_f = prev_state.branch_flows_mw[i].abs();
let curr_f = self.state.branch_flows_mw[i].abs();
let was_energised = prev_f > 1.0; let now_energised = curr_f > 1.0;
if was_energised && !now_energised {
changed.push(i);
n_trips += 1;
} else if !was_energised && now_energised {
changed.push(i);
n_closures += 1;
}
}
let n_gen = self
.state
.generation_mw
.len()
.min(prev_state.generation_mw.len());
for i in 0..n_gen {
if prev_state.generation_mw[i] > 10.0 && self.state.generation_mw[i] < 1.0 {
return Some(TopologyChange {
detected_at_us: self.state.timestamp_us,
changed_branches: changed,
change_type: TopologyChangeType::GeneratorTrip,
});
}
}
if changed.is_empty() {
return None;
}
let change_type = if n_trips > n_closures {
TopologyChangeType::LineTrip
} else {
TopologyChangeType::LineClosure
};
Some(TopologyChange {
detected_at_us: self.state.timestamp_us,
changed_branches: changed,
change_type,
})
}
pub fn alert_engine(&self) -> &AlertEngine {
&self.alert_engine
}
pub fn alert_engine_mut(&mut self) -> &mut AlertEngine {
&mut self.alert_engine
}
fn run_dc_se(&self, measurements: &[Measurement]) -> Result<Vec<f64>> {
let n_buses = self.network.buses.len();
if n_buses == 0 || measurements.is_empty() {
return Err(OxiGridError::InvalidNetwork(
"Empty network or no measurements".into(),
));
}
let slack_idx = self.network.slack_bus_index().unwrap_or(0);
let b_bus = self.build_dc_b_bus();
let branch_from: Vec<usize> = self
.network
.branches
.iter()
.map(|b| {
self.network
.buses
.iter()
.position(|bus| bus.id == b.from_bus)
.unwrap_or(0)
})
.collect();
let branch_to: Vec<usize> = self
.network
.branches
.iter()
.map(|b| {
self.network
.buses
.iter()
.position(|bus| bus.id == b.to_bus)
.unwrap_or(0)
})
.collect();
let branch_x: Vec<f64> = self.network.branches.iter().map(|b| b.x).collect();
let estimator =
DcStateEstimator::new(n_buses, slack_idx, b_bus, branch_from, branch_to, branch_x);
let result = estimator.estimate(measurements)?;
Ok(result.theta)
}
fn build_dc_b_bus(&self) -> Vec<Vec<f64>> {
let n = self.network.buses.len();
let mut b = vec![vec![0.0; n]; n];
let bus_index = |id: usize| -> usize {
self.network
.buses
.iter()
.position(|bus| bus.id == id)
.unwrap_or(0)
};
for branch in &self.network.branches {
if !branch.status {
continue;
}
let x = if branch.x.abs() < 1e-10 {
1e-10
} else {
branch.x
};
let bval = 1.0 / x;
let i = bus_index(branch.from_bus);
let j = bus_index(branch.to_bus);
b[i][i] += bval;
b[j][j] += bval;
b[i][j] -= bval;
b[j][i] -= bval;
}
b
}
fn interpolate_missing(&self, state: &mut TwinState) {
let n = state.voltage_magnitudes.len();
for i in 0..n {
if state.data_quality[i] == DataQuality::Missing
|| state.data_quality[i] == DataQuality::Stale
{
let neighbours: Vec<usize> = self
.network
.branches
.iter()
.filter_map(|br| {
let fi = self
.network
.buses
.iter()
.position(|b| b.id == br.from_bus)
.unwrap_or(usize::MAX);
let ti = self
.network
.buses
.iter()
.position(|b| b.id == br.to_bus)
.unwrap_or(usize::MAX);
if fi == i && ti < n {
Some(ti)
} else if ti == i && fi < n {
Some(fi)
} else {
None
}
})
.collect();
if neighbours.is_empty() {
continue;
}
let avg_v: f64 = neighbours
.iter()
.map(|&j| state.voltage_magnitudes[j])
.sum::<f64>()
/ neighbours.len() as f64;
state.voltage_magnitudes[i] = avg_v;
state.data_quality[i] = DataQuality::Interpolated;
}
}
}
fn compute_branch_flows(&self, state: &mut TwinState) {
let bus_index = |id: usize| -> usize {
self.network
.buses
.iter()
.position(|b| b.id == id)
.unwrap_or(0)
};
for (k, branch) in self.network.branches.iter().enumerate() {
if k >= state.branch_flows_mw.len() {
break;
}
if !branch.status {
state.branch_flows_mw[k] = 0.0;
state.branch_flows_mvar[k] = 0.0;
continue;
}
let i = bus_index(branch.from_bus);
let j = bus_index(branch.to_bus);
let x = if branch.x.abs() < 1e-10 {
1e-10
} else {
branch.x
};
let d_theta = state.voltage_angles[i] - state.voltage_angles[j];
let p_pu = d_theta / x;
state.branch_flows_mw[k] = p_pu * self.network.base_mva;
let b_shunt = branch.b / 2.0;
let dv = state.voltage_magnitudes[i] - state.voltage_magnitudes[j];
state.branch_flows_mvar[k] = dv * b_shunt * self.network.base_mva;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::digitaltwin::telemetry::{
ScadaMeasType, ScadaPoint, TelemetryBatch, TelemetrySource,
};
use crate::network::bus::BusType;
fn three_bus_network() -> PowerNetwork {
use crate::network::branch::Branch;
use crate::network::bus::Bus;
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::PV);
b2.vm = 1.0;
let mut b3 = Bus::new(3, BusType::PQ);
b3.vm = 1.0;
net.buses = vec![b1, b2, b3];
net.branches = vec![
Branch {
from_bus: 1,
to_bus: 2,
r: 0.01,
x: 0.05,
b: 0.01,
rate_a: 200.0,
rate_b: 250.0,
rate_c: 300.0,
tap: 0.0,
shift: 0.0,
status: true,
},
Branch {
from_bus: 2,
to_bus: 3,
r: 0.02,
x: 0.08,
b: 0.005,
rate_a: 150.0,
rate_b: 200.0,
rate_c: 250.0,
tap: 0.0,
shift: 0.0,
status: true,
},
];
net
}
fn make_voltage_scada(bus_idx: usize, v_pu: f64, ts: i64) -> ScadaPoint {
ScadaPoint::new(
bus_idx as u32,
ts,
ScadaMeasType::VoltageMagnitude,
bus_idx,
v_pu,
0,
)
}
#[test]
fn test_twin_ingest_telemetry() {
let net = three_bus_network();
let mut twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(make_voltage_scada(0, 1.02, 1_000_000));
batch.add_scada(make_voltage_scada(1, 0.99, 1_000_000));
batch.add_scada(make_voltage_scada(2, 0.97, 1_000_000));
let alerts = twin
.ingest_telemetry(&batch)
.expect("ingest should succeed");
assert!(
alerts.is_empty()
|| alerts.iter().all(|a| {
use crate::digitaltwin::alert::AlertSeverity;
a.severity < AlertSeverity::Critical
})
);
assert_eq!(twin.update_count, 1);
assert!((twin.state.voltage_magnitudes[0] - 1.02).abs() < 1e-9);
}
#[test]
fn test_twin_state_quality_flags() {
let net = three_bus_network();
let mut twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(make_voltage_scada(0, 1.01, 1_000_000));
twin.ingest_telemetry(&batch).expect("ingest ok");
assert_eq!(twin.state.data_quality[0], DataQuality::Good);
assert_ne!(twin.state.data_quality[2], DataQuality::Good);
}
#[test]
fn test_twin_predict_state() {
let net = three_bus_network();
let mut twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut b1 = TelemetryBatch::new(TelemetrySource::Scada, 0);
b1.add_scada(make_voltage_scada(0, 1.00, 0));
b1.add_scada(make_voltage_scada(1, 1.00, 0));
b1.add_scada(make_voltage_scada(2, 1.00, 0));
twin.ingest_telemetry(&b1).expect("ingest 1");
let mut b2 = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
b2.add_scada(make_voltage_scada(0, 1.01, 1_000_000));
b2.add_scada(make_voltage_scada(1, 1.01, 1_000_000));
b2.add_scada(make_voltage_scada(2, 1.01, 1_000_000));
twin.ingest_telemetry(&b2).expect("ingest 2");
let predicted = twin.predict_state(1.0);
assert!(
(predicted.voltage_magnitudes[0] - 1.02).abs() < 1e-6,
"predicted V0 = {:.6}, expected ~1.02",
predicted.voltage_magnitudes[0]
);
assert!(predicted
.data_quality
.iter()
.all(|q| *q == DataQuality::Interpolated));
}
#[test]
fn test_topology_change_detection() {
let net = three_bus_network();
let mut twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut b1 = TelemetryBatch::new(TelemetrySource::Scada, 0);
b1.add_scada(make_voltage_scada(0, 1.00, 0));
b1.add_scada(make_voltage_scada(1, 0.99, 0));
b1.add_scada(make_voltage_scada(2, 0.98, 0));
twin.ingest_telemetry(&b1).expect("ok");
let state_before = twin.snapshot();
let mut b2 = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
b2.add_scada(make_voltage_scada(0, 1.00, 1_000_000));
b2.add_scada(make_voltage_scada(1, 0.99, 1_000_000));
b2.add_scada(make_voltage_scada(2, 0.00, 1_000_000)); twin.ingest_telemetry(&b2).expect("ok");
let change = twin.detect_topology_change(&state_before);
let _ = change; }
#[test]
fn test_twin_flat_start_dimensions() {
let state = TwinState::flat_start(5, 4, 2);
assert_eq!(state.voltage_magnitudes.len(), 5);
assert_eq!(state.branch_flows_mw.len(), 4);
assert_eq!(state.generation_mw.len(), 2);
for &v in &state.voltage_magnitudes {
assert!((v - 1.0).abs() < 1e-12);
}
}
#[test]
fn test_twin_snapshot_matches_state() {
let net = three_bus_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let snap = twin.snapshot();
assert_eq!(
snap.voltage_magnitudes.len(),
twin.state.voltage_magnitudes.len()
);
for (a, b) in snap
.voltage_magnitudes
.iter()
.zip(twin.state.voltage_magnitudes.iter())
{
assert!((a - b).abs() < 1e-12);
}
}
#[test]
fn test_twin_compare_to_reference_identity_gives_zero_divergence() {
let net = three_bus_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let reference = twin.snapshot();
let div = twin.compare_to_reference(&reference);
assert!((div.max_voltage_error_pu).abs() < 1e-12);
assert!((div.rms_voltage_error).abs() < 1e-12);
assert!(div.diverged_buses.is_empty());
}
#[test]
fn test_twin_compare_to_reference_detects_large_voltage_error() {
let net = three_bus_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let n = twin.state.voltage_magnitudes.len();
let n_br = twin.state.branch_flows_mw.len();
let n_gen = twin.state.generation_mw.len();
let mut reference = TwinState::flat_start(n, n_br, n_gen);
reference.voltage_magnitudes[1] = 0.80; let div = twin.compare_to_reference(&reference);
assert!(
div.max_voltage_error_pu > 0.19,
"max_err={:.4}",
div.max_voltage_error_pu
);
assert!(div.diverged_buses.contains(&1));
}
#[test]
fn test_twin_predict_no_prev_state_returns_current() {
let net = three_bus_network();
let twin = GridDigitalTwin::new(net, TwinConfig::default());
let predicted = twin.predict_state(1.0);
for (p, c) in predicted
.voltage_magnitudes
.iter()
.zip(twin.state.voltage_magnitudes.iter())
{
assert!((p - c).abs() < 1e-12, "predicted V differs from current");
}
}
#[test]
fn test_twin_update_count_increments_on_ingest() {
let net = three_bus_network();
let mut twin = GridDigitalTwin::new(net, TwinConfig::default());
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(make_voltage_scada(0, 1.0, 1_000_000));
twin.ingest_telemetry(&batch).expect("ingest ok");
assert_eq!(twin.update_count, 1);
twin.ingest_telemetry(&batch).expect("ingest ok 2");
assert_eq!(twin.update_count, 2);
}
}