use crate::io::pmu::PmuFrame;
use crate::powerflow::state_estimation::{Measurement, MeasurementType};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScadaMeasType {
VoltageMagnitude,
ActivePower,
ReactivePower,
Current,
Frequency,
TapPosition,
BreakerStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScadaPoint {
pub point_id: u32,
pub timestamp_us: i64,
pub measurement_type: ScadaMeasType,
pub bus_or_branch: usize,
pub value: f64,
pub quality: u8,
}
impl ScadaPoint {
pub fn new(
point_id: u32,
timestamp_us: i64,
measurement_type: ScadaMeasType,
bus_or_branch: usize,
value: f64,
quality: u8,
) -> Self {
Self {
point_id,
timestamp_us,
measurement_type,
bus_or_branch,
value,
quality,
}
}
pub fn is_good(&self) -> bool {
self.quality == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TelemetrySource {
Scada,
Pmu {
pmu_id: u16,
},
Mixed,
}
#[derive(Debug, Clone)]
pub struct TelemetryBatch {
pub source: TelemetrySource,
pub scan_time_us: i64,
pub scada_points: Vec<ScadaPoint>,
pub pmu_frames: Vec<PmuFrame>,
}
impl TelemetryBatch {
pub fn new(source: TelemetrySource, scan_time_us: i64) -> Self {
Self {
source,
scan_time_us,
scada_points: Vec::new(),
pmu_frames: Vec::new(),
}
}
pub fn add_scada(&mut self, point: ScadaPoint) {
self.scada_points.push(point);
}
pub fn add_pmu_frame(&mut self, frame: PmuFrame) {
self.pmu_frames.push(frame);
}
pub fn to_se_measurements(&self, base_mva: f64) -> Vec<Measurement> {
let mut measurements = Vec::with_capacity(self.scada_points.len());
for point in &self.scada_points {
if !point.is_good() {
continue;
}
let bus = point.bus_or_branch;
match point.measurement_type {
ScadaMeasType::VoltageMagnitude => {
measurements.push(Measurement {
mtype: MeasurementType::VoltageMagnitude,
bus,
to_bus: None,
value: point.value,
sigma: 0.01, });
}
ScadaMeasType::ActivePower => {
let value_pu = point.value / base_mva;
measurements.push(Measurement {
mtype: MeasurementType::PowerInjection,
bus,
to_bus: None,
value: value_pu,
sigma: 0.02,
});
}
ScadaMeasType::ReactivePower => {
let value_pu = point.value / base_mva;
measurements.push(Measurement {
mtype: MeasurementType::ReactiveInjection,
bus,
to_bus: None,
value: value_pu,
sigma: 0.03, });
}
ScadaMeasType::Current => {
measurements.push(Measurement {
mtype: MeasurementType::PowerInjection,
bus,
to_bus: None,
value: point.value,
sigma: 0.02,
});
}
ScadaMeasType::Frequency
| ScadaMeasType::TapPosition
| ScadaMeasType::BreakerStatus => {
}
}
}
for frame in &self.pmu_frames {
for (ph_idx, phasor) in frame.phasors.iter().enumerate() {
if phasor.magnitude >= 0.5 {
measurements.push(Measurement {
mtype: MeasurementType::VoltageMagnitude,
bus: ph_idx,
to_bus: None,
value: phasor.magnitude,
sigma: 0.001, });
measurements.push(Measurement {
mtype: MeasurementType::PowerInjection,
bus: ph_idx,
to_bus: None,
value: phasor.angle_rad,
sigma: 0.001,
});
}
}
}
measurements
}
pub fn filter_quality(&self, max_age_us: i64) -> TelemetryBatch {
let cutoff = self.scan_time_us - max_age_us;
let scada_points = self
.scada_points
.iter()
.filter(|p| p.is_good() && p.timestamp_us >= cutoff)
.cloned()
.collect();
TelemetryBatch {
source: self.source,
scan_time_us: self.scan_time_us,
scada_points,
pmu_frames: self.pmu_frames.clone(),
}
}
pub fn is_empty(&self) -> bool {
self.scada_points.is_empty() && self.pmu_frames.is_empty()
}
pub fn len(&self) -> usize {
self.scada_points.len() + self.pmu_frames.len()
}
}
#[derive(Debug, Clone)]
pub struct TelemetryStats {
pub n_points_received: usize,
pub n_points_good: usize,
pub n_points_bad: usize,
pub n_pmu_frames: usize,
pub scan_latency_ms: f64,
pub coverage_pct: f64,
}
pub fn compute_telemetry_stats(
batch: &TelemetryBatch,
n_buses: usize,
max_age_us: i64,
) -> TelemetryStats {
let n_received = batch.scada_points.len();
let n_good = batch.scada_points.iter().filter(|p| p.is_good()).count();
let n_bad = n_received - n_good;
let cutoff = batch.scan_time_us - max_age_us;
let mut covered_buses: Vec<usize> = batch
.scada_points
.iter()
.filter(|p| p.is_good() && p.timestamp_us >= cutoff)
.map(|p| p.bus_or_branch)
.collect();
for frame in &batch.pmu_frames {
for (ph_idx, _) in frame.phasors.iter().enumerate() {
covered_buses.push(ph_idx);
}
}
covered_buses.sort_unstable();
covered_buses.dedup();
let n_covered = covered_buses.len().min(n_buses);
let scan_latency_ms = if n_received == 0 {
0.0
} else {
let total_lag_us: i64 = batch
.scada_points
.iter()
.map(|p| (batch.scan_time_us - p.timestamp_us).max(0))
.sum();
(total_lag_us as f64 / n_received as f64) / 1_000.0
};
let coverage_pct = if n_buses == 0 {
100.0
} else {
100.0 * n_covered as f64 / n_buses as f64
};
TelemetryStats {
n_points_received: n_received,
n_points_good: n_good,
n_points_bad: n_bad,
n_pmu_frames: batch.pmu_frames.len(),
scan_latency_ms,
coverage_pct,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_voltage_point(bus: usize, v_pu: f64, ts: i64, quality: u8) -> ScadaPoint {
ScadaPoint::new(
bus as u32,
ts,
ScadaMeasType::VoltageMagnitude,
bus,
v_pu,
quality,
)
}
#[test]
fn test_telemetry_to_se_measurements() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(make_voltage_point(0, 1.02, 1_000_000, 0));
batch.add_scada(ScadaPoint::new(
1,
1_000_000,
ScadaMeasType::ActivePower,
0,
50.0,
0,
));
let meas = batch.to_se_measurements(100.0);
assert_eq!(meas.len(), 2);
assert!(meas
.iter()
.any(|m| m.mtype == MeasurementType::VoltageMagnitude));
let p = meas
.iter()
.find(|m| m.mtype == MeasurementType::PowerInjection)
.expect("P meas");
assert!((p.value - 0.5).abs() < 1e-9);
}
#[test]
fn test_telemetry_quality_filter() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 2_000_000);
batch.add_scada(make_voltage_point(0, 1.0, 1_900_000, 0));
batch.add_scada(make_voltage_point(1, 1.0, 1_900_000, 1));
batch.add_scada(make_voltage_point(2, 1.0, 1_000_000, 0));
let filtered = batch.filter_quality(500_000);
assert_eq!(filtered.scada_points.len(), 1);
assert_eq!(filtered.scada_points[0].bus_or_branch, 0);
}
#[test]
fn test_compute_telemetry_stats() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 2_000_000);
batch.add_scada(make_voltage_point(0, 1.0, 1_990_000, 0));
batch.add_scada(make_voltage_point(1, 1.0, 1_990_000, 0));
batch.add_scada(make_voltage_point(2, 1.0, 1_990_000, 1)); let stats = compute_telemetry_stats(&batch, 4, 100_000);
assert_eq!(stats.n_points_received, 3);
assert_eq!(stats.n_points_good, 2);
assert_eq!(stats.n_points_bad, 1);
assert!((stats.coverage_pct - 50.0).abs() < 1e-9); }
#[test]
fn test_scada_point_is_good_quality_zero() {
let good = ScadaPoint::new(1, 1_000_000, ScadaMeasType::VoltageMagnitude, 0, 1.0, 0);
let bad = ScadaPoint::new(2, 1_000_000, ScadaMeasType::VoltageMagnitude, 0, 1.0, 7);
assert!(good.is_good());
assert!(!bad.is_good());
}
#[test]
fn test_batch_is_empty_and_len() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 0);
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
batch.add_scada(make_voltage_point(0, 1.0, 0, 0));
assert!(!batch.is_empty());
assert_eq!(batch.len(), 1);
}
#[test]
fn test_bad_quality_points_excluded_from_se_measurements() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(make_voltage_point(0, 1.01, 1_000_000, 0));
batch.add_scada(ScadaPoint::new(
99,
1_000_000,
ScadaMeasType::ActivePower,
1,
100.0,
1,
));
let meas = batch.to_se_measurements(100.0);
assert_eq!(meas.len(), 1);
assert!(
meas[0].mtype == crate::powerflow::state_estimation::MeasurementType::VoltageMagnitude
);
}
#[test]
fn test_reactive_power_normalised_to_pu() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(ScadaPoint::new(
10,
1_000_000,
ScadaMeasType::ReactivePower,
2,
30.0,
0,
));
let meas = batch.to_se_measurements(100.0);
assert_eq!(meas.len(), 1);
assert!(
(meas[0].value - 0.3).abs() < 1e-9,
"value = {:.6}",
meas[0].value
);
}
#[test]
fn test_filter_quality_keeps_only_good_and_fresh() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 3_000_000);
batch.add_scada(make_voltage_point(0, 1.0, 2_900_000, 0));
batch.add_scada(make_voltage_point(1, 1.0, 1_000_000, 0));
batch.add_scada(make_voltage_point(2, 1.0, 1_000_000, 1));
let filtered = batch.filter_quality(500_000);
assert_eq!(filtered.len(), 1, "only one fresh+good point");
}
#[test]
fn test_frequency_and_tap_position_not_in_se_measurements() {
let mut batch = TelemetryBatch::new(TelemetrySource::Scada, 1_000_000);
batch.add_scada(ScadaPoint::new(
5,
1_000_000,
ScadaMeasType::Frequency,
0,
50.1,
0,
));
batch.add_scada(ScadaPoint::new(
6,
1_000_000,
ScadaMeasType::TapPosition,
0,
1.0,
0,
));
batch.add_scada(ScadaPoint::new(
7,
1_000_000,
ScadaMeasType::BreakerStatus,
0,
1.0,
0,
));
let meas = batch.to_se_measurements(100.0);
assert!(meas.is_empty(), "expected empty, got {}", meas.len());
}
}