use std::collections::HashMap;
use crate::branch_flow::Terminal;
use crate::measurement::{AngleFrame, Measurement, MeasurementKind, Target};
use super::{PgmInput, PgmNetwork};
#[derive(Clone, Debug, PartialEq)]
pub enum MeasurementError {
UnknownObject { sensor: u64, object: u64 },
UnsupportedTerminalType { sensor: u64, terminal_type: u8 },
InvalidSigma { sensor: u64, sigma: f64 },
CurrentSensorOnLink { sensor: u64, link: u64 },
ConflictingAngleFrame { sensor: u64, object: u64 },
MixedPowerAndCurrent { sensor: u64, object: u64 },
}
impl std::fmt::Display for MeasurementError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownObject { sensor, object } => {
write!(
f,
"sensor {sensor} measures object {object}, which is not in the network"
)
}
Self::UnsupportedTerminalType {
sensor,
terminal_type,
} => {
write!(
f,
"sensor {sensor} has unsupported measured_terminal_type {terminal_type}"
)
}
Self::InvalidSigma { sensor, sigma } => {
write!(
f,
"sensor {sensor} has non-positive or missing sigma {sigma}"
)
}
Self::CurrentSensorOnLink { sensor, link } => write!(
f,
"current sensor {sensor} measures link {link}, whose admittance is a \
regularization constant rather than a physical property"
),
Self::ConflictingAngleFrame { sensor, object } => write!(
f,
"current sensor {sensor} uses a different angle frame than another sensor on the \
same terminal of object {object}"
),
Self::MixedPowerAndCurrent { sensor, object } => write!(
f,
"sensor {sensor} puts a current measurement on a terminal of object {object} that \
a power sensor already measures"
),
}
}
}
impl std::error::Error for MeasurementError {}
#[derive(Clone, Copy, Debug, Default)]
struct Merged {
weighted_sum: f64,
weight: f64,
}
impl Merged {
fn add(&mut self, value: f64, sigma: f64) {
let w = 1.0 / (sigma * sigma);
self.weighted_sum += value * w;
self.weight += w;
}
fn finish(self) -> Option<(f64, f64)> {
(self.weight > 0.0).then(|| (self.weighted_sum / self.weight, (1.0 / self.weight).sqrt()))
}
}
#[derive(Clone, Copy, Debug, Default)]
struct MergedPhasor {
weighted_sum: num_complex::Complex<f64>,
weight: f64,
}
impl MergedPhasor {
fn add(&mut self, value: num_complex::Complex<f64>, sigma: f64) {
let w = 1.0 / (sigma * sigma);
self.weighted_sum += value * w;
self.weight += w;
}
fn finish(self) -> Option<(num_complex::Complex<f64>, f64)> {
(self.weight > 0.0).then(|| (self.weighted_sum / self.weight, (1.0 / self.weight).sqrt()))
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Summed {
value: f64,
variance: f64,
any: bool,
}
impl Summed {
fn add(&mut self, value: f64, sigma: f64) {
self.value += value;
self.variance += sigma * sigma;
self.any = true;
}
fn finish(self) -> Option<(f64, f64)> {
self.any.then(|| (self.value, self.variance.sqrt()))
}
}
#[derive(Clone, Copy, Debug)]
struct ApplianceAccumulator {
bus: usize,
sign: f64,
is_shunt: bool,
p: Merged,
q: Merged,
}
#[derive(Clone, Copy, Debug, Default)]
struct BusAccumulator {
u_mag: Merged,
u_phasor: MergedPhasor,
appliance_p: Summed,
appliance_q: Summed,
shunt_p: Summed,
shunt_q: Summed,
node_p: Merged,
node_q: Merged,
}
#[derive(Default)]
struct Accumulators {
buses: HashMap<usize, BusAccumulator>,
branch_p: HashMap<(usize, Terminal), Merged>,
branch_q: HashMap<(usize, Terminal), Merged>,
appliances: HashMap<u64, ApplianceAccumulator>,
sources: HashMap<usize, (Merged, Merged)>,
currents: HashMap<(usize, Terminal), (AngleFrame, Merged, Merged)>,
}
trait Resolver {
fn resolve_terminal(&self, id: u64, terminal: Terminal) -> Option<(usize, Terminal)>;
fn source_branch(&self, id: u64) -> Option<usize>;
fn appliance_bus(&self, id: u64) -> Option<usize>;
fn node_bus(&self, id: u64) -> Option<usize>;
fn three_winding_legs(&self, id: u64) -> Option<[usize; 3]>;
}
impl Resolver for PgmNetwork {
fn resolve_terminal(&self, id: u64, terminal: Terminal) -> Option<(usize, Terminal)> {
PgmNetwork::resolve_terminal(self, id, terminal)
}
fn source_branch(&self, id: u64) -> Option<usize> {
self.source_branch_idx.get(&id).copied()
}
fn appliance_bus(&self, id: u64) -> Option<usize> {
self.appliance_bus.get(&id).copied()
}
fn node_bus(&self, id: u64) -> Option<usize> {
self.node_idx.get(&id).copied()
}
fn three_winding_legs(&self, id: u64) -> Option<[usize; 3]> {
self.three_winding_branch_idx.get(&id).copied()
}
}
struct PhaseView<'a> {
net: &'a crate::pgm::PgmNetwork3Ph,
phase: usize,
}
impl Resolver for PhaseView<'_> {
fn resolve_terminal(&self, id: u64, terminal: Terminal) -> Option<(usize, Terminal)> {
let &branch = self.net.branch_idx.get(&id)?;
match self.net.half_open_terminal.get(&id) {
Some(&live) if live == terminal => Some((3 * branch + self.phase, Terminal::From)),
Some(_) => None,
None => Some((3 * branch + self.phase, terminal)),
}
}
fn source_branch(&self, id: u64) -> Option<usize> {
self.net
.source_branch_idx
.get(&id)
.map(|&b| 3 * b + self.phase)
}
fn appliance_bus(&self, id: u64) -> Option<usize> {
self.net.appliance_bus.get(&id).map(|&k| 3 * k + self.phase)
}
fn node_bus(&self, id: u64) -> Option<usize> {
self.net.node_idx.get(&id).map(|&k| 3 * k + self.phase)
}
fn three_winding_legs(&self, id: u64) -> Option<[usize; 3]> {
self.net
.three_winding_idx
.get(&id)
.map(|legs| legs.map(|b| 3 * b + self.phase))
}
}
type PowerReading = (Option<(f64, f64)>, Option<(f64, f64)>);
fn route_power_reading(
acc: &mut Accumulators,
net: &dyn Resolver,
sensor: u64,
measured_object: u64,
terminal_type: u8,
(p, q): PowerReading,
) -> Result<(), MeasurementError> {
match terminal_type {
0 | 1 => {
let terminal = if terminal_type == 0 {
Terminal::From
} else {
Terminal::To
};
let Some(key) = net.resolve_terminal(measured_object, terminal) else {
return Ok(());
};
if let Some((v, s)) = p {
acc.branch_p.entry(key).or_default().add(v, s);
}
if let Some((v, s)) = q {
acc.branch_q.entry(key).or_default().add(v, s);
}
}
2 => {
let Some(branch) = net.source_branch(measured_object) else {
return Ok(());
};
let entry = acc.sources.entry(branch).or_default();
if let Some((v, s)) = p {
entry.0.add(v, s);
}
if let Some((v, s)) = q {
entry.1.add(v, s);
}
}
3 | 4 | 5 => {
let sign = match terminal_type {
3 | 4 => -1.0,
_ => 1.0,
};
let Some(bus) = net.appliance_bus(measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor,
object: measured_object,
});
};
let entry = acc
.appliances
.entry(measured_object)
.or_insert(ApplianceAccumulator {
bus,
sign,
is_shunt: terminal_type == 3,
p: Merged::default(),
q: Merged::default(),
});
if let Some((v, s)) = p {
entry.p.add(v, s);
}
if let Some((v, s)) = q {
entry.q.add(v, s);
}
}
6 | 7 | 8 => {
let Some(legs) = net.three_winding_legs(measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor,
object: measured_object,
});
};
let key = (legs[(terminal_type - 6) as usize], Terminal::From);
if let Some((v, s)) = p {
acc.branch_p.entry(key).or_default().add(v, s);
}
if let Some((v, s)) = q {
acc.branch_q.entry(key).or_default().add(v, s);
}
}
9 => {
let Some(bus) = net.node_bus(measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor,
object: measured_object,
});
};
let entry = acc.buses.entry(bus).or_default();
if let Some((v, s)) = p {
entry.node_p.add(v, s);
}
if let Some((v, s)) = q {
entry.node_q.add(v, s);
}
}
other => {
return Err(MeasurementError::UnsupportedTerminalType {
sensor,
terminal_type: other,
});
}
}
Ok(())
}
const SQRT_3: f64 = 1.732_050_807_568_877_2;
fn positive_sequence(magnitudes: &[f64; 3], angles: &[f64; 3]) -> num_complex::Complex<f64> {
let a = num_complex::Complex::from_polar(1.0, std::f64::consts::TAU / 3.0);
let rotate = [num_complex::Complex::new(1.0, 0.0), a, a * a];
(0..3)
.map(|p| rotate[p] * num_complex::Complex::from_polar(magnitudes[p], angles[p]))
.sum::<num_complex::Complex<f64>>()
/ 3.0
}
fn apparent_power_component_sigma(apparent: f64) -> f64 {
apparent / std::f64::consts::SQRT_2
}
fn component_sigmas(
sensor: u64,
p_sigma: f64,
q_sigma: f64,
power_sigma: f64,
s_base_va: f64,
) -> Result<(f64, f64), MeasurementError> {
if p_sigma.is_finite() && q_sigma.is_finite() {
return Ok((
checked_sigma(sensor, p_sigma)? / s_base_va,
checked_sigma(sensor, q_sigma)? / s_base_va,
));
}
let shared = apparent_power_component_sigma(checked_sigma(sensor, power_sigma)? / s_base_va);
Ok((shared, shared))
}
fn decompose_polar(
magnitude: f64,
angle: f64,
mag_sigma: f64,
angle_sigma: f64,
) -> ((f64, f64), (f64, f64)) {
let (sin, cos) = angle.sin_cos();
let (sin2, cos2) = (sin * sin, cos * cos);
let mag2 = magnitude * magnitude;
let mag_var = mag_sigma * mag_sigma;
let ang_var = angle_sigma * angle_sigma;
let re_var = mag_var * cos2
+ mag2 * ang_var * sin2
+ 0.5 * mag2 * ang_var * ang_var * cos2
+ mag_var * ang_var * sin2;
let im_var = mag_var * sin2
+ mag2 * ang_var * cos2
+ 0.5 * mag2 * ang_var * ang_var * sin2
+ mag_var * ang_var * cos2;
(
(magnitude * cos, re_var.sqrt()),
(magnitude * sin, im_var.sqrt()),
)
}
struct CurrentTerminal {
branch: usize,
terminal: Terminal,
u_rated: f64,
}
fn resolve_current_terminal(
input: &PgmInput,
net: &PgmNetwork,
sensor: u64,
object: u64,
terminal_type: u8,
) -> Result<Option<CurrentTerminal>, MeasurementError> {
if input.data.link.iter().any(|l| l.id == object) {
return Err(MeasurementError::CurrentSensorOnLink {
sensor,
link: object,
});
}
let node_of = |id: u64| net.node_idx.get(&id).map(|&b| net.buses[b].u_rated);
match terminal_type {
0 | 1 => {
let terminal = if terminal_type == 0 {
Terminal::From
} else {
Terminal::To
};
let Some((branch, terminal)) = net.resolve_terminal(object, terminal) else {
return Ok(None);
};
let node = input
.data
.line
.iter()
.find(|l| l.id == object)
.map(|l| {
if terminal_type == 0 {
l.from_node
} else {
l.to_node
}
})
.or_else(|| {
input
.data
.transformer
.iter()
.find(|t| t.id == object)
.map(|t| {
if terminal_type == 0 {
t.from_node
} else {
t.to_node
}
})
});
let Some(u_rated) = node.and_then(node_of) else {
return Err(MeasurementError::UnknownObject { sensor, object });
};
Ok(Some(CurrentTerminal {
branch,
terminal,
u_rated,
}))
}
6 | 7 | 8 => {
let Some(&legs) = net.three_winding_branch_idx.get(&object) else {
return Err(MeasurementError::UnknownObject { sensor, object });
};
let side = (terminal_type - 6) as usize;
let node = input
.data
.three_winding_transformer
.iter()
.find(|t| t.id == object)
.map(|t| [t.node_1, t.node_2, t.node_3][side]);
let Some(u_rated) = node.and_then(node_of) else {
return Err(MeasurementError::UnknownObject { sensor, object });
};
Ok(Some(CurrentTerminal {
branch: legs[side],
terminal: Terminal::From,
u_rated,
}))
}
other => Err(MeasurementError::UnsupportedTerminalType {
sensor,
terminal_type: other,
}),
}
}
fn checked_sigma(sensor: u64, sigma: f64) -> Result<f64, MeasurementError> {
if sigma.is_nan() || sigma <= 0.0 {
return Err(MeasurementError::InvalidSigma { sensor, sigma });
}
Ok(sigma)
}
pub fn measurements_from_pgm(
input: &PgmInput,
net: &PgmNetwork,
s_base_va: f64,
) -> Result<Vec<Measurement>, MeasurementError> {
let mut acc = Accumulators::default();
for s in &input.data.sym_voltage_sensor {
let Some(&bus) = net.node_idx.get(&s.measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor: s.id,
object: s.measured_object,
});
};
let u_rated = net.buses[bus].u_rated;
let bus_acc = acc.buses.entry(bus).or_default();
if !s.u_measured.is_finite() {
continue;
}
let sigma = checked_sigma(s.id, s.u_sigma)? / u_rated;
let magnitude = s.u_measured / u_rated;
if s.u_angle_measured.is_finite() {
let sigma = if s.u_angle_sigma.is_finite() {
checked_sigma(s.id, s.u_angle_sigma)?
} else {
sigma
};
bus_acc.u_phasor.add(
num_complex::Complex::from_polar(magnitude, s.u_angle_measured),
sigma,
);
} else {
bus_acc.u_mag.add(magnitude, sigma);
}
}
for s in &input.data.asym_voltage_sensor {
let Some(&bus) = net.node_idx.get(&s.measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor: s.id,
object: s.measured_object,
});
};
if !s.u_measured.iter().all(|v| v.is_finite()) {
continue;
}
let u_base = net.buses[bus].u_rated / SQRT_3;
let sigma = checked_sigma(s.id, s.u_sigma)? / u_base;
let mag = s.u_measured.map(|v| v / u_base);
let bus_acc = acc.buses.entry(bus).or_default();
if s.u_angle_measured.iter().all(|a| a.is_finite()) {
bus_acc
.u_phasor
.add(positive_sequence(&mag, &s.u_angle_measured), sigma);
} else {
bus_acc.u_mag.add(mag.iter().sum::<f64>() / 3.0, sigma);
}
}
for s in &input.data.sym_power_sensor {
if !s.p_measured.is_finite() && !s.q_measured.is_finite() {
continue;
}
let (p_sigma, q_sigma) =
component_sigmas(s.id, s.p_sigma, s.q_sigma, s.power_sigma, s_base_va)?;
let reading = (
s.p_measured
.is_finite()
.then(|| (s.p_measured / s_base_va, p_sigma)),
s.q_measured
.is_finite()
.then(|| (s.q_measured / s_base_va, q_sigma)),
);
route_power_reading(
&mut acc,
net,
s.id,
s.measured_object,
s.measured_terminal_type,
reading,
)?;
}
for s in &input.data.asym_power_sensor {
let p_any = s.p_measured.iter().any(|v| v.is_finite());
let q_any = s.q_measured.iter().any(|v| v.is_finite());
if !p_any && !q_any {
continue;
}
let s_base_1ph = s_base_va / 3.0;
let mean = |vs: &[f64; 3]| vs.iter().sum::<f64>() / 3.0 / s_base_1ph;
let rms =
|vs: &[f64; 3]| (vs.iter().map(|v| (v / s_base_1ph).powi(2)).sum::<f64>() / 3.0).sqrt();
let per_phase_finite = |vs: &[f64; 3]| vs.iter().all(|v| v.is_finite());
let (p_sigma, q_sigma) = if per_phase_finite(&s.p_sigma) && per_phase_finite(&s.q_sigma) {
(
checked_sigma(s.id, rms(&s.p_sigma))?,
checked_sigma(s.id, rms(&s.q_sigma))?,
)
} else {
let shared =
apparent_power_component_sigma(checked_sigma(s.id, s.power_sigma)? / s_base_1ph);
(shared, shared)
};
let reading = (
p_any.then(|| (mean(&s.p_measured), p_sigma)),
q_any.then(|| (mean(&s.q_measured), q_sigma)),
);
route_power_reading(
&mut acc,
net,
s.id,
s.measured_object,
s.measured_terminal_type,
reading,
)?;
}
let sym_currents = input.data.sym_current_sensor.iter().map(|s| {
(
s.id,
s.measured_object,
s.measured_terminal_type,
s.angle_measurement_type,
s.i_measured,
s.i_angle_measured,
s.i_sigma,
s.i_angle_sigma,
)
});
let asym_currents = input.data.asym_current_sensor.iter().map(|s| {
let (magnitude, angle) = if s.i_angle_measured.iter().all(|a| a.is_finite()) {
let seq = positive_sequence(&s.i_measured, &s.i_angle_measured);
(seq.norm(), seq.arg())
} else {
(s.i_measured.iter().sum::<f64>() / 3.0, f64::NAN)
};
(
s.id,
s.measured_object,
s.measured_terminal_type,
s.angle_measurement_type,
magnitude,
angle,
s.i_sigma,
s.i_angle_sigma,
)
});
for (id, object, terminal_type, frame_code, i_measured, i_angle, i_sigma, i_angle_sigma) in
sym_currents.chain(asym_currents)
{
if !i_measured.is_finite() || !i_angle.is_finite() {
continue;
}
let Some(t) = resolve_current_terminal(input, net, id, object, terminal_type)? else {
continue;
};
let frame = if frame_code == 0 {
AngleFrame::Local
} else {
AngleFrame::Global
};
let i_base = s_base_va / (SQRT_3 * t.u_rated);
let magnitude = i_measured / i_base;
let mag_sigma = checked_sigma(id, i_sigma)? / i_base;
let angle_sigma = checked_sigma(id, i_angle_sigma)?;
let ((re, re_sigma), (im, im_sigma)) =
decompose_polar(magnitude, i_angle, mag_sigma, angle_sigma);
let key = (t.branch, t.terminal);
if acc.branch_p.contains_key(&key) || acc.branch_q.contains_key(&key) {
return Err(MeasurementError::MixedPowerAndCurrent { sensor: id, object });
}
let entry =
acc.currents
.entry(key)
.or_insert((frame, Merged::default(), Merged::default()));
if entry.0 != frame {
return Err(MeasurementError::ConflictingAngleFrame { sensor: id, object });
}
entry.1.add(re, re_sigma);
entry.2.add(im, im_sigma);
}
Ok(flatten(acc))
}
fn flatten(mut acc: Accumulators) -> Vec<Measurement> {
let mut appliance_ids: Vec<u64> = acc.appliances.keys().copied().collect();
appliance_ids.sort_unstable();
for id in appliance_ids {
let app = acc.appliances[&id];
let bus = acc.buses.entry(app.bus).or_default();
let (p_acc, q_acc) = if app.is_shunt {
(&mut bus.shunt_p, &mut bus.shunt_q)
} else {
(&mut bus.appliance_p, &mut bus.appliance_q)
};
if let Some((value, sigma)) = app.p.finish() {
p_acc.add(app.sign * value, sigma);
}
if let Some((value, sigma)) = app.q.finish() {
q_acc.add(app.sign * value, sigma);
}
}
let mut out = Vec::new();
let mut bus_ids: Vec<usize> = acc.buses.keys().copied().collect();
bus_ids.sort_unstable();
for bus in bus_ids {
let b = acc.buses[&bus];
let target = Target::Bus(bus);
let mut u_mag = b.u_mag;
let phasor = b.u_phasor.finish();
if let Some((value, sigma)) = phasor {
u_mag.add(value.norm(), sigma);
}
if let Some((value, sigma)) = u_mag.finish() {
out.push(Measurement {
kind: MeasurementKind::VoltageMagnitude,
target,
value,
sigma,
});
}
if let Some((value, sigma)) = phasor {
out.push(Measurement {
kind: MeasurementKind::VoltageAngle,
target,
value: value.arg(),
sigma,
});
}
for (kind, appliance, shunt, node) in [
(
MeasurementKind::ActivePower,
b.appliance_p.finish(),
b.shunt_p.finish(),
b.node_p.finish(),
),
(
MeasurementKind::ReactivePower,
b.appliance_q.finish(),
b.shunt_q.finish(),
b.node_q.finish(),
),
] {
if let Some((value, sigma)) = appliance {
out.push(Measurement {
kind,
target,
value,
sigma,
});
}
if let Some((value, sigma)) = shunt {
out.push(Measurement {
kind,
target: Target::ShuntInjection { bus },
value,
sigma,
});
}
if let Some((value, sigma)) = node {
out.push(Measurement {
kind,
target: Target::NodeInjection(bus),
value,
sigma,
});
}
}
}
let mut source_branches: Vec<usize> = acc.sources.keys().copied().collect();
source_branches.sort_unstable();
for branch in source_branches {
let (p, q) = acc.sources[&branch];
let target = Target::SourceInjection { branch };
if let Some((value, sigma)) = p.finish() {
out.push(Measurement {
kind: MeasurementKind::ActivePower,
target,
value,
sigma,
});
}
if let Some((value, sigma)) = q.finish() {
out.push(Measurement {
kind: MeasurementKind::ReactivePower,
target,
value,
sigma,
});
}
}
let mut branch_keys: Vec<(usize, Terminal)> = acc
.branch_p
.keys()
.chain(acc.branch_q.keys())
.copied()
.collect();
branch_keys.sort_unstable_by_key(|&(b, t)| (b, t == Terminal::To));
branch_keys.dedup();
for key in branch_keys {
let (branch, terminal) = key;
let target = Target::BranchTerminal { branch, terminal };
if let Some((value, sigma)) = acc.branch_p.get(&key).copied().unwrap_or_default().finish() {
out.push(Measurement {
kind: MeasurementKind::ActivePower,
target,
value,
sigma,
});
}
if let Some((value, sigma)) = acc.branch_q.get(&key).copied().unwrap_or_default().finish() {
out.push(Measurement {
kind: MeasurementKind::ReactivePower,
target,
value,
sigma,
});
}
}
let mut current_keys: Vec<(usize, Terminal)> = acc.currents.keys().copied().collect();
current_keys.sort_unstable_by_key(|&(b, t)| (b, t == Terminal::To));
for key in current_keys {
let (branch, terminal) = key;
let (frame, re, im) = acc.currents[&key];
let target = Target::BranchTerminalCurrent {
branch,
terminal,
frame,
};
if let Some((value, sigma)) = re.finish() {
out.push(Measurement {
kind: MeasurementKind::CurrentReal,
target,
value,
sigma,
});
}
if let Some((value, sigma)) = im.finish() {
out.push(Measurement {
kind: MeasurementKind::CurrentImag,
target,
value,
sigma,
});
}
}
out
}
const PHASE_ANGLE: [f64; 3] = [
0.0,
-std::f64::consts::TAU / 3.0,
std::f64::consts::TAU / 3.0,
];
pub fn measurements_from_pgm_3ph(
input: &PgmInput,
net: &crate::pgm::PgmNetwork3Ph,
s_base_va: f64,
u_rated: &dyn Fn(usize) -> f64,
) -> Result<Vec<Measurement>, MeasurementError> {
let mut acc = Accumulators::default();
for phase in 0..3 {
let view = PhaseView { net, phase };
for s in &input.data.sym_voltage_sensor {
let Some(bus) = view.node_bus(s.measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor: s.id,
object: s.measured_object,
});
};
if !s.u_measured.is_finite() {
continue;
}
let base = u_rated(bus);
let sigma = checked_sigma(s.id, s.u_sigma)? / base;
let magnitude = s.u_measured / base;
let bus_acc = acc.buses.entry(bus).or_default();
if s.u_angle_measured.is_finite() {
let sigma = if s.u_angle_sigma.is_finite() {
checked_sigma(s.id, s.u_angle_sigma)?
} else {
sigma
};
bus_acc.u_phasor.add(
num_complex::Complex::from_polar(
magnitude,
s.u_angle_measured + PHASE_ANGLE[phase],
),
sigma,
);
} else {
bus_acc.u_mag.add(magnitude, sigma);
}
}
for s in &input.data.sym_power_sensor {
if !s.p_measured.is_finite() && !s.q_measured.is_finite() {
continue;
}
let (p_sigma, q_sigma) =
component_sigmas(s.id, s.p_sigma, s.q_sigma, s.power_sigma, s_base_va)?;
let reading = (
s.p_measured
.is_finite()
.then(|| (s.p_measured / s_base_va, p_sigma)),
s.q_measured
.is_finite()
.then(|| (s.q_measured / s_base_va, q_sigma)),
);
route_power_reading(
&mut acc,
&view,
s.id,
s.measured_object,
s.measured_terminal_type,
reading,
)?;
}
for s in &input.data.asym_voltage_sensor {
let Some(bus) = view.node_bus(s.measured_object) else {
return Err(MeasurementError::UnknownObject {
sensor: s.id,
object: s.measured_object,
});
};
if !s.u_measured[phase].is_finite() {
continue;
}
let base = u_rated(bus) / SQRT_3;
let sigma = checked_sigma(s.id, s.u_sigma)? / base;
let magnitude = s.u_measured[phase] / base;
let bus_acc = acc.buses.entry(bus).or_default();
if s.u_angle_measured[phase].is_finite() {
bus_acc.u_phasor.add(
num_complex::Complex::from_polar(magnitude, s.u_angle_measured[phase]),
sigma,
);
} else {
bus_acc.u_mag.add(magnitude, sigma);
}
}
for s in &input.data.asym_power_sensor {
let p_finite = s.p_measured[phase].is_finite();
let q_finite = s.q_measured[phase].is_finite();
if !p_finite && !q_finite {
continue;
}
let s_base_1ph = s_base_va / 3.0;
let (p_sigma, q_sigma) = if s.p_sigma[phase].is_finite() && s.q_sigma[phase].is_finite()
{
(
checked_sigma(s.id, s.p_sigma[phase])? / s_base_1ph,
checked_sigma(s.id, s.q_sigma[phase])? / s_base_1ph,
)
} else {
let shared = apparent_power_component_sigma(
checked_sigma(s.id, s.power_sigma)? / s_base_1ph,
);
(shared, shared)
};
let reading = (
p_finite.then(|| (s.p_measured[phase] / s_base_1ph, p_sigma)),
q_finite.then(|| (s.q_measured[phase] / s_base_1ph, q_sigma)),
);
route_power_reading(
&mut acc,
&view,
s.id,
s.measured_object,
s.measured_terminal_type,
reading,
)?;
}
for (id, object, terminal_type, frame_code, i_measured, i_angle, i_sigma, i_angle_sigma) in
input
.data
.sym_current_sensor
.iter()
.map(|s| {
(
s.id,
s.measured_object,
s.measured_terminal_type,
s.angle_measurement_type,
s.i_measured,
s.i_angle_measured + PHASE_ANGLE[phase],
s.i_sigma,
s.i_angle_sigma,
)
})
.chain(input.data.asym_current_sensor.iter().map(|s| {
(
s.id,
s.measured_object,
s.measured_terminal_type,
s.angle_measurement_type,
s.i_measured[phase],
s.i_angle_measured[phase],
s.i_sigma,
s.i_angle_sigma,
)
}))
{
if !i_measured.is_finite() || !i_angle.is_finite() {
continue;
}
if input.data.link.iter().any(|l| l.id == object) {
return Err(MeasurementError::CurrentSensorOnLink {
sensor: id,
link: object,
});
}
if !matches!(terminal_type, 0 | 1) {
return Err(MeasurementError::UnsupportedTerminalType {
sensor: id,
terminal_type,
});
}
let terminal = if terminal_type == 0 {
Terminal::From
} else {
Terminal::To
};
let Some(key) = view.resolve_terminal(object, terminal) else {
continue;
};
let near = if terminal_type == 0 {
input
.data
.line
.iter()
.find(|l| l.id == object)
.map(|l| l.from_node)
.or_else(|| {
input
.data
.transformer
.iter()
.find(|t| t.id == object)
.map(|t| t.from_node)
})
} else {
input
.data
.line
.iter()
.find(|l| l.id == object)
.map(|l| l.to_node)
.or_else(|| {
input
.data
.transformer
.iter()
.find(|t| t.id == object)
.map(|t| t.to_node)
})
};
let Some(near_bus) = near.and_then(|id| view.node_bus(id)) else {
return Err(MeasurementError::UnknownObject { sensor: id, object });
};
let i_base = s_base_va / (SQRT_3 * u_rated(near_bus));
let ((re, re_sigma), (im, im_sigma)) = decompose_polar(
i_measured / i_base,
i_angle,
checked_sigma(id, i_sigma)? / i_base,
checked_sigma(id, i_angle_sigma)?,
);
let frame = if frame_code == 0 {
AngleFrame::Local
} else {
AngleFrame::Global
};
if acc.branch_p.contains_key(&key) || acc.branch_q.contains_key(&key) {
return Err(MeasurementError::MixedPowerAndCurrent { sensor: id, object });
}
let entry =
acc.currents
.entry(key)
.or_insert((frame, Merged::default(), Merged::default()));
if entry.0 != frame {
return Err(MeasurementError::ConflictingAngleFrame { sensor: id, object });
}
entry.1.add(re, re_sigma);
entry.2.add(im, im_sigma);
}
}
Ok(flatten(acc))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_sensors_merge_to_mean_with_tighter_sigma() {
let mut m = Merged::default();
m.add(1.0, 0.1);
m.add(2.0, 0.1);
let (value, sigma) = m.finish().unwrap();
assert!((value - 1.5).abs() < 1e-12);
assert!(
(sigma - 0.1 / 2.0_f64.sqrt()).abs() < 1e-12,
"sigma={sigma}"
);
}
#[test]
fn merge_weights_by_inverse_variance() {
let mut m = Merged::default();
m.add(1.0, 0.1);
m.add(5.0, 1.0);
let (value, sigma) = m.finish().unwrap();
assert!(
(value - (100.0 + 5.0) / 101.0).abs() < 1e-12,
"value={value}"
);
assert!(sigma < 0.1, "merging must not lose certainty: {sigma}");
}
#[test]
fn summed_appliances_add_variances() {
let mut s = Summed::default();
s.add(1.0, 0.3);
s.add(2.0, 0.4);
let (value, sigma) = s.finish().unwrap();
assert!((value - 3.0).abs() < 1e-12);
assert!((sigma - 0.5).abs() < 1e-12, "sigma={sigma}");
assert!(sigma > 0.4, "summing must not gain certainty");
}
#[test]
fn empty_accumulators_yield_nothing() {
assert_eq!(Merged::default().finish(), None);
assert_eq!(Summed::default().finish(), None);
}
#[test]
fn weight_is_inverse_variance() {
let m = Measurement {
kind: MeasurementKind::ActivePower,
target: Target::Bus(0),
value: 0.5,
sigma: 0.25,
};
assert!((m.weight() - 16.0).abs() < 1e-12);
}
fn network_with_sensors(sensors: &str) -> (PgmInput, PgmNetwork) {
let json = format!(
r#"{{"version":"1.0","type":"input","is_batch":false,"attributes":{{}},"data":{{
"node":[{{"id":1,"u_rated":10000.0}},{{"id":2,"u_rated":10000.0}}],
"line":[{{"id":3,"from_node":1,"to_node":2,"from_status":1,"to_status":1,
"r1":0.1,"x1":0.2,"c1":0.0,"tan1":0.0,
"r0":0.1,"x0":0.2,"c0":0.0}}],
"source":[{{"id":4,"node":1,"status":1,"u_ref":1.0,"sk":1e10,"rx_ratio":0.1}}],
"sym_load":[{{"id":5,"node":2,"status":1,"type":0,
"p_specified":1000.0,"q_specified":0.0}}],
{sensors}
}}}}"#
);
let input: PgmInput = serde_json::from_str(&json).expect("test fixture parses");
let net = crate::pgm::pgm_to_network(
serde_json::from_str(&json).expect("test fixture parses"),
1e6,
50.0,
);
(input, net)
}
#[test]
fn phasor_sensors_merge_as_vectors_not_as_magnitudes() {
let (input, net) = network_with_sensors(
r#""sym_voltage_sensor":[
{"id":9,"measured_object":1,"u_measured":11000.0,"u_sigma":1000.0,
"u_angle_measured":-0.1},
{"id":10,"measured_object":1,"u_measured":9000.0,"u_sigma":2000.0,
"u_angle_measured":0.1}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let mag = find(&ms, MeasurementKind::VoltageMagnitude, Target::Bus(0));
let ang = find(&ms, MeasurementKind::VoltageAngle, Target::Bus(0));
assert!(
(mag.value - 1.0570170726436283).abs() < 1e-12,
"magnitude={}",
mag.value
);
assert!(
(ang.value + 0.0661620368126038).abs() < 1e-12,
"angle={}",
ang.value
);
assert!(
(mag.value - 1.06).abs() > 1e-4,
"merging the magnitudes alone would give exactly 1.06 — the point is that it does not"
);
assert!(
(mag.sigma - 125.0f64.sqrt().recip()).abs() < 1e-12,
"sigma={}",
mag.sigma
);
assert!((ang.sigma - 125.0f64.sqrt().recip()).abs() < 1e-12);
}
#[test]
fn magnitude_only_sensors_produce_no_angle_row() {
let (input, net) = network_with_sensors(
r#""sym_voltage_sensor":[
{"id":9,"measured_object":1,"u_measured":11000.0,"u_sigma":1000.0},
{"id":10,"measured_object":1,"u_measured":9000.0,"u_sigma":2000.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let mag = find(&ms, MeasurementKind::VoltageMagnitude, Target::Bus(0));
assert!((mag.value - 1.06).abs() < 1e-12, "magnitude={}", mag.value);
assert!(
!ms.iter().any(|m| m.kind == MeasurementKind::VoltageAngle),
"no sensor supplied an angle, so no angle row should exist"
);
}
#[test]
fn a_current_sensor_converts_to_decomposed_per_unit() {
let (input, net) = network_with_sensors(
r#""sym_current_sensor":[{"id":9,"measured_object":3,"measured_terminal_type":1,
"angle_measurement_type":1,"i_measured":10.0,"i_angle_measured":0.5,
"i_sigma":5.0,"i_angle_sigma":0.1}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).expect("measurements");
let target = Target::BranchTerminalCurrent {
branch: 0,
terminal: Terminal::To,
frame: AngleFrame::Global,
};
let re = find(&ms, MeasurementKind::CurrentReal, target);
let im = find(&ms, MeasurementKind::CurrentImag, target);
let i_pu = 10.0 * SQRT_3 * 10000.0 / 1e6;
assert!(
(re.value - i_pu * 0.5f64.cos()).abs() < 1e-12,
"Re={}",
re.value
);
assert!(
(im.value - i_pu * 0.5f64.sin()).abs() < 1e-12,
"Im={}",
im.value
);
let sigma = 5.0 * SQRT_3 * 10000.0 / 1e6;
let (sin, cos) = 0.5f64.sin_cos();
let (v, a) = (sigma * sigma, 0.01);
let want_re = (v * cos * cos
+ i_pu * i_pu * a * sin * sin
+ 0.5 * i_pu * i_pu * a * a * cos * cos
+ v * a * sin * sin)
.sqrt();
assert!(
(re.sigma - want_re).abs() < 1e-14,
"σ_Re={} want {want_re}",
re.sigma
);
}
#[test]
fn a_power_and_a_current_sensor_may_not_share_a_terminal() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":8,"measured_object":3,"measured_terminal_type":1,
"p_measured":1000.0,"q_measured":0.0,"power_sigma":10.0}],
"sym_current_sensor":[{"id":9,"measured_object":3,"measured_terminal_type":1,
"angle_measurement_type":1,"i_measured":10.0,"i_angle_measured":0.5,
"i_sigma":5.0,"i_angle_sigma":0.1}]"#,
);
assert_eq!(
measurements_from_pgm(&input, &net, 1e6),
Err(MeasurementError::MixedPowerAndCurrent {
sensor: 9,
object: 3
})
);
}
#[test]
fn two_current_sensors_on_one_terminal_must_share_a_frame() {
let (input, net) = network_with_sensors(
r#""sym_current_sensor":[
{"id":9,"measured_object":3,"measured_terminal_type":1,"angle_measurement_type":1,
"i_measured":10.0,"i_angle_measured":0.5,"i_sigma":5.0,"i_angle_sigma":0.1},
{"id":10,"measured_object":3,"measured_terminal_type":1,"angle_measurement_type":0,
"i_measured":10.0,"i_angle_measured":0.5,"i_sigma":5.0,"i_angle_sigma":0.1}]"#,
);
assert_eq!(
measurements_from_pgm(&input, &net, 1e6),
Err(MeasurementError::ConflictingAngleFrame {
sensor: 10,
object: 3
})
);
}
#[test]
fn a_current_sensor_off_a_branch_is_rejected() {
let (input, net) = network_with_sensors(
r#""sym_current_sensor":[{"id":9,"measured_object":5,"measured_terminal_type":4,
"angle_measurement_type":1,"i_measured":10.0,"i_angle_measured":0.5,
"i_sigma":5.0,"i_angle_sigma":0.1}]"#,
);
assert_eq!(
measurements_from_pgm(&input, &net, 1e6),
Err(MeasurementError::UnsupportedTerminalType {
sensor: 9,
terminal_type: 4
})
);
}
#[test]
fn three_winding_sides_map_to_their_legs_from_terminal() {
let json = r#"{"version":"1.0","type":"input","is_batch":false,"attributes":{},"data":{
"node":[{"id":1,"u_rated":138000.0},{"id":2,"u_rated":69000.0},{"id":3,"u_rated":13800.0}],
"three_winding_transformer":[{"id":4,"node_1":1,"node_2":2,"node_3":3,
"status_1":1,"status_2":1,"status_3":1,
"u1":138000.0,"u2":69000.0,"u3":13800.0,
"sn_1":60000000.0,"sn_2":50000000.0,"sn_3":10000000.0,
"uk_12":0.09,"uk_13":0.03,"uk_23":0.06,
"pk_12":50000.0,"pk_13":5000.0,"pk_23":10000.0,
"i0":0.1,"p0":50000.0,"winding_1":1,"winding_2":2,"winding_3":2,
"clock_12":11,"clock_13":11,"tap_side":2,"tap_pos":0,
"tap_min":-8,"tap_max":10,"tap_nom":0,"tap_size":1380.0}],
"source":[{"id":7,"node":1,"status":1,"u_ref":1.0,"sk":1e12,"rx_ratio":0.1}],
"sym_power_sensor":[
{"id":61,"measured_object":4,"measured_terminal_type":6,
"p_measured":1000000.0,"q_measured":0.0,"power_sigma":100000.0},
{"id":62,"measured_object":4,"measured_terminal_type":7,
"p_measured":-600000.0,"q_measured":0.0,"power_sigma":100000.0},
{"id":63,"measured_object":4,"measured_terminal_type":8,
"p_measured":-400000.0,"q_measured":0.0,"power_sigma":100000.0}]
}}"#;
let input: PgmInput = serde_json::from_str(json).expect("fixture parses");
let net = crate::pgm::pgm_to_network(
serde_json::from_str(json).expect("fixture parses"),
1e6,
50.0,
);
let legs = net.three_winding_branch_idx[&4];
let ms = measurements_from_pgm(&input, &net, 1e6).expect("measurements");
for (side, expected_p) in [(0usize, 1.0), (1, -0.6), (2, -0.4)] {
let m = find(
&ms,
MeasurementKind::ActivePower,
Target::BranchTerminal {
branch: legs[side],
terminal: Terminal::From,
},
);
assert!(
(m.value - expected_p).abs() < 1e-12,
"side {side} landed on leg {} with value {}",
legs[side],
m.value
);
}
}
#[test]
fn an_unknown_terminal_type_is_still_rejected() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":9,"measured_object":3,"measured_terminal_type":11,
"p_measured":1.0,"q_measured":0.0,"power_sigma":1.0}]"#,
);
assert_eq!(
measurements_from_pgm(&input, &net, 1e6),
Err(MeasurementError::UnsupportedTerminalType {
sensor: 9,
terminal_type: 11
})
);
}
fn find(ms: &[Measurement], kind: MeasurementKind, target: Target) -> Measurement {
*ms.iter()
.find(|m| m.kind == kind && m.target == target)
.unwrap_or_else(|| panic!("no {kind:?} measurement on {target:?} in {ms:#?}"))
}
#[test]
fn voltage_sensor_converts_to_per_unit() {
let (input, net) = network_with_sensors(
r#""sym_voltage_sensor":[{"id":10,"measured_object":2,
"u_measured":10200.0,"u_sigma":100.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let m = find(&ms, MeasurementKind::VoltageMagnitude, Target::Bus(1));
assert!((m.value - 1.02).abs() < 1e-12, "value={}", m.value);
assert!((m.sigma - 0.01).abs() < 1e-12, "sigma={}", m.sigma);
}
#[test]
fn load_sensor_becomes_negative_injection() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":11,"measured_object":5,"measured_terminal_type":4,
"p_measured":2000.0,"q_measured":500.0,"power_sigma":10.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let p = find(&ms, MeasurementKind::ActivePower, Target::Bus(1));
let q = find(&ms, MeasurementKind::ReactivePower, Target::Bus(1));
assert!((p.value + 0.002).abs() < 1e-12, "p={}", p.value);
assert!((q.value + 0.0005).abs() < 1e-12, "q={}", q.value);
assert!(
(p.sigma - 1e-5 / std::f64::consts::SQRT_2).abs() < 1e-15,
"sigma={}",
p.sigma
);
}
#[test]
fn per_component_sigmas_are_not_rescaled() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":11,"measured_object":5,"measured_terminal_type":4,
"p_measured":2000.0,"q_measured":500.0,
"p_sigma":10.0,"q_sigma":20.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let p = find(&ms, MeasurementKind::ActivePower, Target::Bus(1));
let q = find(&ms, MeasurementKind::ReactivePower, Target::Bus(1));
assert!((p.sigma - 1e-5).abs() < 1e-15, "p sigma={}", p.sigma);
assert!((q.sigma - 2e-5).abs() < 1e-15, "q sigma={}", q.sigma);
}
#[test]
fn source_sensor_targets_the_source_branch_not_the_bus() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":12,"measured_object":4,"measured_terminal_type":2,
"p_measured":2000.0,"q_measured":0.0,"power_sigma":10.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let branch = net.source_branch_idx[&4];
let p = find(
&ms,
MeasurementKind::ActivePower,
Target::SourceInjection { branch },
);
assert!((p.value - 0.002).abs() < 1e-12, "p={}", p.value);
assert!(
!ms.iter().any(|m| m.target == Target::Bus(0)),
"a source sensor must not produce a bus-injection row: {ms:#?}"
);
}
#[test]
fn branch_sensor_maps_to_terminal_without_sign_change() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[{"id":13,"measured_object":3,"measured_terminal_type":1,
"p_measured":-1500.0,"q_measured":0.0,"power_sigma":10.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let branch = net.branch_idx[&3];
let target = Target::BranchTerminal {
branch,
terminal: Terminal::To,
};
let p = find(&ms, MeasurementKind::ActivePower, target);
assert!((p.value + 0.0015).abs() < 1e-12, "p={}", p.value);
}
#[test]
fn duplicate_appliance_sensors_merge_rather_than_double() {
let (input, net) = network_with_sensors(
r#""sym_power_sensor":[
{"id":14,"measured_object":5,"measured_terminal_type":4,
"p_measured":1000.0,"q_measured":0.0,"power_sigma":10.0},
{"id":15,"measured_object":5,"measured_terminal_type":4,
"p_measured":1000.0,"q_measured":0.0,"power_sigma":10.0}]"#,
);
let ms = measurements_from_pgm(&input, &net, 1e6).unwrap();
let p = find(&ms, MeasurementKind::ActivePower, Target::Bus(1));
assert!((p.value + 0.001).abs() < 1e-12, "p={}", p.value);
assert!(p.sigma < 1e-5, "merged sigma should tighten: {}", p.sigma);
}
#[test]
fn unknown_measured_object_is_an_error() {
let (input, net) = network_with_sensors(
r#""sym_voltage_sensor":[{"id":16,"measured_object":999,
"u_measured":10000.0,"u_sigma":100.0}]"#,
);
assert_eq!(
measurements_from_pgm(&input, &net, 1e6),
Err(MeasurementError::UnknownObject {
sensor: 16,
object: 999
})
);
}
#[test]
fn zero_sigma_is_rejected() {
assert_eq!(
checked_sigma(7, 0.0),
Err(MeasurementError::InvalidSigma {
sensor: 7,
sigma: 0.0
})
);
assert!(checked_sigma(7, f64::NAN).is_err());
assert!(checked_sigma(7, -1.0).is_err());
assert_eq!(checked_sigma(7, 2.0), Ok(2.0));
}
}