use crate::network::{YBusSparse, power_injections};
use crate::types::{Bus, RegulatingMachine};
#[derive(Clone, Debug, PartialEq)]
pub struct BusDispatch {
pub bus: usize,
pub required: f64,
pub attributed: f64,
pub unattributed: f64,
pub basis: KeyBasis,
pub machines: Vec<MachineDispatch>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MachineDispatch {
pub id: String,
pub at_bus: usize,
pub controls_bus: usize,
pub q: f64,
pub q_min: f64,
pub q_max: f64,
pub at_limit: bool,
pub share: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum KeyBasis {
Explicit,
Capability,
Uniform,
}
const MIN_PLAUSIBLE_RANGE_PU: f64 = 0.01;
const MAX_PLAUSIBLE_RANGE_PU: f64 = 100.0;
pub fn reactive_keys(machines: &[&RegulatingMachine]) -> (Vec<f64>, KeyBasis) {
let uniform = |n: usize| (vec![1.0 / n as f64; n], KeyBasis::Uniform);
if machines.is_empty() {
return (Vec::new(), KeyBasis::Uniform);
}
let normalize = |raw: Vec<f64>, basis| {
let total: f64 = raw.iter().sum();
if total > 0.0 && total.is_finite() {
Some((
raw.into_iter().map(|k| k / total).collect::<Vec<_>>(),
basis,
))
} else {
None
}
};
if machines
.iter()
.all(|m| m.key.is_some_and(|k| k.is_finite() && k >= 0.0))
&& let Some(out) = normalize(
machines.iter().map(|m| m.key.unwrap()).collect(),
KeyBasis::Explicit,
)
{
return out;
}
let ranges: Vec<f64> = machines.iter().map(|m| m.q_max - m.q_min).collect();
if ranges
.iter()
.all(|r| (MIN_PLAUSIBLE_RANGE_PU..=MAX_PLAUSIBLE_RANGE_PU).contains(r))
&& let Some(out) = normalize(ranges, KeyBasis::Capability)
{
return out;
}
uniform(machines.len())
}
fn split(total: f64, machines: &[&RegulatingMachine], keys: &[f64]) -> (Vec<f64>, Vec<bool>) {
let n = machines.len();
let mut q = vec![0.0; n];
let mut pinned = vec![false; n];
for _ in 0..=n {
let free_key: f64 = (0..n).filter(|&i| !pinned[i]).map(|i| keys[i]).sum();
let pinned_q: f64 = (0..n).filter(|&i| pinned[i]).map(|i| q[i]).sum();
let remaining = total - pinned_q;
for i in 0..n {
if !pinned[i] {
q[i] = if free_key > 0.0 {
remaining * keys[i] / free_key
} else {
0.0
};
}
}
let mut moved = false;
for i in 0..n {
if pinned[i] {
continue;
}
if q[i] > machines[i].q_max {
q[i] = machines[i].q_max;
pinned[i] = true;
moved = true;
} else if q[i] < machines[i].q_min {
q[i] = machines[i].q_min;
pinned[i] = true;
moved = true;
}
}
if !moved {
break;
}
}
(q, pinned)
}
pub fn allocate(
machines: &[RegulatingMachine],
nonregulating_q: &[f64],
buses: &[Bus],
ybus: &YBusSparse,
) -> Vec<BusDispatch> {
let (_, q_calc) = power_injections(buses, ybus);
let mut order: Vec<usize> = (0..machines.len()).collect();
order.sort_by_key(|&i| machines[i].controls_bus);
let mut out: Vec<BusDispatch> = Vec::new();
let mut start = 0;
while start < order.len() {
let bus = machines[order[start]].controls_bus;
let mut end = start;
while end < order.len() && machines[order[end]].controls_bus == bus {
end += 1;
}
let group: Vec<&RegulatingMachine> =
order[start..end].iter().map(|&i| &machines[i]).collect();
let required = q_calc[bus] - nonregulating_q.get(bus).copied().unwrap_or(0.0);
let (keys, basis) = reactive_keys(&group);
let (shares, pinned) = split(required, &group, &keys);
let attributed: f64 = shares.iter().sum();
out.push(BusDispatch {
bus,
required,
attributed,
unattributed: required - attributed,
basis,
machines: group
.iter()
.enumerate()
.map(|(k, m)| MachineDispatch {
id: m.id.clone(),
at_bus: m.at_bus,
controls_bus: m.controls_bus,
q: shares[k],
q_min: m.q_min,
q_max: m.q_max,
at_limit: pinned[k],
share: keys[k],
})
.collect(),
});
start = end;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn machine(id: &str, q_min: f64, q_max: f64, key: Option<f64>) -> RegulatingMachine {
RegulatingMachine {
id: id.to_string(),
at_bus: 0,
controls_bus: 0,
target_pu: 1.0,
q_min,
q_max,
q_scheduled: 0.0,
key,
}
}
#[test]
fn explicit_keys_are_used_and_normalized() {
let ms = [
machine("a", -1.0, 1.0, Some(3.0)),
machine("b", -1.0, 1.0, Some(1.0)),
];
let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
assert_eq!(basis, KeyBasis::Explicit);
assert!(
(keys[0] - 0.75).abs() < 1e-12 && (keys[1] - 0.25).abs() < 1e-12,
"{keys:?}"
);
}
#[test]
fn one_missing_key_falls_back_wholesale() {
let ms = [
machine("a", -1.0, 1.0, Some(3.0)),
machine("b", -2.0, 2.0, None),
];
let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
assert_eq!(
basis,
KeyBasis::Capability,
"the stated key must not survive alone"
);
assert!((keys[0] - 1.0 / 3.0).abs() < 1e-12, "{keys:?}");
}
#[test]
fn capability_keys_are_proportional_to_range() {
let ms = [machine("a", -0.3, 0.3, None), machine("b", -0.1, 0.1, None)];
let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
assert_eq!(basis, KeyBasis::Capability);
assert!((keys[0] - 0.75).abs() < 1e-12, "{keys:?}");
}
#[test]
fn implausible_ranges_fall_back_to_uniform() {
for (lo, hi) in [
(f64::NEG_INFINITY, f64::INFINITY),
(0.0, 0.0), (-1e6, 1e6), (-0.001, 0.001), ] {
let ms = [machine("a", lo, hi, None), machine("b", -0.2, 0.2, None)];
let (keys, basis) = reactive_keys(&ms.iter().collect::<Vec<_>>());
assert_eq!(
basis,
KeyBasis::Uniform,
"range ({lo}, {hi}) should be rejected"
);
assert!((keys[0] - 0.5).abs() < 1e-12);
}
}
#[test]
fn a_split_sums_to_the_total_and_respects_every_limit() {
let ms = [machine("a", -0.3, 0.3, None), machine("b", -0.1, 0.1, None)];
let refs: Vec<&RegulatingMachine> = ms.iter().collect();
let (keys, _) = reactive_keys(&refs);
let (q, pinned) = split(0.2, &refs, &keys);
assert!((q.iter().sum::<f64>() - 0.2).abs() < 1e-12);
assert!(pinned.iter().all(|p| !p));
assert!(
(q[0] - 0.15).abs() < 1e-12 && (q[1] - 0.05).abs() < 1e-12,
"{q:?}"
);
}
#[test]
fn a_saturated_machine_pins_and_the_rest_absorbs_it() {
let ms = [
machine("big", -1.0, 1.0, None),
machine("small", -0.3, 0.1, None),
];
let refs: Vec<&RegulatingMachine> = ms.iter().collect();
let (keys, _) = reactive_keys(&refs);
let (q, pinned) = split(0.9, &refs, &keys);
assert!((q.iter().sum::<f64>() - 0.9).abs() < 1e-12, "{q:?}");
assert!(
pinned[1] && !pinned[0],
"the small machine should be the one to saturate"
);
assert!(
(q[1] - 0.1).abs() < 1e-12,
"pinned at its own max, not its share"
);
assert!(
(q[0] - 0.8).abs() < 1e-12,
"the big machine takes what is left"
);
}
#[test]
fn an_impossible_total_leaves_a_shortfall() {
let ms = [machine("a", -0.1, 0.1, None), machine("b", -0.1, 0.1, None)];
let refs: Vec<&RegulatingMachine> = ms.iter().collect();
let (keys, _) = reactive_keys(&refs);
let (q, pinned) = split(0.5, &refs, &keys);
assert!(pinned.iter().all(|p| *p));
assert!(
(q.iter().sum::<f64>() - 0.2).abs() < 1e-12,
"capped at joint capability"
);
}
#[test]
fn a_lone_machine_takes_everything() {
let ms = [machine("only", -1.0, 1.0, None)];
let refs: Vec<&RegulatingMachine> = ms.iter().collect();
let (keys, _) = reactive_keys(&refs);
assert_eq!(keys, vec![1.0]);
let (q, _) = split(-0.42, &refs, &keys);
assert!((q[0] + 0.42).abs() < 1e-12);
}
}