use crate::error::OxiGridError;
#[cfg(feature = "renewable")]
use crate::renewable::wind::offshore::OffshoreWindFarm;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HvdcType {
Lcc,
Vsc,
HvdcLight,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HvdcLink {
pub id: usize,
pub name: String,
pub from_bus: usize,
pub to_bus: usize,
pub hvdc_type: HvdcType,
pub p_rated_mw: f64,
pub p_setpoint_mw: f64,
pub v_dc_kv: f64,
pub length_km: f64,
pub resistance_ohm_per_km: f64,
pub losses_pct: f64,
pub q_from_min: f64,
pub q_from_max: f64,
pub q_to_min: f64,
pub q_to_max: f64,
pub q_from_setpoint: f64,
pub q_to_setpoint: f64,
pub in_service: bool,
}
impl HvdcLink {
pub fn new_vsc(id: usize, from: usize, to: usize, p_mw: f64, v_dc_kv: f64) -> Self {
let q_cap = 0.3 * p_mw;
Self {
id,
name: format!("VSC-HVDC-{id}"),
from_bus: from,
to_bus: to,
hvdc_type: HvdcType::Vsc,
p_rated_mw: p_mw.abs(),
p_setpoint_mw: p_mw,
v_dc_kv,
length_km: 0.0,
resistance_ohm_per_km: 0.011,
losses_pct: 1.5,
q_from_min: -q_cap,
q_from_max: q_cap,
q_to_min: -q_cap,
q_to_max: q_cap,
q_from_setpoint: 0.0,
q_to_setpoint: 0.0,
in_service: true,
}
}
pub fn new_lcc(id: usize, from: usize, to: usize, p_mw: f64, v_dc_kv: f64) -> Self {
Self {
id,
name: format!("LCC-HVDC-{id}"),
from_bus: from,
to_bus: to,
hvdc_type: HvdcType::Lcc,
p_rated_mw: p_mw.abs(),
p_setpoint_mw: p_mw.abs(), v_dc_kv,
length_km: 0.0,
resistance_ohm_per_km: 0.011,
losses_pct: 1.5,
q_from_min: 0.0,
q_from_max: 0.0,
q_to_min: 0.0,
q_to_max: 0.0,
q_from_setpoint: 0.0,
q_to_setpoint: 0.0,
in_service: true,
}
}
pub fn cable_losses_mw(&self) -> f64 {
if !self.in_service {
return 0.0;
}
let p = self.p_setpoint_mw.abs();
if self.length_km > 0.0 && self.v_dc_kv > 0.0 {
let r_total = self.resistance_ohm_per_km * self.length_km; let i_ka = p / self.v_dc_kv; i_ka.powi(2) * r_total } else {
p * self.losses_pct / 200.0
}
}
pub fn converter_losses_mw(&self) -> f64 {
if !self.in_service {
return 0.0;
}
let p = self.p_setpoint_mw.abs();
match self.hvdc_type {
HvdcType::Lcc => p * 0.012,
HvdcType::Vsc | HvdcType::HvdcLight => p * 0.020,
}
}
pub fn power_delivered_mw(&self) -> f64 {
if !self.in_service {
return 0.0;
}
let gross = self.p_setpoint_mw;
let losses = self.cable_losses_mw() + self.converter_losses_mw();
(gross.abs() - losses).max(0.0) * gross.signum()
}
pub fn p_range(&self) -> (f64, f64) {
match self.hvdc_type {
HvdcType::Lcc => (0.0, self.p_rated_mw),
HvdcType::Vsc | HvdcType::HvdcLight => (-self.p_rated_mw, self.p_rated_mw),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum MtdcControlMode {
ConstantPower,
ConstantVoltage,
VoltageMargin { margin_kv: f64 },
VoltageDroop { droop: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MtdcConverter {
pub id: usize,
pub ac_bus: usize,
pub dc_bus: usize,
pub p_rated_mw: f64,
pub control_mode: MtdcControlMode,
pub p_setpoint_mw: f64,
pub v_dc_setpoint_kv: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DcBus {
pub id: usize,
pub v_rated_kv: f64,
pub v_dc: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DcLine {
pub id: usize,
pub from_dc_bus: usize,
pub to_dc_bus: usize,
pub resistance_ohm: f64,
pub rating_mw: f64,
}
#[derive(Debug, Clone)]
pub struct MtdcGrid {
pub id: usize,
pub converters: Vec<MtdcConverter>,
pub dc_buses: Vec<DcBus>,
pub dc_lines: Vec<DcLine>,
pub slack_converter: usize,
}
impl MtdcGrid {
pub fn new(
converters: Vec<MtdcConverter>,
dc_buses: Vec<DcBus>,
dc_lines: Vec<DcLine>,
slack: usize,
) -> Self {
Self {
id: 0,
converters,
dc_buses,
dc_lines,
slack_converter: slack,
}
}
pub fn solve_dc_power_flow(&mut self) -> Result<Vec<f64>, OxiGridError> {
let n = self.dc_buses.len();
if n == 0 {
return Err(OxiGridError::InvalidNetwork(
"MTDC grid has no DC buses".to_string(),
));
}
let bus_index: std::collections::HashMap<usize, usize> = self
.dc_buses
.iter()
.enumerate()
.map(|(i, b)| (b.id, i))
.collect();
let mut g = vec![vec![0.0_f64; n]; n];
for line in &self.dc_lines {
let i = match bus_index.get(&line.from_dc_bus) {
Some(&idx) => idx,
None => {
return Err(OxiGridError::InvalidNetwork(format!(
"DC line {} references unknown from_dc_bus {}",
line.id, line.from_dc_bus
)))
}
};
let j = match bus_index.get(&line.to_dc_bus) {
Some(&idx) => idx,
None => {
return Err(OxiGridError::InvalidNetwork(format!(
"DC line {} references unknown to_dc_bus {}",
line.id, line.to_dc_bus
)))
}
};
let y = if line.resistance_ohm.abs() > 1e-12 {
1.0 / line.resistance_ohm
} else {
return Err(OxiGridError::InvalidParameter(format!(
"DC line {} has zero resistance",
line.id
)));
};
g[i][i] += y;
g[j][j] += y;
g[i][j] -= y;
g[j][i] -= y;
}
let mut i_inj = vec![0.0_f64; n];
for conv in &self.converters {
let dc_idx = match bus_index.get(&conv.dc_bus) {
Some(&idx) => idx,
None => {
return Err(OxiGridError::InvalidNetwork(format!(
"Converter {} references unknown dc_bus {}",
conv.id, conv.dc_bus
)))
}
};
let v_nom = self.dc_buses[dc_idx].v_rated_kv.max(1.0);
i_inj[dc_idx] += conv.p_setpoint_mw / v_nom;
}
let slack_dc_bus = self
.converters
.get(self.slack_converter)
.map(|c| c.dc_bus)
.ok_or_else(|| {
OxiGridError::InvalidNetwork("slack_converter index out of range".to_string())
})?;
let slack_idx = match bus_index.get(&slack_dc_bus) {
Some(&idx) => idx,
None => {
return Err(OxiGridError::InvalidNetwork(
"Slack converter DC bus not found".to_string(),
))
}
};
let v_slack = self.dc_buses[slack_idx].v_rated_kv;
g[slack_idx][..n].fill(0.0);
g[slack_idx][slack_idx] = 1.0;
i_inj[slack_idx] = v_slack;
let v_dc = gaussian_elimination(&g, &i_inj)
.map_err(|e| OxiGridError::LinearAlgebra(format!("MTDC DC power flow: {e}")))?;
for bus in &mut self.dc_buses {
if let Some(&idx) = bus_index.get(&bus.id) {
bus.v_dc = v_dc[idx];
}
}
Ok(v_dc)
}
pub fn update_converter_powers(&mut self, v_dc: &[f64]) {
if v_dc.len() != self.dc_buses.len() {
return;
}
let bus_index: std::collections::HashMap<usize, usize> = self
.dc_buses
.iter()
.enumerate()
.map(|(i, b)| (b.id, i))
.collect();
for conv in &mut self.converters {
if let Some(&dc_idx) = bus_index.get(&conv.dc_bus) {
if dc_idx < v_dc.len() {
let v = v_dc[dc_idx];
if let MtdcControlMode::VoltageDroop { droop } = conv.control_mode {
let dv = v - conv.v_dc_setpoint_kv;
let dp = droop * dv; conv.p_setpoint_mw =
(conv.p_setpoint_mw + dp).clamp(-conv.p_rated_mw, conv.p_rated_mw);
}
}
}
}
}
}
fn gaussian_elimination(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>, String> {
let n = b.len();
if a.len() != n || a.iter().any(|row| row.len() != n) {
return Err("matrix dimensions mismatch".to_string());
}
let mut mat: Vec<Vec<f64>> = a.to_vec();
let mut rhs: Vec<f64> = b.to_vec();
for col in 0..n {
let pivot_row = (col..n).max_by(|&r1, &r2| {
mat[r1][col]
.abs()
.partial_cmp(&mat[r2][col].abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let pivot_row = pivot_row.ok_or("empty range in gaussian elimination")?;
mat.swap(col, pivot_row);
rhs.swap(col, pivot_row);
let pivot = mat[col][col];
if pivot.abs() < 1e-14 {
return Err(format!("singular matrix at column {col}"));
}
for row in (col + 1)..n {
let factor = mat[row][col] / pivot;
#[allow(clippy::needless_range_loop)]
for k in col..n {
let delta = factor * mat[col][k];
mat[row][k] -= delta;
}
rhs[row] -= factor * rhs[col];
}
}
let mut x = vec![0.0_f64; n];
for i in (0..n).rev() {
let mut s = rhs[i];
for j in (i + 1)..n {
s -= mat[i][j] * x[j];
}
x[i] = s / mat[i][i];
}
Ok(x)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HvdcLosses {
pub converter_losses_mw: f64,
pub cable_losses_mw: f64,
pub transformer_losses_mw: f64,
pub total_losses_mw: f64,
pub efficiency_pct: f64,
}
#[cfg(feature = "renewable")]
pub struct OffshoreHvdcSystem {
pub farms: Vec<OffshoreWindFarm>,
pub hvdc_links: Vec<HvdcLink>,
pub offshore_substation_voltage_kv: f64,
pub total_capacity_mw: f64,
}
#[cfg(feature = "renewable")]
impl OffshoreHvdcSystem {
pub fn new(farms: Vec<OffshoreWindFarm>, links: Vec<HvdcLink>) -> Self {
let total_capacity_mw: f64 = farms.iter().map(|f| f.installed_capacity_mw()).sum();
Self {
farms,
hvdc_links: links,
offshore_substation_voltage_kv: 66.0,
total_capacity_mw,
}
}
pub fn power_to_shore(&self, wind_conditions: &[(f64, f64)]) -> f64 {
if self.farms.is_empty() {
return 0.0;
}
let total_generated: f64 = self
.farms
.iter()
.enumerate()
.map(|(i, farm)| {
let (speed, dir) = wind_conditions
.get(i)
.copied()
.unwrap_or_else(|| *wind_conditions.last().unwrap_or(&(10.0, 270.0)));
farm.compute_power(speed, dir)
})
.sum();
let losses = self.losses_breakdown_for_power(total_generated);
(total_generated - losses.total_losses_mw).max(0.0)
}
fn losses_breakdown_for_power(&self, total_mw: f64) -> HvdcLosses {
let conv_losses: f64 = self
.hvdc_links
.iter()
.map(|l| l.converter_losses_mw())
.sum();
let cable_losses: f64 = self.hvdc_links.iter().map(|l| l.cable_losses_mw()).sum();
let transformer_losses = total_mw * 0.005; let total_losses = conv_losses + cable_losses + transformer_losses;
let efficiency_pct = if total_mw > 0.0 {
100.0 * (1.0 - total_losses / total_mw)
} else {
100.0
};
HvdcLosses {
converter_losses_mw: conv_losses,
cable_losses_mw: cable_losses,
transformer_losses_mw: transformer_losses,
total_losses_mw: total_losses,
efficiency_pct,
}
}
pub fn losses_breakdown(&self, wind_conditions: &[(f64, f64)]) -> HvdcLosses {
let total_generated: f64 = self
.farms
.iter()
.enumerate()
.map(|(i, farm)| {
let (speed, dir) = wind_conditions
.get(i)
.copied()
.unwrap_or_else(|| *wind_conditions.last().unwrap_or(&(10.0, 270.0)));
farm.compute_power(speed, dir)
})
.sum();
self.losses_breakdown_for_power(total_generated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hvdc_cable_losses_positive() {
let mut link = HvdcLink::new_vsc(0, 1, 2, 1000.0, 320.0);
link.length_km = 200.0;
let losses = link.cable_losses_mw();
assert!(losses > 0.0, "cable losses must be positive: {losses}");
}
#[test]
fn test_hvdc_power_delivered_approx() {
let link = HvdcLink::new_vsc(0, 1, 2, 1000.0, 320.0);
let delivered = link.power_delivered_mw();
assert!(
delivered > 950.0 && delivered < 1000.0,
"delivered should be in (950, 1000) MW: {delivered}"
);
}
#[test]
fn test_hvdc_p_range_vsc_bidirectional() {
let link = HvdcLink::new_vsc(0, 1, 2, 500.0, 320.0);
let (min, max) = link.p_range();
assert_eq!(min, -500.0);
assert_eq!(max, 500.0);
}
#[test]
fn test_hvdc_p_range_lcc_unidirectional() {
let link = HvdcLink::new_lcc(0, 1, 2, 500.0, 500.0);
let (min, max) = link.p_range();
assert_eq!(min, 0.0);
assert_eq!(max, 500.0);
}
#[test]
fn test_hvdc_converter_losses_vsc_higher_than_lcc() {
let vsc = HvdcLink::new_vsc(0, 1, 2, 1000.0, 320.0);
let lcc = HvdcLink::new_lcc(1, 1, 2, 1000.0, 500.0);
assert!(
vsc.converter_losses_mw() > lcc.converter_losses_mw(),
"VSC losses ({}) should exceed LCC losses ({})",
vsc.converter_losses_mw(),
lcc.converter_losses_mw()
);
}
#[test]
fn test_hvdc_out_of_service_zero_losses() {
let mut link = HvdcLink::new_vsc(0, 1, 2, 1000.0, 320.0);
link.in_service = false;
assert_eq!(link.cable_losses_mw(), 0.0);
assert_eq!(link.converter_losses_mw(), 0.0);
assert_eq!(link.power_delivered_mw(), 0.0);
}
fn make_simple_mtdc() -> MtdcGrid {
let dc_buses = vec![
DcBus {
id: 0,
v_rated_kv: 320.0,
v_dc: 320.0,
},
DcBus {
id: 1,
v_rated_kv: 320.0,
v_dc: 320.0,
},
];
let dc_lines = vec![DcLine {
id: 0,
from_dc_bus: 0,
to_dc_bus: 1,
resistance_ohm: 1.0,
rating_mw: 1000.0,
}];
let converters = vec![
MtdcConverter {
id: 0,
ac_bus: 0,
dc_bus: 0,
p_rated_mw: 500.0,
control_mode: MtdcControlMode::ConstantVoltage,
p_setpoint_mw: 300.0,
v_dc_setpoint_kv: 320.0,
},
MtdcConverter {
id: 1,
ac_bus: 1,
dc_bus: 1,
p_rated_mw: 500.0,
control_mode: MtdcControlMode::ConstantPower,
p_setpoint_mw: -300.0,
v_dc_setpoint_kv: 320.0,
},
];
MtdcGrid::new(converters, dc_buses, dc_lines, 0)
}
#[test]
fn test_mtdc_power_flow_solves() {
let mut grid = make_simple_mtdc();
let v_dc = grid
.solve_dc_power_flow()
.expect("MTDC power flow should solve");
assert_eq!(v_dc.len(), 2);
assert!(
(v_dc[0] - 320.0).abs() < 1.0,
"slack bus voltage: {:.2} kV",
v_dc[0]
);
}
#[test]
fn test_mtdc_power_flow_balance() {
let mut grid = make_simple_mtdc();
let v_dc = grid.solve_dc_power_flow().expect("MTDC power flow");
let delta_v = (v_dc[0] - v_dc[1]).abs();
let i_line = delta_v / 1.0; let v_avg = (v_dc[0] + v_dc[1]) / 2.0;
let p_line_mw = i_line * v_avg;
assert!(
p_line_mw >= 0.0,
"line power must be non-negative: {p_line_mw}"
);
}
#[test]
fn test_mtdc_update_droop_converter() {
let dc_buses = vec![
DcBus {
id: 0,
v_rated_kv: 320.0,
v_dc: 320.0,
},
DcBus {
id: 1,
v_rated_kv: 320.0,
v_dc: 320.0,
},
];
let dc_lines = vec![DcLine {
id: 0,
from_dc_bus: 0,
to_dc_bus: 1,
resistance_ohm: 0.5,
rating_mw: 500.0,
}];
let converters = vec![
MtdcConverter {
id: 0,
ac_bus: 0,
dc_bus: 0,
p_rated_mw: 500.0,
control_mode: MtdcControlMode::ConstantVoltage,
p_setpoint_mw: 200.0,
v_dc_setpoint_kv: 320.0,
},
MtdcConverter {
id: 1,
ac_bus: 1,
dc_bus: 1,
p_rated_mw: 500.0,
control_mode: MtdcControlMode::VoltageDroop { droop: 10.0 },
p_setpoint_mw: -150.0,
v_dc_setpoint_kv: 320.0,
},
];
let mut grid = MtdcGrid::new(converters, dc_buses, dc_lines, 0);
let v_dc = grid.solve_dc_power_flow().expect("solve");
let p_before = grid.converters[1].p_setpoint_mw;
grid.update_converter_powers(&v_dc);
let p_after = grid.converters[1].p_setpoint_mw;
let _ = (p_before, p_after); }
#[cfg(feature = "renewable")]
#[test]
fn test_offshore_hvdc_system_power_to_shore() {
use crate::renewable::wind::offshore::{OffshoreWindFarm, OffshoreWindTurbine};
let turbines: Vec<OffshoreWindTurbine> = (0..4)
.map(|i| OffshoreWindTurbine::new_15mw(i, (i as f64) * 1400.0, 0.0))
.collect();
let farm = OffshoreWindFarm::new(turbines, 10, 1);
let link = HvdcLink::new_vsc(0, 10, 1, 200.0, 320.0);
let system = OffshoreHvdcSystem::new(vec![farm], vec![link]);
let power = system.power_to_shore(&[(10.0, 270.0)]);
assert!(power >= 0.0, "power to shore must be non-negative: {power}");
}
#[cfg(feature = "renewable")]
#[test]
fn test_offshore_hvdc_losses_breakdown_efficiency() {
use crate::renewable::wind::offshore::{OffshoreWindFarm, OffshoreWindTurbine};
let turbines: Vec<OffshoreWindTurbine> = (0..4)
.map(|i| OffshoreWindTurbine::new_15mw(i, (i as f64) * 1400.0, 0.0))
.collect();
let farm = OffshoreWindFarm::new(turbines, 10, 1);
let mut link = HvdcLink::new_vsc(0, 10, 1, 200.0, 320.0);
link.p_setpoint_mw = 50.0;
let system = OffshoreHvdcSystem::new(vec![farm], vec![link]);
let losses = system.losses_breakdown(&[(10.0, 270.0)]);
assert!(
losses.efficiency_pct > 50.0 && losses.efficiency_pct <= 100.0,
"efficiency should be reasonable: {:.1}%",
losses.efficiency_pct
);
}
}