use std::collections::HashMap;
use std::path::Path;
use num_complex::Complex;
use crate::network::ShuntAdm;
use crate::ratings::BranchLimits;
use crate::types::{Bus, BusType, Line, TapChanger, Transformer};
pub const DEFAULT_BASE_MVA: f64 = 100.0;
#[derive(Debug)]
pub enum UcteError {
Io(std::io::Error),
UnknownNode {
record: String,
node: String,
},
BadVoltageLevel {
node: String,
code: char,
},
TruncatedRecord {
block: char,
line: usize,
},
NoNodes,
DataOutsideBlock {
line: usize,
},
}
impl std::fmt::Display for UcteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UcteError::Io(e) => write!(f, "reading UCTE file: {e}"),
UcteError::UnknownNode { record, node } => {
write!(f, "record `{record}` refers to undefined node `{node}`")
}
UcteError::BadVoltageLevel { node, code } => {
write!(f, "node `{node}` has unknown voltage-level code `{code}`")
}
UcteError::TruncatedRecord { block, line } => {
write!(f, "##{block} record on line {line} is too short")
}
UcteError::NoNodes => write!(f, "file defines no nodes"),
UcteError::DataOutsideBlock { line } => {
write!(f, "line {line} carries data before any `##` block header")
}
}
}
}
impl std::error::Error for UcteError {}
impl From<std::io::Error> for UcteError {
fn from(e: std::io::Error) -> Self {
UcteError::Io(e)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum SlackPolicy {
#[default]
LargestGeneration,
Node(String),
}
#[derive(Clone, Debug)]
pub struct UcteOptions {
pub base_mva: f64,
pub slack: SlackPolicy,
pub nominal_voltages: Vec<(f64, f64)>,
}
impl UcteOptions {
pub fn core_capacity_calculation() -> Self {
Self {
nominal_voltages: vec![(380_000.0, 400_000.0), (220_000.0, 225_000.0)],
..Default::default()
}
}
}
impl Default for UcteOptions {
fn default() -> Self {
Self {
base_mva: DEFAULT_BASE_MVA,
slack: SlackPolicy::default(),
nominal_voltages: Vec::new(),
}
}
}
#[derive(Debug)]
pub struct UcteImport {
pub buses: Vec<Bus>,
pub lines: Vec<Line>,
pub transformers: Vec<Transformer>,
pub shunts: Vec<ShuntAdm>,
pub node_codes: Vec<String>,
pub bus_countries: Vec<Option<String>>,
pub node_index: HashMap<String, usize>,
pub branch_ids: Vec<String>,
pub limits: Vec<BranchLimits>,
pub tap_changers: Vec<Option<TapChanger>>,
pub regulation: Vec<crate::outerloop::TapRegulation>,
pub slack: usize,
pub generation: Vec<f64>,
pub p_limits: Vec<Option<(f64, f64)>>,
pub base_mva: f64,
pub notes: Vec<String>,
pub initially_open: Vec<usize>,
pub out_of_service: Vec<String>,
}
impl UcteImport {
pub fn as_switched(&self) -> (Vec<Line>, Vec<Transformer>) {
let mut lines = self.lines.clone();
let mut transformers = self.transformers.clone();
for &branch in &self.initially_open {
if branch < lines.len() {
lines[branch].r = crate::topology::reduction::OPEN_BRANCH_Z;
lines[branch].x = crate::topology::reduction::OPEN_BRANCH_Z;
lines[branch].b_shunt = 0.0;
lines[branch].g_shunt = 0.0;
} else if let Some(t) = transformers.get_mut(branch - lines.len()) {
t.from_status = 0;
t.to_status = 0;
}
}
(lines, transformers)
}
pub fn s_base_va(&self) -> f64 {
self.base_mva * 1e6
}
pub fn country_areas(&self) -> (Vec<Option<usize>>, Vec<String>) {
let mut names: Vec<String> = self.bus_countries.iter().flatten().cloned().collect();
names.sort();
names.dedup();
let of_bus = self
.bus_countries
.iter()
.map(|c| c.as_ref().and_then(|c| names.iter().position(|n| n == c)))
.collect();
(of_bus, names)
}
pub fn n_branches(&self) -> usize {
self.lines.len() + self.transformers.len()
}
pub fn transformer_branch(&self, i: usize) -> usize {
self.lines.len() + i
}
}
struct Record<'a> {
raw: &'a [u8],
}
impl<'a> Record<'a> {
fn new(raw: &'a [u8]) -> Self {
Self { raw }
}
fn slice(&self, from: usize, to: usize) -> &'a [u8] {
let lo = from.min(self.raw.len());
let hi = to.min(self.raw.len());
if lo >= hi { &[] } else { &self.raw[lo..hi] }
}
fn text(&self, from: usize, to: usize) -> String {
self.slice(from, to).iter().map(|&b| b as char).collect()
}
fn trimmed(&self, from: usize, to: usize) -> String {
self.text(from, to).trim().to_string()
}
fn number(&self, from: usize, to: usize) -> Option<f64> {
let s = self.trimmed(from, to);
if s.is_empty() {
None
} else {
s.parse::<f64>().ok()
}
}
fn integer(&self, from: usize, to: usize) -> Option<i32> {
let s = self.trimmed(from, to);
if s.is_empty() {
None
} else {
s.parse::<i32>().ok()
}
}
fn char_at(&self, i: usize) -> Option<char> {
self.raw.get(i).map(|&b| b as char)
}
fn digit_at(&self, i: usize) -> Option<i32> {
self.char_at(i)
.and_then(|c| c.to_digit(10))
.map(|d| d as i32)
}
fn is_blank(&self) -> bool {
self.raw.iter().all(|b| b.is_ascii_whitespace())
}
}
fn voltage_level_v(code: char) -> Option<f64> {
let kv = match code {
'0' => 750.0,
'1' => 380.0,
'2' => 220.0,
'3' => 150.0,
'4' => 120.0,
'5' => 110.0,
'6' => 70.0,
'7' => 27.0,
'8' => 330.0,
'9' => 500.0,
_ => return None,
};
Some(kv * 1000.0)
}
#[derive(Clone, Debug)]
struct RawNode {
code: String,
country: Option<String>,
type_code: i32,
voltage_reference: Option<f64>,
active_load: Option<f64>,
reactive_load: Option<f64>,
active_generation: Option<f64>,
reactive_generation: Option<f64>,
min_p: Option<f64>,
max_p: Option<f64>,
min_q: Option<f64>,
max_q: Option<f64>,
}
impl RawNode {
fn generation_mw(&self) -> f64 {
-self.active_generation.unwrap_or(0.0)
}
fn generation_mvar(&self) -> f64 {
-self.reactive_generation.unwrap_or(0.0)
}
fn regulates_voltage(&self) -> bool {
self.type_code == 2 || self.type_code == 3
}
}
#[derive(Clone, Debug)]
struct RawBranch {
id: String,
node1: String,
node2: String,
status: i32,
r: f64,
x: f64,
b: f64,
current_limit: Option<f64>,
transformer: Option<RawTransformerFields>,
}
#[derive(Clone, Debug)]
struct RawTransformerFields {
rated_v1: f64,
rated_v2: f64,
g: f64,
}
#[derive(Clone, Debug, Default)]
struct RawRegulation {
ratio: Option<RatioRegulation>,
angle: Option<AngleRegulation>,
}
#[derive(Clone, Copy, Debug)]
struct RatioRegulation {
du_percent: f64,
n: i32,
position: i32,
target_kv: Option<f64>,
}
#[derive(Clone, Copy, Debug)]
struct AngleRegulation {
du_percent: f64,
theta_deg: f64,
n: i32,
position: i32,
symmetrical: bool,
target_mw: Option<f64>,
}
fn status_in_service(status: i32) -> bool {
!matches!(status, 7 | 8 | 9)
}
fn status_is_coupler(status: i32) -> bool {
matches!(status, 2 | 7)
}
pub fn read(path: impl AsRef<Path>) -> Result<UcteImport, UcteError> {
read_with(path, &UcteOptions::default())
}
pub fn read_with(path: impl AsRef<Path>, options: &UcteOptions) -> Result<UcteImport, UcteError> {
let bytes = std::fs::read(path)?;
parse_with(&bytes, options)
}
pub fn parse(bytes: &[u8]) -> Result<UcteImport, UcteError> {
parse_with(bytes, &UcteOptions::default())
}
pub fn parse_with(bytes: &[u8], options: &UcteOptions) -> Result<UcteImport, UcteError> {
let mut notes = Vec::new();
let (nodes, branches, regulations) = parse_records(bytes, &mut notes)?;
convert(nodes, branches, regulations, options, notes)
}
#[derive(Clone, Copy, PartialEq)]
enum Block {
None,
Nodes,
Lines,
Transformers,
Regulations,
Skip,
}
fn parse_records(
bytes: &[u8],
notes: &mut Vec<String>,
) -> Result<(Vec<RawNode>, Vec<RawBranch>, HashMap<String, RawRegulation>), UcteError> {
let mut nodes = Vec::new();
let mut branches = Vec::new();
let mut regulations: HashMap<String, RawRegulation> = HashMap::new();
let mut block = Block::None;
let mut country: Option<String> = None;
let mut skipped: Vec<String> = Vec::new();
for (n, raw) in bytes.split(|&b| b == b'\n').enumerate() {
let raw = raw.strip_suffix(b"\r").unwrap_or(raw);
let record = Record::new(raw);
if raw.starts_with(b"##") {
let tag = record.text(2, raw.len());
let tag = tag.trim_end();
block = if tag.starts_with("TT") {
if !skipped.contains(&"TT".to_string()) {
skipped.push("TT".to_string());
}
Block::Skip
} else if tag.starts_with('N') || tag.starts_with('Z') {
if let Some(code) = tag.strip_prefix('Z') {
let code = code.trim();
country = (!code.is_empty()).then(|| code.to_string());
}
Block::Nodes
} else if tag.starts_with('L') {
Block::Lines
} else if tag.starts_with('T') {
Block::Transformers
} else if tag.starts_with('R') {
Block::Regulations
} else if tag.starts_with('C') {
Block::Skip
} else {
let name = tag.split_whitespace().next().unwrap_or("").to_string();
if !name.is_empty() && !skipped.contains(&name) {
skipped.push(name);
}
Block::Skip
};
continue;
}
if record.is_blank() {
continue;
}
match block {
Block::Nodes => {
let mut node = parse_node(&record, n + 1)?;
node.country = country.clone();
nodes.push(node);
}
Block::Lines => branches.push(parse_line(&record, n + 1)?),
Block::Transformers => branches.push(parse_transformer(&record, n + 1)?),
Block::Regulations => {
let (id, reg) = parse_regulation(&record, n + 1)?;
regulations.insert(id, reg);
}
Block::Skip => {}
Block::None => return Err(UcteError::DataOutsideBlock { line: n + 1 }),
}
}
for name in skipped {
notes.push(format!("skipped unsupported `##{name}` block"));
}
if nodes.is_empty() {
return Err(UcteError::NoNodes);
}
Ok((nodes, branches, regulations))
}
fn parse_node(record: &Record<'_>, line: usize) -> Result<RawNode, UcteError> {
if record.raw.len() < 25 {
return Err(UcteError::TruncatedRecord { block: 'N', line });
}
Ok(RawNode {
country: None,
code: record.text(0, 8),
type_code: record.digit_at(24).unwrap_or(0),
voltage_reference: record.number(26, 32),
active_load: record.number(33, 40),
reactive_load: record.number(41, 48),
active_generation: record.number(49, 56),
reactive_generation: record.number(57, 64),
min_p: record.number(65, 72),
max_p: record.number(73, 80),
min_q: record.number(81, 88),
max_q: record.number(89, 96),
})
}
fn element_id(record: &Record<'_>) -> (String, String, String) {
(record.text(0, 19), record.text(0, 8), record.text(9, 17))
}
fn parse_line(record: &Record<'_>, line: usize) -> Result<RawBranch, UcteError> {
if record.raw.len() < 21 {
return Err(UcteError::TruncatedRecord { block: 'L', line });
}
let (id, node1, node2) = element_id(record);
Ok(RawBranch {
id,
node1,
node2,
status: record.digit_at(20).unwrap_or(0),
r: record.number(22, 28).unwrap_or(0.0),
x: record.number(29, 35).unwrap_or(0.0),
b: record.number(36, 44).unwrap_or(0.0) * 1e-6,
current_limit: record.number(45, 51),
transformer: None,
})
}
fn parse_transformer(record: &Record<'_>, line: usize) -> Result<RawBranch, UcteError> {
if record.raw.len() < 21 {
return Err(UcteError::TruncatedRecord { block: 'T', line });
}
let (id, node1, node2) = element_id(record);
Ok(RawBranch {
id,
node1,
node2,
status: record.digit_at(20).unwrap_or(0),
r: record.number(40, 46).unwrap_or(0.0),
x: record.number(47, 53).unwrap_or(0.0),
b: record.number(54, 62).unwrap_or(0.0) * 1e-6,
current_limit: record.number(70, 76),
transformer: Some(RawTransformerFields {
rated_v1: record.number(22, 27).unwrap_or(0.0),
rated_v2: record.number(28, 33).unwrap_or(0.0),
g: record.number(63, 69).unwrap_or(0.0) * 1e-6,
}),
})
}
fn parse_regulation(
record: &Record<'_>,
line: usize,
) -> Result<(String, RawRegulation), UcteError> {
if record.raw.len() < 19 {
return Err(UcteError::TruncatedRecord { block: 'R', line });
}
let id = record.text(0, 19);
let mut reg = RawRegulation::default();
let du = record.number(20, 25);
let n = record.integer(26, 28);
let np = record.integer(29, 32);
let target_kv = record.number(33, 38);
if du.is_some() || n.is_some() || np.is_some() || target_kv.is_some() {
reg.ratio = Some(RatioRegulation {
du_percent: du.unwrap_or(0.0),
n: n.unwrap_or(0),
position: np.unwrap_or(0),
target_kv,
});
}
let adu = record.number(39, 44);
let theta = record.number(45, 50);
let an = record.integer(51, 53);
let anp = record.integer(54, 57);
let target_mw = record.number(58, 63);
let kind = record.trimmed(64, 68);
if adu.is_some()
|| theta.is_some()
|| an.is_some()
|| anp.is_some()
|| target_mw.is_some()
|| !kind.is_empty()
{
reg.angle = Some(AngleRegulation {
du_percent: adu.unwrap_or(0.0),
theta_deg: theta.unwrap_or(0.0),
n: an.unwrap_or(0),
position: anp.unwrap_or(0),
target_mw,
symmetrical: !kind.eq_ignore_ascii_case("ASYM"),
});
}
let _ = line;
Ok((id, reg))
}
fn ratio_multiplier(reg: &RatioRegulation, position: i32) -> Complex<f64> {
Complex::new(1.0 + position as f64 * reg.du_percent / 100.0, 0.0)
}
fn angle_multiplier(reg: &AngleRegulation, position: i32) -> Complex<f64> {
let du = position as f64 * reg.du_percent / 100.0;
let theta = reg.theta_deg.to_radians();
let dx = du * theta.cos();
let dy = du * theta.sin();
if reg.symmetrical {
let alpha = 2.0 * (dy / 2.0).atan2(1.0 + dx);
Complex::from_polar(1.0, alpha)
} else {
Complex::new(1.0 + dx, dy)
}
}
fn convert(
nodes: Vec<RawNode>,
branches: Vec<RawBranch>,
regulations: HashMap<String, RawRegulation>,
options: &UcteOptions,
mut notes: Vec<String>,
) -> Result<UcteImport, UcteError> {
let s_base_va = options.base_mva * 1e6;
let mut buses = Vec::with_capacity(nodes.len());
let mut node_codes = Vec::with_capacity(nodes.len());
let mut bus_countries = Vec::with_capacity(nodes.len());
let mut node_index = HashMap::with_capacity(nodes.len());
let mut p_limits = Vec::with_capacity(nodes.len());
let mut generation: Vec<f64> = Vec::new();
let mut x_nodes = 0usize;
for (idx, node) in nodes.iter().enumerate() {
let level = node.code.chars().nth(6).unwrap_or(' ');
let class = voltage_level_v(level).ok_or_else(|| UcteError::BadVoltageLevel {
node: node.code.clone(),
code: level,
})?;
let u_rated = options
.nominal_voltages
.iter()
.find(|(from, _)| (class - from).abs() < 1.0)
.map(|(_, to)| *to)
.unwrap_or(class);
if node.code.starts_with('X') {
x_nodes += 1;
}
let bus_type = match node.type_code {
3 => BusType::Slack,
2 => BusType::PV,
_ => BusType::PQ,
};
let voltage_mag = match (node.regulates_voltage(), node.voltage_reference) {
(true, Some(v)) if v > 0.0 => v * 1000.0 / u_rated,
_ => 1.0,
};
let p_mw = node.generation_mw() - node.active_load.unwrap_or(0.0);
let q_mvar = node.generation_mvar() - node.reactive_load.unwrap_or(0.0);
let (q_min, q_max) = match (node.min_q, node.max_q) {
(Some(lo), Some(hi)) => (-lo * 1e6 / s_base_va, -hi * 1e6 / s_base_va),
_ => (f64::NEG_INFINITY, f64::INFINITY),
};
buses.push(Bus {
idx,
bus_type,
voltage_mag,
voltage_ang: 0.0,
p_spec: p_mw * 1e6 / s_base_va,
q_spec: q_mvar * 1e6 / s_base_va,
q_min,
q_max,
u_rated,
zip_terms: Vec::new(),
});
p_limits.push(match (node.min_p, node.max_p) {
(Some(lo), Some(hi)) => Some((-lo * 1e6 / s_base_va, -hi * 1e6 / s_base_va)),
_ => None,
});
generation.push(node.generation_mw().max(0.0) * 1e6 / s_base_va);
node_index.insert(node.code.clone(), idx);
node_codes.push(node.code.clone());
bus_countries.push(node.country.clone());
}
if !options.nominal_voltages.is_empty() {
notes.push(format!(
"nominal voltages substituted: {}",
options
.nominal_voltages
.iter()
.map(|(a, b)| format!("{:.0}->{:.0} kV", a / 1000.0, b / 1000.0))
.collect::<Vec<_>>()
.join(", ")
));
}
if x_nodes > 0 {
notes.push(format!(
"{x_nodes} X-node(s) kept as ordinary buses; boundary-line semantics are not modelled"
));
}
let mut lines = Vec::new();
let mut line_ids = Vec::new();
let mut line_limits = Vec::new();
let mut transformers = Vec::new();
let mut transformer_ids = Vec::new();
let mut transformer_limits = Vec::new();
let mut tap_changers = Vec::new();
let mut tap_regulation: Vec<crate::outerloop::TapRegulation> = Vec::new();
let mut pending_power_regulation: Vec<(usize, usize, f64, String)> = Vec::new();
let mut out_of_service = Vec::new();
let mut initially_open: Vec<usize> = Vec::new();
let mut initially_open_transformers: Vec<usize> = Vec::new();
let mut couplers = 0usize;
for branch in &branches {
let node1 = *node_index
.get(&branch.node1)
.ok_or_else(|| UcteError::UnknownNode {
record: branch.id.clone(),
node: branch.node1.clone(),
})?;
let node2 = *node_index
.get(&branch.node2)
.ok_or_else(|| UcteError::UnknownNode {
record: branch.id.clone(),
node: branch.node2.clone(),
})?;
let (from, to) = match branch.transformer {
None => (node1, node2),
Some(_) => (node2, node1),
};
let in_service = status_in_service(branch.status);
if !in_service {
out_of_service.push(branch.id.clone());
}
if status_is_coupler(branch.status) {
couplers += 1;
}
let limits = match branch.current_limit {
Some(a) if a > 0.0 => BranchLimits::permanent(a),
_ => BranchLimits::default(),
};
match &branch.transformer {
None => {
let z_base = buses[from].u_rated * buses[from].u_rated / s_base_va;
let (r, x) = crate::topology::reduction::clamp_branch_impedance(
branch.r / z_base,
branch.x / z_base,
);
lines.push(Line {
from,
to,
r,
x,
b_shunt: branch.b * z_base,
g_shunt: 0.0,
});
if !in_service {
initially_open.push(lines.len() - 1);
}
line_ids.push(branch.id.clone());
line_limits.push(limits);
}
Some(t) => {
let z_base = buses[to].u_rated * buses[to].u_rated / s_base_va;
let (r, x) = crate::topology::reduction::clamp_branch_impedance(
branch.r / z_base,
branch.x / z_base,
);
let y_series = Complex::new(1.0, 0.0) / Complex::new(r, x);
let y_shunt = Complex::new(t.g * z_base, branch.b * z_base);
let nominal = if t.rated_v1 > 0.0 && t.rated_v2 > 0.0 {
(t.rated_v2 * 1000.0 / buses[from].u_rated)
/ (t.rated_v1 * 1000.0 / buses[to].u_rated)
} else {
1.0
};
let regulation = regulations.get(&branch.id);
let (tap, changer) = build_tap(nominal, regulation);
if let Some(reg) = regulation {
if let Some(kv) = reg.ratio.and_then(|r| r.target_kv) {
tap_regulation.push(crate::outerloop::TapRegulation {
transformer: transformers.len(),
controlled_bus: to,
mode: crate::outerloop::RegulationMode::Voltage,
target: kv * 1000.0 / buses[to].u_rated,
deadband: 0.0,
enabled: true,
id: branch.id.clone(),
});
} else if let Some(mw) = reg.angle.and_then(|a| a.target_mw) {
pending_power_regulation.push((
transformers.len(),
to,
mw,
branch.id.clone(),
));
}
}
transformers.push(Transformer {
from,
to,
from_status: 1,
to_status: 1,
y_series,
y_shunt,
tap,
});
transformer_ids.push(branch.id.clone());
transformer_limits.push(limits);
tap_changers.push(changer);
if !in_service {
initially_open_transformers.push(transformers.len() - 1);
}
}
}
}
if couplers > 0 {
notes.push(format!(
"{couplers} closed busbar coupler(s) imported as near-zero-impedance branches"
));
}
for i in initially_open_transformers {
initially_open.push(lines.len() + i);
}
initially_open.sort_unstable();
if !out_of_service.is_empty() {
notes.push(format!(
"{} branch(es) present but open (out of operation)",
out_of_service.len()
));
}
for (transformer, to, mw, id) in pending_power_regulation {
tap_regulation.push(crate::outerloop::TapRegulation {
transformer,
controlled_bus: to,
mode: crate::outerloop::RegulationMode::ActivePower {
branch: lines.len() + transformer,
terminal: crate::branch_flow::Terminal::To,
},
target: mw * 1e6 / (options.base_mva * 1e6),
deadband: 0.0,
enabled: true,
id,
});
}
let unmatched = regulations.len().saturating_sub(
transformer_ids
.iter()
.filter(|id| regulations.contains_key(*id))
.count(),
);
if unmatched > 0 {
notes.push(format!(
"{unmatched} `##R` record(s) matched no in-service transformer"
));
}
let mut branch_ids = line_ids;
branch_ids.extend(transformer_ids);
let mut limits = line_limits;
limits.extend(transformer_limits);
let slack = choose_slack(&nodes, &node_index, &options.slack, &mut notes)?;
buses[slack].bus_type = BusType::Slack;
Ok(UcteImport {
generation,
buses,
lines,
transformers,
shunts: Vec::new(),
node_codes,
bus_countries,
node_index,
branch_ids,
limits,
tap_changers,
regulation: tap_regulation,
slack,
p_limits,
base_mva: options.base_mva,
notes,
initially_open,
out_of_service,
})
}
fn build_tap(
nominal: f64,
regulation: Option<&RawRegulation>,
) -> (Complex<f64>, Option<TapChanger>) {
let base = Complex::new(nominal, 0.0);
let Some(reg) = regulation else {
return (base, None);
};
let ratio_at = |pos: i32| {
reg.ratio
.map(|r| ratio_multiplier(&r, pos))
.unwrap_or(Complex::new(1.0, 0.0))
};
let angle_at = |pos: i32| {
reg.angle
.map(|a| angle_multiplier(&a, pos))
.unwrap_or(Complex::new(1.0, 0.0))
};
let (n, position, use_angle) = match (®.angle, ®.ratio) {
(Some(a), _) => (a.n, a.position, true),
(None, Some(r)) => (r.n, r.position, false),
(None, None) => return (base, None),
};
if n <= 0 {
let tap = base
* ratio_at(reg.ratio.map(|r| r.position).unwrap_or(0))
* angle_at(reg.angle.map(|a| a.position).unwrap_or(0));
return (tap, None);
}
let held_ratio = ratio_at(reg.ratio.map(|r| r.position).unwrap_or(0));
let held_angle = angle_at(reg.angle.map(|a| a.position).unwrap_or(0));
let steps: Vec<Complex<f64>> = (-n..=n)
.map(|i| {
if use_angle {
base * held_ratio * angle_at(i)
} else {
base * ratio_at(i) * held_angle
}
})
.collect();
let position = position.clamp(-n, n);
let changer = TapChanger {
low: -n,
position,
neutral: 0,
steps,
series: None,
};
let tap = changer.current().unwrap_or(base);
(tap, Some(changer))
}
fn choose_slack(
nodes: &[RawNode],
node_index: &HashMap<String, usize>,
policy: &SlackPolicy,
notes: &mut Vec<String>,
) -> Result<usize, UcteError> {
if let SlackPolicy::Node(code) = policy {
return node_index
.get(code)
.copied()
.ok_or_else(|| UcteError::UnknownNode {
record: "slack policy".to_string(),
node: code.clone(),
});
}
if let Some(i) = nodes.iter().position(|n| n.type_code == 3) {
return Ok(i);
}
let pick = |filter: &dyn Fn(&RawNode) -> bool| -> Option<usize> {
nodes
.iter()
.enumerate()
.filter(|(_, n)| filter(n) && n.generation_mw() > 0.0)
.max_by(|(_, a), (_, b)| {
a.generation_mw()
.total_cmp(&b.generation_mw())
.then_with(|| b.code.cmp(&a.code))
})
.map(|(i, _)| i)
};
let chosen = pick(&|n: &RawNode| n.regulates_voltage())
.or_else(|| pick(&|_: &RawNode| true))
.unwrap_or(0);
notes.push(format!(
"no `U and theta constant` node in file; slack chosen as `{}` (largest generation)",
nodes[chosen].code
));
Ok(chosen)
}