Skip to main content

dispatch_probe/
dispatch_probe.rs

1//! Probe: per-machine reactive allocation at shared buses.
2use gridoxide::PowerFlowOptions;
3use gridoxide::dispatch::{allocate, reactive_keys};
4use gridoxide::network::{build_ybus, stamp_shunts};
5
6fn main() {
7    let path = std::env::args().nth(1).expect("path to a CGMES directory");
8    let dir = std::path::Path::new(&path);
9    let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
10        .expect("dir")
11        .filter_map(|e| e.ok().map(|e| e.path()))
12        .filter(|p| p.extension().is_some_and(|x| x == "xml"))
13        .collect();
14    paths.sort();
15    let refs: Vec<&std::path::Path> = paths.iter().map(|p| p.as_path()).collect();
16    let ds = gridoxide::cgmes::load_profiles(&refs).expect("load");
17    let net = gridoxide::cgmes::cgmes_to_network(&ds, 100e6).expect("convert");
18    let vc = &net.voltage_control;
19    println!(
20        "{} regulated bus(es), {} shared, {} regulating machine(s)",
21        vc.regulated_buses,
22        vc.shared_buses,
23        vc.machines.len()
24    );
25
26    let report = gridoxide::run_power_flow(
27        net.buses.clone(),
28        &net.lines,
29        &net.transformers,
30        &net.shunts,
31        gridoxide::TapData::none(),
32        PowerFlowOptions {
33            enforce_q_limits: true,
34            max_iter: 60,
35            max_outer_iter: 60,
36            ..Default::default()
37        },
38    );
39    println!("solve: {:?}", report.stats.status);
40
41    let mut ybus = build_ybus(net.buses.len(), &net.lines, &net.transformers);
42    stamp_shunts(&mut ybus, &net.shunts);
43    let ybus = ybus.finish();
44    let d = allocate(&vc.machines, &vc.nonregulating_q, &report.buses, &ybus);
45
46    // Group by controlled bus, show the shared ones.
47    let mut by_bus: std::collections::BTreeMap<usize, Vec<&gridoxide::dispatch::MachineDispatch>> =
48        Default::default();
49    for bd in &d {
50        for m in &bd.machines {
51            by_bus.entry(m.controls_bus).or_default().push(m);
52        }
53    }
54    let shared: Vec<_> = by_bus.iter().filter(|(_, v)| v.len() > 1).collect();
55    println!("{} bus(es) with >1 machine\n", shared.len());
56
57    for (bus, ms) in shared.iter().take(4) {
58        let refs: Vec<&gridoxide::types::RegulatingMachine> = vc
59            .machines
60            .iter()
61            .filter(|m| m.controls_bus == **bus)
62            .collect();
63        let (_, basis) = reactive_keys(&refs);
64        let total: f64 = ms.iter().map(|m| m.q).sum();
65        println!(
66            "bus {bus}: {} machines, keys from {:?}, total Q = {:+.6} pu",
67            ms.len(),
68            basis,
69            total
70        );
71        for m in ms.iter() {
72            println!(
73                "   {:<22} q={:+.6}  share={:.3}  range=[{:+.4},{:+.4}]{}",
74                &m.id[..m.id.len().min(22)],
75                m.q,
76                m.share,
77                m.q_min,
78                m.q_max,
79                if m.at_limit { "  AT LIMIT" } else { "" }
80            );
81        }
82        println!();
83    }
84
85    // Invariants worth seeing before writing a test around them.
86    // Compare against the fixture's own published per-terminal solution.
87    let mut published: std::collections::HashMap<String, f64> = Default::default();
88    if let Some(mrids) = ds.by_type.get("Terminal") {
89        let mut eq_of: std::collections::HashMap<&str, &str> = Default::default();
90        for m in mrids {
91            if let Some(e) = ds.entries[m].get_ref("Terminal.ConductingEquipment") {
92                eq_of.insert(m.as_str(), e);
93            }
94        }
95        if let Some(fl) = ds.by_type.get("SvPowerFlow") {
96            for m in fl {
97                let sv = &ds.entries[m];
98                let (Some(t), Some(q)) = (
99                    sv.get_ref("SvPowerFlow.Terminal"),
100                    sv.get_f64("SvPowerFlow.q"),
101                ) else {
102                    continue;
103                };
104                if let Some(e) = eq_of.get(t) {
105                    published.insert((*e).to_string(), -q / 100.0);
106                }
107            }
108        }
109    }
110    let mut matched = 0usize;
111    let mut worst_m = 0.0f64;
112    let mut sum_abs = 0.0f64;
113    let mut sum_err = 0.0f64;
114    for bd in &d {
115        for m in &bd.machines {
116            if let Some(&exp) = published.get(&m.id) {
117                matched += 1;
118                worst_m = worst_m.max((m.q - exp).abs());
119                sum_abs += exp.abs();
120                sum_err += (m.q - exp).abs();
121            }
122        }
123    }
124    println!(
125        "published per-machine Q: matched {matched}/{}  worst |diff| = {worst_m:.4} pu  \
126              mean |diff| / mean |published| = {:.1}%",
127        d.iter().map(|b| b.machines.len()).sum::<usize>(),
128        if sum_abs > 0.0 {
129            100.0 * sum_err / sum_abs
130        } else {
131            0.0
132        }
133    );
134
135    // Decompose: does the *bus total* agree (the solve) or the *split* (the rule)?
136    let (mut bus_err, mut bus_abs) = (0.0f64, 0.0f64);
137    let (mut split_err, mut split_abs) = (0.0f64, 0.0f64);
138    let (mut shared_split_err, mut shared_split_abs) = (0.0f64, 0.0f64);
139    let mut worst_bus = (0.0f64, 0usize);
140    for bd in &d {
141        let pub_sum: f64 = bd
142            .machines
143            .iter()
144            .filter_map(|m| published.get(&m.id))
145            .sum();
146        let n_pub = bd
147            .machines
148            .iter()
149            .filter(|m| published.contains_key(&m.id))
150            .count();
151        if n_pub != bd.machines.len() {
152            continue;
153        }
154        bus_err += (bd.attributed - pub_sum).abs();
155        bus_abs += pub_sum.abs();
156        if (bd.attributed - pub_sum).abs() > worst_bus.0 {
157            worst_bus = ((bd.attributed - pub_sum).abs(), bd.bus);
158        }
159        // Split quality, measured against the *published* total so the solve
160        // difference is factored out.
161        let (keys, _) = reactive_keys(
162            &vc.machines
163                .iter()
164                .filter(|m| m.controls_bus == bd.bus)
165                .collect::<Vec<_>>(),
166        );
167        for (k, m) in bd.machines.iter().enumerate() {
168            let exp = published[&m.id];
169            let ours_on_pub_total = pub_sum * keys[k];
170            split_err += (ours_on_pub_total - exp).abs();
171            split_abs += exp.abs();
172            if bd.machines.len() > 1 {
173                shared_split_err += (ours_on_pub_total - exp).abs();
174                shared_split_abs += exp.abs();
175            }
176        }
177    }
178    println!(
179        "  bus totals   (the solve): mean rel err {:.1}%   worst {:.4} pu at bus {}",
180        100.0 * bus_err / bus_abs.max(1e-12),
181        worst_bus.0,
182        worst_bus.1
183    );
184    println!(
185        "  the split    (the rule) : mean rel err {:.1}%   (shared buses only: {:.1}%)",
186        100.0 * split_err / split_abs.max(1e-12),
187        100.0 * shared_split_err / shared_split_abs.max(1e-12)
188    );
189
190    // Basis distribution over shared buses, and unattributed at a physical threshold.
191    let mut basis_counts: std::collections::BTreeMap<String, usize> = Default::default();
192    for bd in &d {
193        if bd.machines.len() > 1 {
194            *basis_counts.entry(format!("{:?}", bd.basis)).or_default() += 1;
195        }
196    }
197    println!("shared-bus key basis: {basis_counts:?}");
198    for thr in [1e-9f64, 1e-7, 1e-6, 1e-5] {
199        let n = d.iter().filter(|b| b.unattributed.abs() > thr).count();
200        let t: f64 = d
201            .iter()
202            .filter(|b| b.unattributed.abs() > thr)
203            .map(|b| b.unattributed.abs())
204            .sum();
205        println!("  unattributed > {thr:.0e}: {n} bus(es), {t:.4} pu");
206    }
207    // Which shared buses fall back, and why?
208    for bd in d
209        .iter()
210        .filter(|b| b.machines.len() > 1 && format!("{:?}", b.basis) != "Capability")
211    {
212        println!("  FALLBACK bus {} -> {:?}", bd.bus, bd.basis);
213        for m in &bd.machines {
214            println!(
215                "     {} range=[{:+.6},{:+.6}] width={:.6}",
216                &m.id[..12],
217                m.q_min,
218                m.q_max,
219                m.q_max - m.q_min
220            );
221        }
222    }
223
224    let (_, qall) = gridoxide::network::power_injections(&report.buses, &ybus);
225    let mut worst: Vec<(f64, usize, usize, usize, f64, f64)> = Vec::new();
226    let mut violations = 0;
227    for (bus, ms) in &by_bus {
228        let total: f64 = ms.iter().map(|m| m.q).sum();
229        let expected = qall[*bus] - vc.nonregulating_q[*bus];
230        let pinned = ms.iter().filter(|m| m.at_limit).count();
231        worst.push((
232            (total - expected).abs(),
233            *bus,
234            ms.len(),
235            pinned,
236            total,
237            expected,
238        ));
239        for m in ms.iter() {
240            if m.q > m.q_max + 1e-9 || m.q < m.q_min - 1e-9 {
241                violations += 1;
242            }
243        }
244    }
245    let n_bad = worst.iter().filter(|w| w.0 > 1e-9).count();
246    let total_bad: f64 = worst.iter().map(|w| w.0).sum();
247    // How many regulated buses have reactive load co-located with the machine?
248    let with_load = by_bus
249        .keys()
250        .filter(|b| vc.nonregulating_q[**b].abs() > 1e-9)
251        .count();
252    println!(
253        "regulated buses: {}  with co-located reactive injection: {with_load}  \
254              unattributable: {n_bad}  total unattributed = {total_bad:.4} pu",
255        by_bus.len()
256    );
257    worst.sort_by(|a, b| b.0.total_cmp(&a.0));
258    println!("limit violations = {violations}");
259    println!("worst mismatches (err, bus, machines, pinned, sum, expected):");
260    for w in worst.iter().take(6) {
261        let bt = report.buses[w.1].bus_type;
262        println!(
263            "  {:.3e}  bus {:4} n={} pinned={} sum={:+.6} expected={:+.6}  type={:?} qlim=[{:+.4},{:+.4}]",
264            w.0, w.1, w.2, w.3, w.4, w.5, bt, report.buses[w.1].q_min, report.buses[w.1].q_max
265        );
266    }
267}