Skip to main content

powerio_tx/
hvdc.rs

1//! HVDC lines as fixed bus injections.
2//!
3//! A balanced calculation has no DC network: an [`Hvdc`] line couples its two
4//! AC terminal buses only through the power its converters exchange with
5//! them. The calculations therefore carry each in service line as two fixed
6//! injections at its stated operating point.
7//!
8//! The calculation interface is the bus-to-device terminal view,
9//! [`Hvdc::calc_bus_to_device_powers`]: at each end, active and reactive
10//! power are positive from that end's AC bus into the HVDC system (the two
11//! converters and the DC line taken as one device), the direction AC branch
12//! terminal flows use. The two active terminal powers sum to the power the
13//! line and its converters lose, and the injection into each bus is the
14//! negative of that end's terminal power, negated once in
15//! [`Hvdc::calc_terminal_injections`].
16//!
17//! The stored fields [`pf`](Hvdc::pf), [`pt`](Hvdc::pt), [`qf`](Hvdc::qf), and
18//! [`qt`](Hvdc::qt) keep MATPOWER's `dcline` signs throughout 0.11.x, whatever
19//! the source format: `pf` is positive from the from bus into the line, while
20//! `pt`, `qf`, and `qt` are positive from the line into their bus. The
21//! terminal view converts them once: `p_from = pf`, `q_from = -qf`,
22//! `p_to = -pt`, `q_to = -qt`. The injections match MATPOWER's
23//! `toggle_dcline` dummy generators.
24
25use 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/// How a calculation treats the network's HVDC lines.
34#[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    /// Each in service line enters the bus balance as a fixed injection: the
40    /// negative of its bus-to-device terminal powers, which in the stored
41    /// MATPOWER signs is `-pf` and `+qf` at the from bus and `+pt` and `+qt`
42    /// at the to bus.
43    #[default]
44    FixedInjection,
45    /// Leave every HVDC line out of the bus balance. The power the lines
46    /// transfer is absent from the calculation, and a `BUILD.HVDC.IGNORED`
47    /// warning states how much.
48    Ignore,
49}
50
51/// The injection one HVDC line makes at one of its AC terminal buses.
52#[derive(Debug, Clone, Copy, PartialEq)]
53#[non_exhaustive]
54pub struct HvdcTerminalInjection {
55    /// Row of the line in [`BalancedNetwork::hvdc`].
56    pub row: usize,
57    /// The AC terminal bus.
58    pub bus: BusId,
59    /// Active power injected into the bus, in the network's power unit (MW,
60    /// or per unit for a normalized network). Negative at a from end that
61    /// sends power.
62    pub p: f64,
63    /// Reactive power injected into the bus, same unit.
64    pub q: f64,
65}
66
67/// Every fixed HVDC injection a network states, with the lines that make
68/// none. Build it with [`BalancedNetwork::calc_hvdc_injections`].
69#[derive(Debug, Clone, Default, PartialEq)]
70#[non_exhaustive]
71pub struct HvdcInjections {
72    /// Two entries per injecting line, its from end then its to end, in
73    /// [`BalancedNetwork::hvdc`] row order.
74    pub terminals: Vec<HvdcTerminalInjection>,
75    /// Rows of the lines whose in service flag is false. They inject nothing.
76    pub out_of_service: Vec<usize>,
77    /// Rows of in service lines left out because a terminal bus is isolated
78    /// or not declared by the network. A converter with no energized AC
79    /// terminal cannot exchange power, so neither end injects.
80    pub inactive_terminal: Vec<usize>,
81    /// Whether the powers are per unit (the network is normalized) rather
82    /// than MW and MVAr.
83    pub per_unit: bool,
84}
85
86impl HvdcInjections {
87    /// How many lines inject.
88    #[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    /// Active power the injecting lines take from their from buses, summed.
102    #[must_use]
103    pub fn calc_sent_power(&self) -> f64 {
104        -self.sending_ends().map(|t| t.p).sum::<f64>()
105    }
106
107    /// Active power the injecting lines deliver to their to buses, summed.
108    #[must_use]
109    pub fn calc_delivered_power(&self) -> f64 {
110        self.receiving_ends().map(|t| t.p).sum()
111    }
112
113    /// Reactive power the injecting lines put into their terminal buses,
114    /// summed over both ends.
115    #[must_use]
116    pub fn calc_reactive_injection(&self) -> f64 {
117        self.terminals.iter().map(|t| t.q).sum()
118    }
119
120    /// The diagnostics a calculation reports for these lines under
121    /// `treatment`: a remark stating what was injected, or a warning stating
122    /// what was left out, plus one warning per in service line whose terminal
123    /// bus is inactive. A network with no HVDC line reports nothing.
124    #[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
172/// A power total for a message: at most six decimals, trailing zeros
173/// dropped, so a sum's rounding noise does not reach the text.
174fn 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/// The bus-to-device terminal powers of one HVDC line: at each end, active
185/// and reactive power positive from that end's AC bus into the HVDC system
186/// (both converters and the DC line taken as one device). Build it with
187/// [`Hvdc::calc_bus_to_device_powers`].
188///
189/// The from and to ends name the line's orientation, not which end sends: a
190/// line transferring power from its to end has a negative `p_from` and a
191/// positive `p_to`. Powers are in the network's unit, MW and MVAr, or per
192/// unit for a normalized network.
193#[derive(Debug, Clone, Copy, PartialEq)]
194#[non_exhaustive]
195pub struct HvdcTerminalPowers {
196    /// Active power from the from bus into the HVDC system.
197    pub p_from: f64,
198    /// Reactive power from the from bus into the HVDC system; positive when
199    /// the from converter absorbs reactive power.
200    pub q_from: f64,
201    /// Active power from the to bus into the HVDC system; negative when the
202    /// line delivers power to the to bus.
203    pub p_to: f64,
204    /// Reactive power from the to bus into the HVDC system; positive when the
205    /// to converter absorbs reactive power.
206    pub q_to: f64,
207}
208
209impl HvdcTerminalPowers {
210    /// The active power the line and its converters lose, `p_from + p_to`:
211    /// what enters the HVDC system at both ends together.
212    #[must_use]
213    pub fn calc_active_loss(&self) -> f64 {
214        self.p_from + self.p_to
215    }
216
217    /// The injections these terminal powers make into the two AC buses,
218    /// `[(p, q) at the from bus, (p, q) at the to bus]`: each end's terminal
219    /// power negated. This is the one place a terminal power becomes a bus
220    /// injection; [`Hvdc::calc_terminal_injections`] goes through it, and a
221    /// calculation that replaces a terminal power (an LCC converter's bridge
222    /// reactive demand, say) negates the replaced value here too.
223    #[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    /// The line's stated operating point as bus-to-device terminal powers,
231    /// the convention the [module](crate::hvdc) states. The stored fields
232    /// keep MATPOWER's `dcline` signs in 0.11.x, so this is the one place
233    /// they convert: `p_from = pf`, `q_from = -qf`, `p_to = -pt`,
234    /// `q_to = -qt`. The in service flag is not consulted.
235    ///
236    /// A line sending 100 MW from its from end and delivering 97 MW at its
237    /// to end, with converters absorbing 30 and 25 MVAr, stores `pf = 100`,
238    /// `pt = 97`, `qf = -30`, `qt = -25` and has terminal powers
239    /// `(p_from, q_from) = (100, 30)` and `(p_to, q_to) = (-97, 25)`, a 3 MW
240    /// loss.
241    #[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    /// The fixed injections this line's stated operating point makes,
252    /// `[from end, to end]` as `(bus, p, q)`: at each end the negative of
253    /// the [bus-to-device terminal powers](Self::calc_bus_to_device_powers).
254    /// In the stored MATPOWER signs the from converter withdraws
255    /// [`pf`](Self::pf) and injects [`qf`](Self::qf), and the to converter
256    /// injects [`pt`](Self::pt) and [`qt`](Self::qt). The in service flag is
257    /// not consulted.
258    #[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    /// The fixed bus injections of every in service HVDC line whose two
268    /// terminal buses are declared and not isolated, in the convention the
269    /// [module](crate::hvdc) states, plus the rows of the lines that inject
270    /// nothing. Powers are in the network's unit, so a normalized network
271    /// yields per unit.
272    #[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    /// Each bus injection is exactly the negative of its terminal power.
330    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        // 100 MW sent, 97 MW delivered, converters absorbing 30 and 25 MVAr:
340        // MATPOWER stores the delivered power and both reactive powers as
341        // injections into their buses.
342        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        // The to end sends 100 MW and the from end receives 97 MW, with the
356        // receiving converter absorbing 25 MVAr and the sending one 30: the
357        // ends keep their identities and the terminal values change sign.
358        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        // The from converter injects 10 MVAr into its bus and the to
372        // converter absorbs 5 MVAr from its bus.
373        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        // The opposite: the from converter absorbs 10 MVAr and the to
381        // converter injects 5 MVAr.
382        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        // Both ends injecting, and both absorbing, at once.
390        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}