1use std::collections::HashMap;
26
27use serde::{Deserialize, Serialize};
28
29use crate::Diagnostic;
30use crate::diagnostics::codes;
31use crate::network::{BalancedNetwork, BusId, BusType, Hvdc};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36#[serde(rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum HvdcTreatment {
39 #[default]
44 FixedInjection,
45 Ignore,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
53#[non_exhaustive]
54pub struct HvdcTerminalInjection {
55 pub row: usize,
57 pub bus: BusId,
59 pub p: f64,
63 pub q: f64,
65}
66
67#[derive(Debug, Clone, Default, PartialEq)]
70#[non_exhaustive]
71pub struct HvdcInjections {
72 pub terminals: Vec<HvdcTerminalInjection>,
75 pub out_of_service: Vec<usize>,
77 pub inactive_terminal: Vec<usize>,
81 pub per_unit: bool,
84}
85
86impl HvdcInjections {
87 #[must_use]
89 pub fn n_injecting(&self) -> usize {
90 self.terminals.len() / 2
91 }
92
93 fn sending_ends(&self) -> impl Iterator<Item = &HvdcTerminalInjection> {
94 self.terminals.iter().step_by(2)
95 }
96
97 fn receiving_ends(&self) -> impl Iterator<Item = &HvdcTerminalInjection> {
98 self.terminals.iter().skip(1).step_by(2)
99 }
100
101 #[must_use]
103 pub fn calc_sent_power(&self) -> f64 {
104 -self.sending_ends().map(|t| t.p).sum::<f64>()
105 }
106
107 #[must_use]
109 pub fn calc_delivered_power(&self) -> f64 {
110 self.receiving_ends().map(|t| t.p).sum()
111 }
112
113 #[must_use]
116 pub fn calc_reactive_injection(&self) -> f64 {
117 self.terminals.iter().map(|t| t.q).sum()
118 }
119
120 #[must_use]
125 pub fn to_diagnostics(&self, treatment: HvdcTreatment) -> Vec<Diagnostic> {
126 let (p_unit, q_unit) = if self.per_unit {
127 ("per unit", "per unit")
128 } else {
129 ("MW", "MVAr")
130 };
131 let mut diagnostics = Vec::new();
132 let n = self.n_injecting();
133 if n > 0 {
134 let sent = self.calc_sent_power();
135 let delivered = self.calc_delivered_power();
136 let lost = amount(sent - delivered);
137 let reactive = amount(self.calc_reactive_injection());
138 let (sent, delivered) = (amount(sent), amount(delivered));
139 diagnostics.push(match treatment {
140 HvdcTreatment::FixedInjection => Diagnostic::of(
141 &codes::BUILD_HVDC_FIXED_INJECTION,
142 format!(
143 "{n} in service HVDC line(s) enter the bus balance as fixed injections: \
144 {sent} {p_unit} withdrawn at the from buses, {delivered} {p_unit} \
145 delivered at the to buses ({lost} {p_unit} lost), and {reactive} \
146 {q_unit} reactive injection over both ends"
147 ),
148 ),
149 HvdcTreatment::Ignore => Diagnostic::of(
150 &codes::BUILD_HVDC_IGNORED,
151 format!(
152 "{n} in service HVDC line(s) were left out of the bus balance by \
153 request; {sent} {p_unit} sent and {delivered} {p_unit} delivered are \
154 absent from the calculation"
155 ),
156 ),
157 });
158 }
159 for row in &self.inactive_terminal {
160 diagnostics.push(Diagnostic::of(
161 &codes::BUILD_HVDC_TERMINAL_INACTIVE,
162 format!(
163 "HVDC line row {row} is in service, but a terminal bus is isolated or not \
164 declared; it injects nothing"
165 ),
166 ));
167 }
168 diagnostics
169 }
170}
171
172fn amount(value: f64) -> String {
175 let text = format!("{value:.6}");
176 let text = text.trim_end_matches('0').trim_end_matches('.');
177 if text == "-0" {
178 "0".to_owned()
179 } else {
180 text.to_owned()
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq)]
194#[non_exhaustive]
195pub struct HvdcTerminalPowers {
196 pub p_from: f64,
198 pub q_from: f64,
201 pub p_to: f64,
204 pub q_to: f64,
207}
208
209impl HvdcTerminalPowers {
210 #[must_use]
213 pub fn calc_active_loss(&self) -> f64 {
214 self.p_from + self.p_to
215 }
216
217 #[must_use]
224 pub fn calc_bus_injections(&self) -> [(f64, f64); 2] {
225 [(-self.p_from, -self.q_from), (-self.p_to, -self.q_to)]
226 }
227}
228
229impl Hvdc {
230 #[must_use]
242 pub fn calc_bus_to_device_powers(&self) -> HvdcTerminalPowers {
243 HvdcTerminalPowers {
244 p_from: self.pf,
245 q_from: -self.qf,
246 p_to: -self.pt,
247 q_to: -self.qt,
248 }
249 }
250
251 #[must_use]
259 pub fn calc_terminal_injections(&self) -> [(BusId, f64, f64); 2] {
260 let [(p_from, q_from), (p_to, q_to)] =
261 self.calc_bus_to_device_powers().calc_bus_injections();
262 [(self.from, p_from, q_from), (self.to, p_to, q_to)]
263 }
264}
265
266impl BalancedNetwork {
267 #[must_use]
273 pub fn calc_hvdc_injections(&self) -> HvdcInjections {
274 let mut injections = HvdcInjections {
275 per_unit: self.is_normalized(),
276 ..HvdcInjections::default()
277 };
278 if self.hvdc().is_empty() {
279 return injections;
280 }
281 let kinds: HashMap<BusId, BusType> = self.buses().iter().map(|b| (b.id, b.kind)).collect();
282 let active = |bus: BusId| {
283 kinds
284 .get(&bus)
285 .is_some_and(|&kind| kind != BusType::Isolated)
286 };
287 for (row, line) in self.hvdc().iter().enumerate() {
288 if !line.in_service {
289 injections.out_of_service.push(row);
290 } else if !(active(line.from) && active(line.to)) {
291 injections.inactive_terminal.push(row);
292 } else {
293 for (bus, p, q) in line.calc_terminal_injections() {
294 injections
295 .terminals
296 .push(HvdcTerminalInjection { row, bus, p, q });
297 }
298 }
299 }
300 injections
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::network::Bus;
308
309 fn bus(id: usize, kind: BusType) -> Bus {
310 Bus::new(BusId(id), kind, 230.0)
311 }
312
313 fn line(from: usize, to: usize, pf: f64, pt: f64, qf: f64, qt: f64) -> Hvdc {
314 let mut line = Hvdc::new(BusId(from), BusId(to));
315 line.pf = pf;
316 line.pt = pt;
317 line.qf = qf;
318 line.qt = qt;
319 line
320 }
321
322 #[test]
323 fn terminal_injections_follow_the_dcline_convention() {
324 let injections = line(1, 2, 100.0, 97.0, -40.0, -35.0).calc_terminal_injections();
325 assert_eq!(injections[0], (BusId(1), -100.0, -40.0));
326 assert_eq!(injections[1], (BusId(2), 97.0, -35.0));
327 }
328
329 fn assert_injections_negate_terminals(line: &Hvdc) {
331 let terminal = line.calc_bus_to_device_powers();
332 let [from, to] = line.calc_terminal_injections();
333 assert_eq!(from, (line.from, -terminal.p_from, -terminal.q_from));
334 assert_eq!(to, (line.to, -terminal.p_to, -terminal.q_to));
335 }
336
337 #[test]
338 fn forward_transfer_terminal_powers_point_from_each_bus_into_the_line() {
339 let forward = line(1, 2, 100.0, 97.0, -30.0, -25.0);
343 let terminal = forward.calc_bus_to_device_powers();
344 assert_eq!((terminal.p_from, terminal.q_from), (100.0, 30.0));
345 assert_eq!((terminal.p_to, terminal.q_to), (-97.0, 25.0));
346 assert!((terminal.calc_active_loss() - 3.0).abs() < 1e-12);
347 let [from, to] = forward.calc_terminal_injections();
348 assert_eq!(from, (BusId(1), -100.0, -30.0));
349 assert_eq!(to, (BusId(2), 97.0, -25.0));
350 assert_injections_negate_terminals(&forward);
351 }
352
353 #[test]
354 fn reverse_transfer_keeps_the_ends_and_a_positive_loss() {
355 let reverse = line(1, 2, -97.0, -100.0, -25.0, -30.0);
359 let terminal = reverse.calc_bus_to_device_powers();
360 assert_eq!((terminal.p_from, terminal.q_from), (-97.0, 25.0));
361 assert_eq!((terminal.p_to, terminal.q_to), (100.0, 30.0));
362 assert!((terminal.calc_active_loss() - 3.0).abs() < 1e-12);
363 let [from, to] = reverse.calc_terminal_injections();
364 assert_eq!(from, (BusId(1), 97.0, -25.0));
365 assert_eq!(to, (BusId(2), -100.0, -30.0));
366 assert_injections_negate_terminals(&reverse);
367 }
368
369 #[test]
370 fn reactive_absorption_and_injection_at_either_end() {
371 let mixed = line(1, 2, 50.0, 49.0, 10.0, -5.0);
374 let terminal = mixed.calc_bus_to_device_powers();
375 assert_eq!((terminal.q_from, terminal.q_to), (-10.0, 5.0));
376 let [from, to] = mixed.calc_terminal_injections();
377 assert_eq!((from.2, to.2), (10.0, -5.0));
378 assert_injections_negate_terminals(&mixed);
379
380 let opposite = line(1, 2, 50.0, 49.0, -10.0, 5.0);
383 let terminal = opposite.calc_bus_to_device_powers();
384 assert_eq!((terminal.q_from, terminal.q_to), (10.0, -5.0));
385 let [from, to] = opposite.calc_terminal_injections();
386 assert_eq!((from.2, to.2), (-10.0, 5.0));
387 assert_injections_negate_terminals(&opposite);
388
389 for (qf, qt) in [(12.0, 7.0), (-12.0, -7.0)] {
391 let both = line(1, 2, 50.0, 49.0, qf, qt);
392 let terminal = both.calc_bus_to_device_powers();
393 assert_eq!((terminal.q_from, terminal.q_to), (-qf, -qt));
394 assert_injections_negate_terminals(&both);
395 }
396 }
397
398 #[test]
399 fn network_injections_negate_the_terminal_powers() {
400 let mut net = BalancedNetwork::in_memory(
401 "hvdc",
402 100.0,
403 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
404 Vec::new(),
405 );
406 let forward = line(1, 2, 100.0, 97.0, -30.0, -25.0);
407 net.hvdc_mut().push(forward.clone());
408 let injections = net.calc_hvdc_injections();
409 let terminal = forward.calc_bus_to_device_powers();
410 let [from, to] = &injections.terminals[..] else {
411 panic!("one injecting line has two terminals");
412 };
413 assert_eq!((from.p, from.q), (-terminal.p_from, -terminal.q_from));
414 assert_eq!((to.p, to.q), (-terminal.p_to, -terminal.q_to));
415 let lost = injections.calc_sent_power() - injections.calc_delivered_power();
416 assert!((lost - terminal.calc_active_loss()).abs() < 1e-12);
417 assert!((injections.calc_reactive_injection() + 55.0).abs() < 1e-12);
418 }
419
420 #[test]
421 fn out_of_service_and_inactive_terminals_inject_nothing() {
422 let mut net = BalancedNetwork::in_memory(
423 "hvdc",
424 100.0,
425 vec![
426 bus(1, BusType::Ref),
427 bus(2, BusType::Pq),
428 bus(3, BusType::Isolated),
429 ],
430 Vec::new(),
431 );
432 let mut off = line(1, 2, 50.0, 49.0, 0.0, 0.0);
433 off.in_service = false;
434 net.hvdc_mut().extend([
435 line(1, 2, 100.0, 97.0, 0.0, 0.0),
436 off,
437 line(1, 3, 10.0, 10.0, 0.0, 0.0),
438 line(1, 9, 10.0, 10.0, 0.0, 0.0),
439 ]);
440 let injections = net.calc_hvdc_injections();
441 assert_eq!(injections.n_injecting(), 1);
442 assert_eq!(injections.out_of_service, vec![1]);
443 assert_eq!(injections.inactive_terminal, vec![2, 3]);
444 assert!((injections.calc_sent_power() - 100.0).abs() < 1e-12);
445 assert!((injections.calc_delivered_power() - 97.0).abs() < 1e-12);
446
447 let fixed = injections.to_diagnostics(HvdcTreatment::FixedInjection);
448 assert_eq!(fixed.len(), 3);
449 assert_eq!(fixed[0].code(), "BUILD.HVDC.FIXED_INJECTION");
450 assert!(
451 fixed[0].message().contains("100 MW withdrawn"),
452 "{}",
453 fixed[0].message()
454 );
455 assert!(
456 fixed[0].message().contains("97 MW"),
457 "{}",
458 fixed[0].message()
459 );
460 assert_eq!(fixed[1].code(), "BUILD.HVDC.TERMINAL_INACTIVE");
461 let ignored = injections.to_diagnostics(HvdcTreatment::Ignore);
462 assert_eq!(ignored[0].code(), "BUILD.HVDC.IGNORED");
463 }
464
465 #[test]
466 fn a_network_without_hvdc_reports_nothing() {
467 let net =
468 BalancedNetwork::in_memory("empty", 100.0, vec![bus(1, BusType::Ref)], Vec::new());
469 let injections = net.calc_hvdc_injections();
470 assert_eq!(injections, HvdcInjections::default());
471 assert_eq!(injections.to_diagnostics(HvdcTreatment::FixedInjection), []);
472 }
473}