use serde_json::Value;
use crate::network::{Extras, Hvdc};
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct LccConverterState {
pub dc_voltage_kv: f64,
pub angle_deg: f64,
pub overlap_deg: f64,
pub p_mw: f64,
pub q_mvar: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct LccOperatingPoint {
pub dc_current_ka: f64,
pub rectifier: LccConverterState,
pub inverter: LccConverterState,
}
struct Bridge {
bridges: f64,
rc: f64,
xc: f64,
ebas: f64,
tr: f64,
tap: f64,
xcap: f64,
}
const DEFAULT_BRIDGE: [f64; 8] = [1.0, 15.0, 5.0, 0.0, 0.0, 0.0, 1.0, 1.0];
fn number(token: &Value) -> Option<f64> {
token
.as_str()
.and_then(|text| text.trim().parse::<f64>().ok())
.or_else(|| token.as_f64())
.filter(|value| value.is_finite())
}
fn tail(extras: &Extras, key: &str) -> Option<Vec<f64>> {
extras.get(key).map(|value| {
value
.as_array()
.map(|tokens| tokens.iter().map(|t| number(t).unwrap_or(0.0)).collect())
.unwrap_or_default()
})
}
fn bridge(extras: &Extras, key: &str) -> Bridge {
let fields = tail(extras, key).unwrap_or_else(|| DEFAULT_BRIDGE.to_vec());
let at = |i: usize| fields.get(i).copied().unwrap_or(DEFAULT_BRIDGE[i]);
Bridge {
bridges: at(0),
rc: at(3),
xc: at(4),
ebas: at(5),
tr: at(6),
tap: at(7),
xcap: if fields.len() > DEFAULT_BRIDGE.len() {
fields.last().copied().unwrap_or(0.0)
} else {
0.0
},
}
}
impl Bridge {
fn calc_state(&self, vd: f64, id: f64, vm: f64) -> Option<LccConverterState> {
if self.bridges < 1.0 || self.ebas <= 0.0 || self.tap <= 0.0 || self.xcap != 0.0 {
return None;
}
let e = vm * self.ebas * self.tr / self.tap;
if e.is_nan() || e <= 0.0 {
return None;
}
let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
let drop = (3.0 * self.xc / std::f64::consts::PI + 2.0 * self.rc) * id;
let cos_theta = (vd / self.bridges + drop) / (k * e);
if vd <= 0.0 || !(f64::MIN_POSITIVE..=1.0).contains(&cos_theta) {
return None;
}
let theta = cos_theta.acos();
let cos_end = (cos_theta - std::f64::consts::SQRT_2 * self.xc * id / e).clamp(-1.0, 1.0);
let mu = cos_end.acos() - theta;
let p = vd * id;
let denominator = (2.0 * theta).cos() - (2.0 * (theta + mu)).cos();
let q = if denominator.abs() > 1e-12 {
p * (2.0 * mu + (2.0 * theta).sin() - (2.0 * (theta + mu)).sin()) / denominator
} else {
p * theta.tan()
};
q.is_finite().then(|| LccConverterState {
dc_voltage_kv: vd,
angle_deg: theta.to_degrees(),
overlap_deg: mu.to_degrees(),
p_mw: p,
q_mvar: q.abs(),
})
}
}
impl Hvdc {
#[must_use]
pub fn has_lcc_converter_record(&self) -> bool {
self.extras.contains_key("psse_dc_rectifier_tail")
|| self.extras.contains_key("psse_dc_inverter_tail")
}
#[must_use]
pub fn calc_lcc_operating_point(
&self,
vm_rectifier: f64,
vm_inverter: f64,
) -> Option<LccOperatingPoint> {
let extras = &self.extras;
let f64_extra = |key: &str| extras.get(key).and_then(Value::as_f64);
let vschd = f64_extra("psse_dc_vschd")?;
if vschd <= 0.0 {
return None;
}
let rdc = f64_extra("psse_dc_rdc").unwrap_or(0.0);
let rcomp = tail(extras, "psse_dc_control_tail")
.and_then(|fields| fields.get(1).copied())
.unwrap_or(0.0);
let mdc = extras
.get("psse_dc_mdc")
.and_then(Value::as_i64)
.unwrap_or(1);
let current = if mdc == 2 {
self.pf / vschd
} else if extras.contains_key("psse_dc_setvl_at_inverter") {
solve_current(-rcomp, vschd, self.pt)?
} else {
solve_current(rdc - rcomp, vschd, self.pf)?
};
let vd_inverter = vschd - rcomp * current;
let vd_rectifier = vd_inverter + rdc * current;
Some(LccOperatingPoint {
dc_current_ka: current,
rectifier: bridge(extras, "psse_dc_rectifier_tail").calc_state(
vd_rectifier,
current,
vm_rectifier,
)?,
inverter: bridge(extras, "psse_dc_inverter_tail").calc_state(
vd_inverter,
current,
vm_inverter,
)?,
})
}
}
fn solve_current(a: f64, b: f64, p: f64) -> Option<f64> {
if p <= 0.0 {
return None;
}
let current = if a.abs() < 1e-12 {
p / b
} else {
let discriminant = b.mul_add(b, 4.0 * a * p);
if discriminant < 0.0 {
return None;
}
(discriminant.sqrt() - b) / (2.0 * a)
};
(current.is_finite() && current > 0.0).then_some(current)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::BusId;
fn line() -> Hvdc {
let mut line = Hvdc::new(BusId(1), BusId(2));
line.pf = 1000.0;
let tail = |values: &[&str]| {
Value::Array(values.iter().map(|v| Value::String((*v).into())).collect())
};
line.extras.insert("psse_dc_rdc".into(), Value::from(10.0));
line.extras
.insert("psse_dc_vschd".into(), Value::from(500.0));
line.extras.insert(
"psse_dc_control_tail".into(),
tail(&["0.0", "0.0", "0.1", "'I'", "0.0", "20", "1.0"]),
);
let converter = tail(&[
"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",
]);
line.extras
.insert("psse_dc_rectifier_tail".into(), converter.clone());
line.extras
.insert("psse_dc_inverter_tail".into(), converter);
line
}
#[test]
fn the_operating_point_satisfies_the_bridge_equations() {
let point = line().calc_lcc_operating_point(1.02, 0.98).unwrap();
let i = point.dc_current_ka;
assert!(((500.0 + 10.0 * i) * i - 1000.0).abs() < 1e-9);
assert!((point.inverter.dc_voltage_kv - 500.0).abs() < 1e-12);
assert!((point.rectifier.dc_voltage_kv - (500.0 + 10.0 * i)).abs() < 1e-12);
let k = 3.0 * 2.0_f64.sqrt() / std::f64::consts::PI;
for (state, vm) in [(point.rectifier, 1.02), (point.inverter, 0.98)] {
let e = vm * 345.0 * 0.7;
let theta = state.angle_deg.to_radians();
let mu = state.overlap_deg.to_radians();
let vd = 2.0 * (k * e * theta.cos() - (3.0 * 20.0 / std::f64::consts::PI + 0.6) * i);
assert!((vd - state.dc_voltage_kv).abs() < 1e-9);
let overlap = theta.cos() - (theta + mu).cos();
assert!((overlap - 2.0_f64.sqrt() * 20.0 * i / e).abs() < 1e-12);
let tan = state.q_mvar / state.p_mw;
let pf = 1.0 / (1.0 + tan * tan).sqrt();
assert!(
pf < theta.cos() + 1e-12
&& pf > f64::midpoint(theta.cos(), (theta + mu).cos()) - 0.02
);
}
let low = line().calc_lcc_operating_point(0.97, 0.98).unwrap();
assert!(low.rectifier.angle_deg < point.rectifier.angle_deg);
}
#[test]
fn an_inverter_measured_schedule_and_a_current_schedule() {
let mut line = line();
line.extras
.insert("psse_dc_setvl_at_inverter".into(), Value::Bool(true));
line.pt = 900.0;
let point = line.calc_lcc_operating_point(1.0, 1.0).unwrap();
assert!(
(point.dc_current_ka - 1.8).abs() < 1e-12,
"900 MW at 500 kV"
);
let mut line = super::tests::line();
line.extras.insert("psse_dc_mdc".into(), Value::from(2));
line.pf = 1000.0;
let point = line.calc_lcc_operating_point(1.0, 1.0).unwrap();
assert!((point.dc_current_ka - 2.0).abs() < 1e-12);
}
#[test]
fn a_pss_e_record_reads_into_an_operating_point() {
let raw = "0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.0,0.0,1.1,0.9,1.1,0.9
4,'B4 ', 345.0,1,1,1,1,1.02,0.0,1.1,0.9,1.1,0.9
5,'B5 ', 345.0,1,1,1,1,0.98,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
'POLE', 1, 10.0, 1000.0, 500.0, 0.0, 0.0, 0.1, 'I', 0.0, 20, 1.0
4, 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
5, 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
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
let read = &parsed.network.hvdc()[0];
let point = read.calc_lcc_operating_point(1.02, 0.98).unwrap();
assert_eq!(point, line().calc_lcc_operating_point(1.02, 0.98).unwrap());
}
#[test]
fn lines_without_a_converter_record_have_no_operating_point() {
let mut plain = Hvdc::new(BusId(1), BusId(2));
plain.pf = 100.0;
assert!(plain.calc_lcc_operating_point(1.0, 1.0).is_none());
let mut default_tail = line();
default_tail.extras.remove("psse_dc_rectifier_tail");
assert!(default_tail.calc_lcc_operating_point(1.0, 1.0).is_none());
assert!(line().calc_lcc_operating_point(0.3, 1.0).is_none());
assert!(!plain.has_lcc_converter_record());
assert!(line().has_lcc_converter_record());
}
fn one_bridge(xc: f64) -> Bridge {
Bridge {
bridges: 1.0,
rc: 0.0,
xc,
ebas: 100.0,
tr: 1.0,
tap: 1.0,
xcap: 0.0,
}
}
#[test]
fn without_commutation_reactance_the_reactive_power_is_p_tan_alpha() {
let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
let alpha = 15.0_f64.to_radians();
let vd = k * 100.0 * alpha.cos();
let state = one_bridge(0.0).calc_state(vd, 2.0, 1.0).unwrap();
assert!((state.angle_deg - 15.0).abs() < 1e-9);
assert!(state.overlap_deg.abs() < 1e-9);
assert!((state.q_mvar - state.p_mw * alpha.tan()).abs() < 1e-9);
}
#[test]
fn with_overlap_the_power_factor_is_near_the_classical_estimate() {
let k = 3.0 * std::f64::consts::SQRT_2 / std::f64::consts::PI;
let (xc, id, e) = (5.0, 2.0, 100.0);
let alpha = 15.0_f64.to_radians();
let vd = k * e * alpha.cos() - 3.0 * xc / std::f64::consts::PI * id;
let state = one_bridge(xc).calc_state(vd, id, 1.0).unwrap();
assert!((state.angle_deg - 15.0).abs() < 1e-9);
let mu = state.overlap_deg.to_radians();
assert!(mu > 0.1, "a real overlap: {} degrees", state.overlap_deg);
let exact = state.p_mw / state.p_mw.hypot(state.q_mvar);
let estimate = f64::midpoint(alpha.cos(), (alpha + mu).cos());
assert!(((exact - estimate) / exact).abs() < 0.005);
}
#[test]
fn a_power_factor_at_or_below_zero_is_no_operating_point() {
let mut line = line();
line.extras.insert("psse_dc_mdc".into(), Value::from(2));
line.extras.insert(
"psse_dc_control_tail".into(),
Value::Array(
["0.0", "300.0", "0.1", "'I'", "0.0", "20", "1.0"]
.iter()
.map(|v| Value::String((*v).into()))
.collect(),
),
);
assert!(line.calc_lcc_operating_point(1.0, 1.0).is_none());
let bridge = one_bridge(10.0);
assert!(bridge.calc_state(-50.0, 2.0, 1.0).is_none());
assert!(bridge.calc_state(0.0, 2.0, 1.0).is_none());
}
#[test]
fn the_bridge_current_differs_from_the_reader_pricing() {
let raw = "0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
4,'B4 ', 345.0,3,1,1,1,1.02,0.0,1.1,0.9,1.1,0.9
5,'B5 ', 345.0,1,1,1,1,0.98,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA
'POLE', 1, 10.0, 1000.0, 500.0, 0.0, 0.0, 0.1, 'I', 0.0, 20, 1.0
4, 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
5, 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
0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA
Q
";
let parsed = crate::parse_str(raw, "psse").unwrap();
let read = &parsed.network.hvdc()[0];
assert!((read.pf - 1000.0).abs() < 1e-9);
assert!((read.pt - 960.0).abs() < 1e-9, "reader pt {}", read.pt);
let point = read.calc_lcc_operating_point(1.02, 0.98).unwrap();
let id = (290_000.0_f64.sqrt() - 500.0) / 20.0;
assert!((point.dc_current_ka - id).abs() < 1e-12);
assert!((point.dc_current_ka - 1.9258).abs() < 1e-4);
assert!((point.inverter.p_mw - 962.91).abs() < 0.01);
assert!((point.rectifier.p_mw - 1000.0).abs() < 1e-9);
}
}