Skip to main content

powerio_tx/
lcc.rs

1//! The steady state of a line-commutated (LCC) two-terminal DC line at its
2//! AC terminal voltages.
3//!
4//! A two-terminal DC line read from PSS/E keeps its converter records
5//! (`psse_dc_*` extras), and those, with the AC voltage at each converter
6//! bus, fix the converters' firing and extinction angles, their commutation
7//! overlap, and the reactive power each one draws from its AC bus. The
8//! [`Hvdc`] record states no converter reactive power (`qf = qt = 0`), so a
9//! calculation that wants the converters' physical demand computes it here.
10//!
11//! The model is the classic six-pulse bridge equations PSS/E states for its
12//! two-terminal lines. With `NB` bridges in series, commutating resistance
13//! `RC` and reactance `XC` per bridge (ohms), and the converter transformer's
14//! primary base voltage `EBAS` (kV), ratio `TR`, and tap `TAP`, the valve-side
15//! line-to-line voltage at AC magnitude `V` (per unit) is
16//! `E = V·EBAS·TR/TAP`, and each converter's angle `θ` (firing angle `α` at
17//! the rectifier, extinction angle `γ` at the inverter) satisfies
18//!
19//! ```text
20//! Vd/NB = (3√2/π)·E·cos θ − (3·XC/π + 2·RC)·Id
21//! cos(θ + μ) = cos θ − √2·XC·Id/E
22//! Q = P·(2μ + sin 2θ − sin 2(θ + μ)) / (cos 2θ − cos 2(θ + μ))
23//! ```
24//!
25//! with `P = Vd·Id`. The DC operating point comes from the record's
26//! schedule: the scheduled compounded voltage `VSCHD` holds
27//! `Vd_inverter + RCOMP·Id`, the rectifier sits `RDC·Id` above the inverter,
28//! and `SETVL` states the power (at the rectifier, or at the inverter when
29//! negative) or, under `MDC 2`, the current.
30//!
31//! A converter whose angle reaches 90 degrees or more, or whose DC voltage
32//! is not positive, has a power factor at or below zero: the schedule does
33//! not describe a rectifier or inverter at these voltages, so no operating
34//! point is returned and a caller keeps the line's stated reactive powers.
35//!
36//! # Known inconsistency with the PSS/E reader
37//!
38//! The two-terminal reader on main prices a power schedule at the rectifier
39//! as `Id = SETVL / VSCHD`, as if `VSCHD` were the rectifier voltage, and
40//! stores `pf = SETVL` and `pt = SETVL - RDC·Id²`. The bridge model here
41//! follows PSS/E: `VSCHD` is the inverter voltage plus the compounding drop
42//! `RCOMP·Id`, so the rectifier sits `RDC·Id` above it and `Id` solves
43//! `(VSCHD + (RDC - RCOMP)·Id)·Id = SETVL`. For `SETVL` 1000 MW, `VSCHD`
44//! 500 kV, `RDC` 10 Ω, and `RCOMP` 0, the reader has `Id` = 2.0 kA and
45//! `pt` = 960 MW, the bridge model `Id` = 1.9258 kA and 962.9 MW at the
46//! inverter. The stated-state balance uses this model's current only for
47//! the converters' reactive power and keeps the reader's `pf` and `pt` for
48//! the active power; aligning the reader changes conversion output and is
49//! separate work.
50
51use serde_json::Value;
52
53use crate::network::{Extras, Hvdc};
54
55/// One converter of an LCC line at an operating point.
56#[derive(Debug, Clone, Copy, PartialEq)]
57#[non_exhaustive]
58pub struct LccConverterState {
59    /// DC voltage at the converter, kV.
60    pub dc_voltage_kv: f64,
61    /// Firing angle at the rectifier, extinction angle at the inverter,
62    /// degrees.
63    pub angle_deg: f64,
64    /// Commutation overlap angle, degrees.
65    pub overlap_deg: f64,
66    /// DC power through the converter, `Vd·Id`, MW.
67    pub p_mw: f64,
68    /// Reactive power the converter draws from its AC bus, MVAr. Positive.
69    pub q_mvar: f64,
70}
71
72/// Both converters of an LCC line at an operating point.
73#[derive(Debug, Clone, Copy, PartialEq)]
74#[non_exhaustive]
75pub struct LccOperatingPoint {
76    /// DC current, kA.
77    pub dc_current_ka: f64,
78    /// The rectifier, at the line's `from` bus.
79    pub rectifier: LccConverterState,
80    /// The inverter, at the line's `to` bus.
81    pub inverter: LccConverterState,
82}
83
84/// The converter fields the reactive power depends on, revision independent:
85/// the first eight converter tail fields and the commutating capacitor last.
86struct Bridge {
87    bridges: f64,
88    rc: f64,
89    xc: f64,
90    ebas: f64,
91    tr: f64,
92    tap: f64,
93    xcap: f64,
94}
95
96/// The default converter tail the PSS/E reader does not retain: one bridge
97/// with no commutating impedance and no AC base, which states no converter.
98const DEFAULT_BRIDGE: [f64; 8] = [1.0, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0];
99
100fn number(token: &Value) -> Option<f64> {
101    token
102        .as_str()
103        .and_then(|text| text.trim().parse::<f64>().ok())
104        .or_else(|| token.as_f64())
105        .filter(|value| value.is_finite())
106}
107
108fn tail(extras: &Extras, key: &str) -> Option<Vec<f64>> {
109    extras.get(key).map(|value| {
110        value
111            .as_array()
112            .map(|tokens| tokens.iter().map(|t| number(t).unwrap_or(0.0)).collect())
113            .unwrap_or_default()
114    })
115}
116
117fn bridge(extras: &Extras, key: &str) -> Bridge {
118    let fields = tail(extras, key).unwrap_or_else(|| DEFAULT_BRIDGE.to_vec());
119    let at = |i: usize| fields.get(i).copied().unwrap_or(DEFAULT_BRIDGE[i]);
120    Bridge {
121        bridges: at(0),
122        rc: at(3),
123        xc: at(4),
124        ebas: at(5),
125        tr: at(6),
126        tap: at(7),
127        xcap: if fields.len() > DEFAULT_BRIDGE.len() {
128            fields.last().copied().unwrap_or(0.0)
129        } else {
130            0.0
131        },
132    }
133}
134
135impl Bridge {
136    /// The converter at DC voltage `vd` (kV), current `id` (kA), and AC
137    /// magnitude `vm` (per unit). `None` when the record states no AC base,
138    /// a commutating capacitor (not modeled), or a point no angle reaches.
139    fn calc_state(&self, vd: f64, id: f64, vm: f64) -> Option<LccConverterState> {
140        if self.bridges < 1.0 || self.ebas <= 0.0 || self.tap <= 0.0 || self.xcap != 0.0 {
141            return None;
142        }
143        let e = vm * self.ebas * self.tr / self.tap;
144        if e.is_nan() || e <= 0.0 {
145            return None;
146        }
147        let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
148        let drop = (3.0 * self.xc / std::f64::consts::PI + 2.0 * self.rc) * id;
149        let cos_theta = (vd / self.bridges + drop) / (k * e);
150        // A power factor at or below zero (an angle of 90 degrees or more,
151        // or a DC voltage that is not positive) is no converter operating
152        // point; the caller keeps the stated reactive power.
153        if vd <= 0.0 || !(f64::MIN_POSITIVE..=1.0).contains(&cos_theta) {
154            return None;
155        }
156        let theta = cos_theta.acos();
157        let cos_end = (cos_theta - std::f64::consts::SQRT_2 * self.xc * id / e).clamp(-1.0, 1.0);
158        let mu = cos_end.acos() - theta;
159        let p = vd * id;
160        let denominator = (2.0 * theta).cos() - (2.0 * (theta + mu)).cos();
161        let q = if denominator.abs() > 1e-12 {
162            p * (2.0 * mu + (2.0 * theta).sin() - (2.0 * (theta + mu)).sin()) / denominator
163        } else {
164            // No overlap: the fundamental lags by the angle itself.
165            p * theta.tan()
166        };
167        q.is_finite().then(|| LccConverterState {
168            dc_voltage_kv: vd,
169            angle_deg: theta.to_degrees(),
170            overlap_deg: mu.to_degrees(),
171            p_mw: p,
172            q_mvar: q.abs(),
173        })
174    }
175}
176
177impl Hvdc {
178    /// Whether the line carries a PSS/E two-terminal converter record that
179    /// states a converter: a rectifier or inverter tail the reader retained
180    /// because it differs from the default fields. A line from another
181    /// format, or a record left at the defaults, has none, and
182    /// [`calc_lcc_operating_point`](Self::calc_lcc_operating_point) has
183    /// nothing to model for it.
184    #[must_use]
185    pub fn has_lcc_converter_record(&self) -> bool {
186        self.extras.contains_key("psse_dc_rectifier_tail")
187            || self.extras.contains_key("psse_dc_inverter_tail")
188    }
189
190    /// The LCC operating point of a line read from a PSS/E two-terminal DC
191    /// record, at AC voltage magnitudes `vm_rectifier` (the `from` bus) and
192    /// `vm_inverter` (the `to` bus), per unit: the DC current, and each
193    /// converter's DC voltage, angle, overlap, and reactive demand. See the
194    /// [module](crate::lcc) for the equations.
195    ///
196    /// `None` when the line carries no PSS/E converter record with an AC base
197    /// (a line from another format, or a record left at the default converter
198    /// fields), states no scheduled voltage, uses capacitor commutation, or
199    /// schedules a point no firing or extinction angle reaches at these
200    /// voltages, or one where a converter's power factor is at or below zero
201    /// (an angle of 90 degrees or more, or a DC voltage that is not
202    /// positive). The in service flag is not consulted.
203    ///
204    /// The DC current follows PSS/E's schedule semantics, which differ from
205    /// how the two-terminal reader prices `pf` and `pt`; the
206    /// [module](crate::lcc) states the difference.
207    #[must_use]
208    pub fn calc_lcc_operating_point(
209        &self,
210        vm_rectifier: f64,
211        vm_inverter: f64,
212    ) -> Option<LccOperatingPoint> {
213        let extras = &self.extras;
214        let f64_extra = |key: &str| extras.get(key).and_then(Value::as_f64);
215        let vschd = f64_extra("psse_dc_vschd")?;
216        if vschd <= 0.0 {
217            return None;
218        }
219        let rdc = f64_extra("psse_dc_rdc").unwrap_or(0.0);
220        // The control tail starts at VCMOD; RCOMP follows it.
221        let rcomp = tail(extras, "psse_dc_control_tail")
222            .and_then(|fields| fields.get(1).copied())
223            .unwrap_or(0.0);
224        let mdc = extras
225            .get("psse_dc_mdc")
226            .and_then(Value::as_i64)
227            .unwrap_or(1);
228        let current = if mdc == 2 {
229            // A current schedule the reader priced at the scheduled voltage.
230            self.pf / vschd
231        } else if extras.contains_key("psse_dc_setvl_at_inverter") {
232            // (VSCHD − RCOMP·I)·I = P at the inverter.
233            solve_current(-rcomp, vschd, self.pt)?
234        } else {
235            // (VSCHD + (RDC − RCOMP)·I)·I = P at the rectifier.
236            solve_current(rdc - rcomp, vschd, self.pf)?
237        };
238        let vd_inverter = vschd - rcomp * current;
239        let vd_rectifier = vd_inverter + rdc * current;
240        Some(LccOperatingPoint {
241            dc_current_ka: current,
242            rectifier: bridge(extras, "psse_dc_rectifier_tail").calc_state(
243                vd_rectifier,
244                current,
245                vm_rectifier,
246            )?,
247            inverter: bridge(extras, "psse_dc_inverter_tail").calc_state(
248                vd_inverter,
249                current,
250                vm_inverter,
251            )?,
252        })
253    }
254}
255
256/// The positive root of `a·I² + b·I = p` for the current `I` (kA), with
257/// `b > 0` the scheduled voltage.
258fn solve_current(a: f64, b: f64, p: f64) -> Option<f64> {
259    if p <= 0.0 {
260        return None;
261    }
262    let current = if a.abs() < 1e-12 {
263        p / b
264    } else {
265        let discriminant = b.mul_add(b, 4.0 * a * p);
266        if discriminant < 0.0 {
267            return None;
268        }
269        (discriminant.sqrt() - b) / (2.0 * a)
270    };
271    (current.is_finite() && current > 0.0).then_some(current)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::network::BusId;
278
279    /// A 500 kV, 1000 MW bipole-like line: two bridges per converter.
280    fn line() -> Hvdc {
281        let mut line = Hvdc::new(BusId(1), BusId(2));
282        line.pf = 1000.0;
283        let tail = |values: &[&str]| {
284            Value::Array(values.iter().map(|v| Value::String((*v).into())).collect())
285        };
286        line.extras.insert("psse_dc_rdc".into(), Value::from(10.0));
287        line.extras
288            .insert("psse_dc_vschd".into(), Value::from(500.0));
289        // VCMOD, RCOMP, DELTI, METER, DCVMIN, CCCITMX, CCCACC.
290        line.extras.insert(
291            "psse_dc_control_tail".into(),
292            tail(&["0.0", "0.0", "0.1", "'I'", "0.0", "20", "1.0"]),
293        );
294        // NB, ANMX, ANMN, RC, XC, EBAS, TR, TAP, TMX, TMN, STP, IC, IF, IT, ID, XCAP.
295        let converter = tail(&[
296            "2", "17.5", "12.5", "0.3", "20.0", "345.0", "0.7", "1.0", "1.2", "0.9", "0.0125", "0",
297            "0", "0", "'1'", "0.0",
298        ]);
299        line.extras
300            .insert("psse_dc_rectifier_tail".into(), converter.clone());
301        line.extras
302            .insert("psse_dc_inverter_tail".into(), converter);
303        line
304    }
305
306    #[test]
307    fn the_operating_point_satisfies_the_bridge_equations() {
308        let point = line().calc_lcc_operating_point(1.02, 0.98).unwrap();
309        // (500 + 10·I)·I = 1000 at the rectifier.
310        let i = point.dc_current_ka;
311        assert!(((500.0 + 10.0 * i) * i - 1000.0).abs() < 1e-9);
312        assert!((point.inverter.dc_voltage_kv - 500.0).abs() < 1e-12);
313        assert!((point.rectifier.dc_voltage_kv - (500.0 + 10.0 * i)).abs() < 1e-12);
314        let k = 3.0 * 2.0_f64.sqrt() / std::f64::consts::PI;
315        for (state, vm) in [(point.rectifier, 1.02), (point.inverter, 0.98)] {
316            let e = vm * 345.0 * 0.7;
317            let theta = state.angle_deg.to_radians();
318            let mu = state.overlap_deg.to_radians();
319            let vd = 2.0 * (k * e * theta.cos() - (3.0 * 20.0 / std::f64::consts::PI + 0.6) * i);
320            assert!((vd - state.dc_voltage_kv).abs() < 1e-9);
321            let overlap = theta.cos() - (theta + mu).cos();
322            assert!((overlap - 2.0_f64.sqrt() * 20.0 * i / e).abs() < 1e-12);
323            // A lagging power factor between the angle's cosine and the
324            // average of the two commutation cosines.
325            let tan = state.q_mvar / state.p_mw;
326            let pf = 1.0 / (1.0 + tan * tan).sqrt();
327            assert!(
328                pf < theta.cos() + 1e-12
329                    && pf > f64::midpoint(theta.cos(), (theta + mu).cos()) - 0.02
330            );
331        }
332        // A lower AC voltage leaves less margin: the firing angle shrinks.
333        let low = line().calc_lcc_operating_point(0.97, 0.98).unwrap();
334        assert!(low.rectifier.angle_deg < point.rectifier.angle_deg);
335    }
336
337    #[test]
338    fn an_inverter_measured_schedule_and_a_current_schedule() {
339        let mut line = line();
340        line.extras
341            .insert("psse_dc_setvl_at_inverter".into(), Value::Bool(true));
342        line.pt = 900.0;
343        let point = line.calc_lcc_operating_point(1.0, 1.0).unwrap();
344        assert!(
345            (point.dc_current_ka - 1.8).abs() < 1e-12,
346            "900 MW at 500 kV"
347        );
348
349        let mut line = super::tests::line();
350        line.extras.insert("psse_dc_mdc".into(), Value::from(2));
351        line.pf = 1000.0;
352        let point = line.calc_lcc_operating_point(1.0, 1.0).unwrap();
353        assert!((point.dc_current_ka - 2.0).abs() < 1e-12);
354    }
355
356    #[test]
357    fn a_pss_e_record_reads_into_an_operating_point() {
358        // The converter fields the reader retains are the ones this reads.
359        let raw = "0, 100.00, 33, 0, 0, 60.00 / x
360CASE
361COMMENT
3621,'B1          ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
3634,'B4          ', 345.0,1,1,1,1,1.02,0.0,1.1,0.9,1.1,0.9
3645,'B5          ', 345.0,1,1,1,1,0.98,0.0,1.1,0.9,1.1,0.9
3650 / END OF BUS DATA, BEGIN LOAD DATA
3660 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
3670 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
3680 / END OF GENERATOR DATA, BEGIN BRANCH DATA
3690 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
3700 / END OF TRANSFORMER DATA, BEGIN AREA DATA
3710 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
372'POLE', 1, 10.0, 1000.0, 500.0, 0.0, 0.0, 0.1, 'I', 0.0, 20, 1.0
3734, 2, 17.5, 12.5, 0.3, 20.0, 345.0, 0.7, 1.0, 1.2, 0.9, 0.0125, 0, 0, 0, '1', 0.0
3745, 2, 17.5, 12.5, 0.3, 20.0, 345.0, 0.7, 1.0, 1.2, 0.9, 0.0125, 0, 0, 0, '1', 0.0
3750 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
376Q
377";
378        let parsed = crate::parse_str(raw, "psse").unwrap();
379        let read = &parsed.network.hvdc()[0];
380        let point = read.calc_lcc_operating_point(1.02, 0.98).unwrap();
381        assert_eq!(point, line().calc_lcc_operating_point(1.02, 0.98).unwrap());
382    }
383
384    #[test]
385    fn lines_without_a_converter_record_have_no_operating_point() {
386        let mut plain = Hvdc::new(BusId(1), BusId(2));
387        plain.pf = 100.0;
388        assert!(plain.calc_lcc_operating_point(1.0, 1.0).is_none());
389        let mut default_tail = line();
390        default_tail.extras.remove("psse_dc_rectifier_tail");
391        assert!(default_tail.calc_lcc_operating_point(1.0, 1.0).is_none());
392        // An AC voltage too low for the schedule leaves no angle.
393        assert!(line().calc_lcc_operating_point(0.3, 1.0).is_none());
394        assert!(!plain.has_lcc_converter_record());
395        assert!(line().has_lcc_converter_record());
396    }
397
398    /// One bridge with the given commutating reactance on a 100 kV valve
399    /// side (`EBAS·TR/TAP` at 1 per unit).
400    fn one_bridge(xc: f64) -> Bridge {
401        Bridge {
402            bridges: 1.0,
403            rc: 0.0,
404            xc,
405            ebas: 100.0,
406            tr: 1.0,
407            tap: 1.0,
408            xcap: 0.0,
409        }
410    }
411
412    #[test]
413    fn without_commutation_reactance_the_reactive_power_is_p_tan_alpha() {
414        // No overlap: the fundamental current lags the voltage by the firing
415        // angle itself, so Q = P·tan α exactly. Pick α = 15 degrees.
416        let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
417        let alpha = 15.0_f64.to_radians();
418        let vd = k * 100.0 * alpha.cos();
419        let state = one_bridge(0.0).calc_state(vd, 2.0, 1.0).unwrap();
420        assert!((state.angle_deg - 15.0).abs() < 1e-9);
421        assert!(state.overlap_deg.abs() < 1e-9);
422        assert!((state.q_mvar - state.p_mw * alpha.tan()).abs() < 1e-9);
423    }
424
425    #[test]
426    fn with_overlap_the_power_factor_is_near_the_classical_estimate() {
427        // The exact fundamental power factor and the classical estimate
428        // cos φ ≈ (cos α + cos(α + μ)) / 2 (Kundur, Power System Stability
429        // and Control, section 10.2) agree to within half a percent at
430        // ordinary angles (here α = 15 and μ = 19.5 degrees: 0.8993 against
431        // 0.8952); the reactive power itself differs by a few percent.
432        let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
433        let (xc, id, e) = (5.0, 2.0, 100.0);
434        let alpha = 15.0_f64.to_radians();
435        let vd = k * e * alpha.cos() - 3.0 * xc / std::f64::consts::PI * id;
436        let state = one_bridge(xc).calc_state(vd, id, 1.0).unwrap();
437        assert!((state.angle_deg - 15.0).abs() < 1e-9);
438        let mu = state.overlap_deg.to_radians();
439        assert!(mu > 0.1, "a real overlap: {} degrees", state.overlap_deg);
440        let exact = state.p_mw / state.p_mw.hypot(state.q_mvar);
441        let estimate = f64::midpoint(alpha.cos(), (alpha + mu).cos());
442        assert!(((exact - estimate) / exact).abs() < 0.005);
443    }
444
445    #[test]
446    fn a_power_factor_at_or_below_zero_is_no_operating_point() {
447        // A current schedule with a compounding resistance large enough to
448        // drive the inverter's DC voltage negative: 2 kA through RCOMP = 300 Ω
449        // leaves 500 - 600 = -100 kV, an extinction angle past 90 degrees.
450        let mut line = line();
451        line.extras.insert("psse_dc_mdc".into(), Value::from(2));
452        line.extras.insert(
453            "psse_dc_control_tail".into(),
454            Value::Array(
455                ["0.0", "300.0", "0.1", "'I'", "0.0", "20", "1.0"]
456                    .iter()
457                    .map(|v| Value::String((*v).into()))
458                    .collect(),
459            ),
460        );
461        assert!(line.calc_lcc_operating_point(1.0, 1.0).is_none());
462        // The same bridge at a negative DC voltage directly.
463        let bridge = one_bridge(10.0);
464        assert!(bridge.calc_state(-50.0, 2.0, 1.0).is_none());
465        assert!(bridge.calc_state(0.0, 2.0, 1.0).is_none());
466    }
467
468    #[test]
469    fn the_bridge_current_differs_from_the_reader_pricing() {
470        // SETVL 1000 MW at the rectifier, VSCHD 500 kV, RDC 10 Ω, RCOMP 0.
471        // The reader prices Id = SETVL / VSCHD = 2 kA and pt = 1000 - 10·2² =
472        // 960 MW; the bridge model solves (500 + 10·Id)·Id = 1000.
473        let raw = "0, 100.00, 33, 0, 0, 60.00 / x
474CASE
475COMMENT
4764,'B4          ', 345.0,3,1,1,1,1.02,0.0,1.1,0.9,1.1,0.9
4775,'B5          ', 345.0,1,1,1,1,0.98,0.0,1.1,0.9,1.1,0.9
4780 / END OF BUS DATA, BEGIN LOAD DATA
4790 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
4800 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
4810 / END OF GENERATOR DATA, BEGIN BRANCH DATA
4820 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
4830 / END OF TRANSFORMER DATA, BEGIN AREA DATA
4840 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
485'POLE', 1, 10.0, 1000.0, 500.0, 0.0, 0.0, 0.1, 'I', 0.0, 20, 1.0
4864, 2, 17.5, 12.5, 0.3, 20.0, 345.0, 0.7, 1.0, 1.2, 0.9, 0.0125, 0, 0, 0, '1', 0.0
4875, 2, 17.5, 12.5, 0.3, 20.0, 345.0, 0.7, 1.0, 1.2, 0.9, 0.0125, 0, 0, 0, '1', 0.0
4880 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
489Q
490";
491        let parsed = crate::parse_str(raw, "psse").unwrap();
492        let read = &parsed.network.hvdc()[0];
493        assert!((read.pf - 1000.0).abs() < 1e-9);
494        assert!((read.pt - 960.0).abs() < 1e-9, "reader pt {}", read.pt);
495        let point = read.calc_lcc_operating_point(1.02, 0.98).unwrap();
496        let id = (290_000.0_f64.sqrt() - 500.0) / 20.0;
497        assert!((point.dc_current_ka - id).abs() < 1e-12);
498        assert!((point.dc_current_ka - 1.9258).abs() < 1e-4);
499        assert!((point.inverter.p_mw - 962.91).abs() < 0.01);
500        assert!((point.rectifier.p_mw - 1000.0).abs() < 1e-9);
501    }
502}