1use serde_json::Value;
52
53use crate::network::{Extras, Hvdc};
54
55#[derive(Debug, Clone, Copy, PartialEq)]
57#[non_exhaustive]
58pub struct LccConverterState {
59 pub dc_voltage_kv: f64,
61 pub angle_deg: f64,
64 pub overlap_deg: f64,
66 pub p_mw: f64,
68 pub q_mvar: f64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
74#[non_exhaustive]
75pub struct LccOperatingPoint {
76 pub dc_current_ka: f64,
78 pub rectifier: LccConverterState,
80 pub inverter: LccConverterState,
82}
83
84struct Bridge {
87 bridges: f64,
88 rc: f64,
89 xc: f64,
90 ebas: f64,
91 tr: f64,
92 tap: f64,
93 xcap: f64,
94}
95
96const 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 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 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 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 #[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 #[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 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 self.pf / vschd
231 } else if extras.contains_key("psse_dc_setvl_at_inverter") {
232 solve_current(-rcomp, vschd, self.pt)?
234 } else {
235 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
256fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}