use std::collections::HashMap;
use std::path::Path;
use super::objects::{DssCatalog, DssObject, WdgConn};
use super::resolve::{resolve_linecodes, resolve_xfmrcodes, strip_phases};
use super::to_network::DssParseError;
use surge_dist::{
LoadConnection, PhaseImpedanceMatrix, RegulatorControl, ThreePhaseBranch, ThreePhaseLoad,
ThreePhaseLoadModel, ThreePhaseNetwork, ThreePhaseTransformer,
};
fn build_dss_catalog(path: &Path) -> Result<DssCatalog, DssParseError> {
let content = std::fs::read_to_string(path).map_err(|e| DssParseError::Io {
path: path.to_string_lossy().to_string(),
source: e,
})?;
let base_dir = path.parent().unwrap_or(Path::new("."));
build_catalog_from_str(&content, Some(base_dir))
}
fn build_catalog_from_str(
content: &str,
base_dir: Option<&Path>,
) -> Result<DssCatalog, DssParseError> {
let mut catalog = DssCatalog::new();
let mut last_obj_idx: Option<usize> = None;
let mut last_was_circuit = false;
let mut frequency_hz = 60.0_f64;
process_dss_stream(
content,
base_dir,
0,
&mut catalog,
&mut last_obj_idx,
&mut last_was_circuit,
&mut frequency_hz,
)?;
resolve_linecodes(&mut catalog);
resolve_xfmrcodes(&mut catalog);
Ok(catalog)
}
fn process_dss_stream(
content: &str,
base_dir: Option<&Path>,
depth: usize,
catalog: &mut DssCatalog,
last_obj_idx: &mut Option<usize>,
last_was_circuit: &mut bool,
frequency_hz: &mut f64,
) -> Result<(), DssParseError> {
use super::command::parse_commands;
use super::lexer::tokenize;
if depth > 16 {
return Err(DssParseError::UnresolvedRef(
"redirect/compile nesting depth exceeded".to_string(),
));
}
let tokens = tokenize(content);
let commands = parse_commands(&tokens);
for cmd in &commands {
process_dss_command(
cmd,
catalog,
last_obj_idx,
last_was_circuit,
frequency_hz,
base_dir,
depth,
)?;
}
Ok(())
}
fn process_dss_command(
cmd: &super::command::DssCommand,
catalog: &mut DssCatalog,
last_obj_idx: &mut Option<usize>,
last_was_circuit: &mut bool,
_frequency_hz: &mut f64,
base_dir: Option<&Path>,
depth: usize,
) -> Result<(), DssParseError> {
use super::command::DssCommand;
use super::objects::DssObject;
match cmd {
DssCommand::Clear => {
*catalog = DssCatalog::new();
*last_obj_idx = None;
}
DssCommand::New {
obj_type,
obj_name,
properties,
}
| DssCommand::Edit {
obj_type,
obj_name,
properties,
} => {
let is_circuit =
obj_type.to_lowercase() == "circuit" || obj_type.to_lowercase() == "vsource";
if is_circuit {
let mut circ = super::objects::CircuitData {
name: obj_name.clone(),
..Default::default()
};
for (k, v) in properties {
circ.apply_property(k, v);
}
catalog.circuit = Some(circ);
*last_obj_idx = None;
*last_was_circuit = true;
} else {
match DssObject::new_for_type(obj_type) {
Some(mut obj) => {
*obj.name_mut() = obj_name.clone();
let like_name: Option<String> = properties
.iter()
.find(|(k, _)| k.to_lowercase() == "like")
.map(|(_, v)| v.clone());
if let Some(ref base_name) = like_name {
if let Some(base_obj) = catalog.find(obj_type, base_name).cloned() {
obj = base_obj;
*obj.name_mut() = obj_name.clone();
}
}
for (k, v) in properties {
if k.to_lowercase() != "like" {
obj.apply_property(k, v);
}
}
let idx = catalog.upsert(obj_type, obj_name, obj);
*last_obj_idx = Some(idx);
*last_was_circuit = false;
}
None => {
*last_obj_idx = None;
}
}
}
}
DssCommand::More { properties } => {
if *last_was_circuit {
if let Some(ref mut circ) = catalog.circuit {
for (k, v) in properties {
circ.apply_property(k, v);
}
}
} else if let Some(i) = *last_obj_idx
&& let Some(obj) = catalog.get_mut(i)
{
for (k, v) in properties {
obj.apply_property(k, v);
}
}
}
DssCommand::Redirect {
path: redirect_path,
}
| DssCommand::Compile {
path: redirect_path,
} => {
let full_path = if let Some(bd) = base_dir {
bd.join(redirect_path)
} else {
std::path::PathBuf::from(redirect_path)
};
let redirect_content =
std::fs::read_to_string(&full_path).map_err(|source| DssParseError::Io {
path: full_path.to_string_lossy().to_string(),
source,
})?;
let child_base = full_path
.parent()
.unwrap_or_else(|| base_dir.unwrap_or(Path::new(".")));
process_dss_stream(
&redirect_content,
Some(child_base),
depth + 1,
catalog,
last_obj_idx,
last_was_circuit,
_frequency_hz,
)?;
}
DssCommand::Set { .. } | DssCommand::Solve | DssCommand::Unknown { .. } => {
}
}
Ok(())
}
fn parse_phase_spec(bus: &str) -> (&str, u8) {
let dot_pos = bus.find('.');
let (name, rest) = if let Some(pos) = dot_pos {
(&bus[..pos], &bus[pos + 1..])
} else {
(bus, "")
};
if rest.is_empty() {
return (name, 0b111);
}
let mut mask = 0u8;
for part in rest.split('.') {
match part.trim() {
"1" => mask |= 0b001,
"2" => mask |= 0b010,
"3" => mask |= 0b100,
_ => {}
}
}
if mask == 0 {
mask = 0b111;
}
(name, mask)
}
fn parse_delta_pair(bus: &str) -> Option<(usize, usize)> {
let dot_pos = bus.find('.')?;
let rest = &bus[dot_pos + 1..];
let mut phases = Vec::new();
for part in rest.split('.') {
match part.trim() {
"1" => phases.push(0usize),
"2" => phases.push(1),
"3" => phases.push(2),
_ => {}
}
}
if phases.len() >= 2 {
Some((phases[0], phases[1]))
} else {
None
}
}
fn expand_lower_tri_to_3x3(tri: &[f64], active_phases: &[usize]) -> [[f64; 3]; 3] {
let mut m = [[0.0f64; 3]; 3];
let n = active_phases.len();
let mut idx = 0usize;
for row in 0..n {
for col in 0..=row {
if idx >= tri.len() {
break;
}
let r3 = active_phases[row];
let c3 = active_phases[col];
m[r3][c3] = tri[idx];
m[c3][r3] = tri[idx]; idx += 1;
}
}
m
}
#[allow(dead_code)]
fn expand_lower_tri_3x3(tri: &[f64]) -> [[f64; 3]; 3] {
expand_lower_tri_to_3x3(tri, &[0, 1, 2])
}
fn active_phase_indices(phase_mask: u8) -> Vec<usize> {
let mut phases = Vec::new();
for ph in 0..3usize {
if (phase_mask >> ph) & 1 == 1 {
phases.push(ph);
}
}
phases
}
fn build_3ph_bus_map(catalog: &DssCatalog) -> (HashMap<String, usize>, Vec<String>) {
let mut names: std::collections::BTreeSet<String> = Default::default();
let mut source_bus = String::new();
if let Some(ref circ) = catalog.circuit {
source_bus = circ.bus.to_lowercase();
}
for obj in &catalog.objects {
match obj {
DssObject::Line(l) => {
let b1 = strip_phases(&l.bus1.to_lowercase()).to_string();
let b2 = strip_phases(&l.bus2.to_lowercase()).to_string();
if !b1.is_empty() && b1 != source_bus {
names.insert(b1);
}
if !b2.is_empty() && b2 != source_bus {
names.insert(b2);
}
}
DssObject::Transformer(t) => {
for b in &t.buses {
let bn = strip_phases(&b.to_lowercase()).to_string();
if !bn.is_empty() && bn != source_bus {
names.insert(bn);
}
}
}
DssObject::Load(l) => {
let bus1_lc = l.bus1.to_lowercase();
let (name, _) = parse_phase_spec(&bus1_lc);
let bn = name.to_string();
if !bn.is_empty() && bn != source_bus {
names.insert(bn);
}
}
DssObject::Reactor(r) => {
if !r.bus1.is_empty() {
let b1 = strip_phases(&r.bus1.to_lowercase()).to_string();
if !b1.is_empty() && b1 != source_bus {
names.insert(b1);
}
}
if !r.bus2.is_empty() {
let b2 = strip_phases(&r.bus2.to_lowercase()).to_string();
if !b2.is_empty() && b2 != source_bus {
names.insert(b2);
}
}
}
DssObject::Capacitor(c) => {
if !c.bus1.is_empty() {
let b1 = strip_phases(&c.bus1.to_lowercase()).to_string();
if !b1.is_empty() && b1 != source_bus {
names.insert(b1);
}
}
}
DssObject::Generator(g) => {
if !g.bus1.is_empty() {
let b = strip_phases(&g.bus1.to_lowercase()).to_string();
if !b.is_empty() && b != source_bus {
names.insert(b);
}
}
}
DssObject::PvSystem(pv) => {
if !pv.bus1.is_empty() {
let b = strip_phases(&pv.bus1.to_lowercase()).to_string();
if !b.is_empty() && b != source_bus {
names.insert(b);
}
}
}
DssObject::Storage(st) => {
if !st.bus1.is_empty() {
let b = strip_phases(&st.bus1.to_lowercase()).to_string();
if !b.is_empty() && b != source_bus {
names.insert(b);
}
}
}
DssObject::Fault(f) => {
for bus_str in [&f.bus1, &f.bus2] {
if !bus_str.is_empty() {
let b = strip_phases(&bus_str.to_lowercase()).to_string();
if !b.is_empty() && b != source_bus {
names.insert(b);
}
}
}
}
_ => {}
}
}
let mut bus_names = vec![source_bus.clone()];
bus_names.extend(names.iter().cloned());
let bus_map: HashMap<String, usize> = bus_names
.iter()
.enumerate()
.map(|(i, name)| (name.clone(), i))
.collect();
(bus_map, bus_names)
}
fn apply_phase_mask_to_z(z: &mut [[f64; 2]; 9], phase_mask: u8) {
for ph in 0..3usize {
let active = (phase_mask >> ph) & 1 == 1;
if !active {
for j in 0..3 {
z[ph * 3 + j] = [0.0, 0.0];
z[j * 3 + ph] = [0.0, 0.0];
}
z[ph * 3 + ph] = [1e6, 0.0];
}
}
}
fn line_to_3ph_branch(
line: &super::objects::LineData,
bus_map: &HashMap<String, usize>,
system_freq_hz: f64,
) -> Option<ThreePhaseBranch> {
if line.bus1.is_empty() || line.bus2.is_empty() {
return None;
}
let bus1_lc = line.bus1.to_lowercase();
let bus2_lc = line.bus2.to_lowercase();
let (from_name_str, phase_mask) = parse_phase_spec(&bus1_lc);
let (to_name_str, _) = parse_phase_spec(&bus2_lc);
let from_name = from_name_str;
let to_name = to_name_str;
let from_bus = *bus_map.get(from_name)?;
let to_bus = *bus_map.get(to_name)?;
if from_bus == to_bus {
return None; }
let len_km = line.length * line.units.to_km_factor();
let len_km = if len_km <= 0.0 { 1.0 } else { len_km };
let z_matrix = if !line.rmatrix.is_empty() && !line.xmatrix.is_empty() {
let active = active_phase_indices(phase_mask);
let r3 = expand_lower_tri_to_3x3(&line.rmatrix, &active);
let x3 = expand_lower_tri_to_3x3(&line.xmatrix, &active);
let mut z = [[0.0f64; 2]; 9];
for i in 0..3 {
for j in 0..3 {
z[i * 3 + j] = [r3[i][j] * len_km, x3[i][j] * len_km];
}
}
apply_phase_mask_to_z(&mut z, phase_mask);
PhaseImpedanceMatrix { z }
} else {
let r1 = line.r1 * len_km;
let x1 = line.x1 * len_km;
let r0 = line.r0 * len_km;
let x0 = line.x0 * len_km;
let r_self = (r0 + 2.0 * r1) / 3.0;
let r_mut = (r0 - r1) / 3.0;
let x_self = (x0 + 2.0 * x1) / 3.0;
let x_mut = (x0 - x1) / 3.0;
let mut z = [[0.0f64; 2]; 9];
for i in 0..3 {
for j in 0..3 {
z[i * 3 + j] = if i == j {
[r_self, x_self]
} else {
[r_mut, x_mut]
};
}
}
apply_phase_mask_to_z(&mut z, phase_mask);
PhaseImpedanceMatrix { z }
};
let freq = system_freq_hz;
let omega = 2.0 * std::f64::consts::PI * freq;
let b_shunt_us = if !line.cmatrix.is_empty() {
let active = active_phase_indices(phase_mask);
let c3 = expand_lower_tri_to_3x3(&line.cmatrix, &active);
let mut b = [0.0f64; 3];
let other_phases: [(usize, usize); 3] = [(1, 2), (0, 2), (0, 1)];
for ph in 0..3 {
let (j, k) = other_phases[ph];
let c_nf_self = c3[ph][ph] * len_km;
let c_nf_j = c3[ph][j] * len_km;
let c_nf_k = c3[ph][k] * len_km;
let c_eff = c_nf_self - 0.5 * (c_nf_j + c_nf_k);
b[ph] = omega * c_eff / 1e3; }
b
} else if line.c1 > 0.0 || line.c0 > 0.0 {
let c1_nf = line.c1 * len_km;
let b_pos_seq = omega * c1_nf / 1e3; [b_pos_seq, b_pos_seq, b_pos_seq]
} else {
[0.0; 3]
};
Some(ThreePhaseBranch {
from_bus,
to_bus,
z_matrix,
b_shunt_us,
})
}
fn load_to_3ph_load(
load: &super::objects::LoadData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseLoad> {
if load.bus1.is_empty() {
return None;
}
let bus1_lc = load.bus1.to_lowercase();
let (bus_name, phase_mask) = parse_phase_spec(&bus1_lc);
let bus_idx = *bus_map.get(bus_name)?;
let is_delta = matches!(load.conn, WdgConn::Delta);
let conn = if is_delta {
LoadConnection::Delta
} else {
LoadConnection::Wye
};
let model = match load.model {
super::objects::LoadModel::ConstantZ | super::objects::LoadModel::ConstantZ2 => {
ThreePhaseLoadModel::ConstantZ
}
super::objects::LoadModel::ConstantPFixedQ => ThreePhaseLoadModel::ConstantPConstantXQ,
super::objects::LoadModel::ConstantPConstantXQ => ThreePhaseLoadModel::ConstantI,
_ => ThreePhaseLoadModel::ConstantPQ,
};
if is_delta {
let (mut pa, mut qa) = (0.0, 0.0); let (mut pb, mut qb) = (0.0, 0.0); let (mut pc, mut qc) = (0.0, 0.0);
match phase_mask {
0b011 => {
pa = load.kw;
qa = load.kvar;
}
0b110 => {
pb = load.kw;
qb = load.kvar;
}
0b101 => {
pc = load.kw;
qc = load.kvar;
}
_ => {
let kw3 = load.kw / 3.0;
let kvar3 = load.kvar / 3.0;
pa = kw3;
qa = kvar3;
pb = kw3;
qb = kvar3;
pc = kw3;
qc = kvar3;
}
}
Some(ThreePhaseLoad {
bus_idx,
pa_kw: pa,
qa_kvar: qa,
pb_kw: pb,
qb_kvar: qb,
pc_kw: pc,
qc_kvar: qc,
model,
conn,
})
} else {
let n_active = phase_mask.count_ones() as f64;
let kw_per_phase = if n_active > 0.0 {
load.kw / n_active
} else {
0.0
};
let kvar_per_phase = if n_active > 0.0 {
load.kvar / n_active
} else {
0.0
};
Some(ThreePhaseLoad {
bus_idx,
pa_kw: if phase_mask & 0b001 != 0 {
kw_per_phase
} else {
0.0
},
qa_kvar: if phase_mask & 0b001 != 0 {
kvar_per_phase
} else {
0.0
},
pb_kw: if phase_mask & 0b010 != 0 {
kw_per_phase
} else {
0.0
},
qb_kvar: if phase_mask & 0b010 != 0 {
kvar_per_phase
} else {
0.0
},
pc_kw: if phase_mask & 0b100 != 0 {
kw_per_phase
} else {
0.0
},
qc_kvar: if phase_mask & 0b100 != 0 {
kvar_per_phase
} else {
0.0
},
model,
conn,
})
}
}
fn capacitor_to_3ph_load(
cap: &super::objects::CapacitorData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseLoad> {
if cap.bus1.is_empty() {
return None;
}
let bus1_lc = cap.bus1.to_lowercase();
let (bus_name, phase_mask_raw) = parse_phase_spec(&bus1_lc);
let bus_idx = *bus_map.get(bus_name)?;
let phase_mask = if phase_mask_raw == 0b111 && cap.phases < 3 {
0b111u8
} else {
phase_mask_raw
};
let n_active = phase_mask.count_ones().max(1) as f64;
let total_kvar = cap.total_kvar();
let kvar_per_phase = -total_kvar / n_active;
let qa_kvar = if phase_mask & 0b001 != 0 {
kvar_per_phase
} else {
0.0
};
let qb_kvar = if phase_mask & 0b010 != 0 {
kvar_per_phase
} else {
0.0
};
let qc_kvar = if phase_mask & 0b100 != 0 {
kvar_per_phase
} else {
0.0
};
Some(ThreePhaseLoad {
bus_idx,
pa_kw: 0.0,
qa_kvar,
pb_kw: 0.0,
qb_kvar,
pc_kw: 0.0,
qc_kvar,
model: ThreePhaseLoadModel::ConstantZ,
conn: LoadConnection::Wye,
})
}
fn generator_to_3ph_load(
gendata: &super::objects::GeneratorData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseLoad> {
if gendata.bus1.is_empty() {
return None;
}
let bus1_lc = gendata.bus1.to_lowercase();
let (bus_name, phase_mask) = parse_phase_spec(&bus1_lc);
let bus_idx = *bus_map.get(bus_name)?;
let n_active = phase_mask.count_ones().max(1) as f64;
let kw_per_phase = -gendata.kw / n_active; let kvar_per_phase = -gendata.kvar / n_active;
Some(ThreePhaseLoad {
bus_idx,
pa_kw: if phase_mask & 0b001 != 0 {
kw_per_phase
} else {
0.0
},
qa_kvar: if phase_mask & 0b001 != 0 {
kvar_per_phase
} else {
0.0
},
pb_kw: if phase_mask & 0b010 != 0 {
kw_per_phase
} else {
0.0
},
qb_kvar: if phase_mask & 0b010 != 0 {
kvar_per_phase
} else {
0.0
},
pc_kw: if phase_mask & 0b100 != 0 {
kw_per_phase
} else {
0.0
},
qc_kvar: if phase_mask & 0b100 != 0 {
kvar_per_phase
} else {
0.0
},
model: ThreePhaseLoadModel::ConstantPQ,
conn: LoadConnection::Wye,
})
}
fn pvsystem_to_3ph_load(
pv: &super::objects::PvSystemData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseLoad> {
if pv.bus1.is_empty() {
return None;
}
let bus1_lc = pv.bus1.to_lowercase();
let (bus_name, phase_mask) = parse_phase_spec(&bus1_lc);
let bus_idx = *bus_map.get(bus_name)?;
let p_out = (pv.pmpp * pv.irradiance).min(pv.kva);
let q_out = if pv.pf.abs() < 1.0 - 1e-6 {
p_out * (1.0 - pv.pf * pv.pf).sqrt() / pv.pf.abs() * pv.pf.signum()
} else {
0.0
};
let n_active = phase_mask.count_ones().max(1) as f64;
let kw_per_phase = -p_out / n_active;
let kvar_per_phase = -q_out / n_active;
Some(ThreePhaseLoad {
bus_idx,
pa_kw: if phase_mask & 0b001 != 0 {
kw_per_phase
} else {
0.0
},
qa_kvar: if phase_mask & 0b001 != 0 {
kvar_per_phase
} else {
0.0
},
pb_kw: if phase_mask & 0b010 != 0 {
kw_per_phase
} else {
0.0
},
qb_kvar: if phase_mask & 0b010 != 0 {
kvar_per_phase
} else {
0.0
},
pc_kw: if phase_mask & 0b100 != 0 {
kw_per_phase
} else {
0.0
},
qc_kvar: if phase_mask & 0b100 != 0 {
kvar_per_phase
} else {
0.0
},
model: ThreePhaseLoadModel::ConstantPQ,
conn: LoadConnection::Wye,
})
}
fn storage_to_3ph_load(
storage: &super::objects::StorageData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseLoad> {
if storage.bus1.is_empty() {
return None;
}
let bus1_lc = storage.bus1.to_lowercase();
let (bus_name, phase_mask) = parse_phase_spec(&bus1_lc);
let bus_idx = *bus_map.get(bus_name)?;
let p_out = storage.kw_rated;
let q_out = if storage.pf.abs() < 1.0 - 1e-6 {
p_out * (1.0 - storage.pf * storage.pf).sqrt() / storage.pf.abs() * storage.pf.signum()
} else {
0.0
};
let n_active = phase_mask.count_ones().max(1) as f64;
let kw_per_phase = -p_out / n_active;
let kvar_per_phase = -q_out / n_active;
Some(ThreePhaseLoad {
bus_idx,
pa_kw: if phase_mask & 0b001 != 0 {
kw_per_phase
} else {
0.0
},
qa_kvar: if phase_mask & 0b001 != 0 {
kvar_per_phase
} else {
0.0
},
pb_kw: if phase_mask & 0b010 != 0 {
kw_per_phase
} else {
0.0
},
qb_kvar: if phase_mask & 0b010 != 0 {
kvar_per_phase
} else {
0.0
},
pc_kw: if phase_mask & 0b100 != 0 {
kw_per_phase
} else {
0.0
},
qc_kvar: if phase_mask & 0b100 != 0 {
kvar_per_phase
} else {
0.0
},
model: ThreePhaseLoadModel::ConstantPQ,
conn: LoadConnection::Wye,
})
}
fn fault_to_3ph_branch(
fault: &super::objects::FaultData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseBranch> {
if fault.bus1.is_empty() || fault.bus2.is_empty() {
return None; }
let bus1_lc = fault.bus1.to_lowercase();
let bus2_lc = fault.bus2.to_lowercase();
let (from_name, phase_mask) = parse_phase_spec(&bus1_lc);
let (to_name, _) = parse_phase_spec(&bus2_lc);
let from_bus = *bus_map.get(from_name)?;
let to_bus = *bus_map.get(to_name)?;
if from_bus == to_bus {
return None;
}
let r = fault.r.max(1e-6); let mut z = [[0.0f64; 2]; 9];
for ph in 0..3 {
z[ph * 3 + ph] = [r, 0.0];
}
apply_phase_mask_to_z(&mut z, phase_mask);
Some(ThreePhaseBranch {
from_bus,
to_bus,
z_matrix: PhaseImpedanceMatrix { z },
b_shunt_us: [0.0; 3], })
}
fn reactor_to_3ph_branch(
reactor: &super::objects::ReactorData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseBranch> {
if reactor.bus1.is_empty() || reactor.bus2.is_empty() {
return None; }
let bus1_lc = reactor.bus1.to_lowercase();
let bus2_lc = reactor.bus2.to_lowercase();
let (from_name, phase_mask) = parse_phase_spec(&bus1_lc);
let (to_name, _) = parse_phase_spec(&bus2_lc);
let from_bus = *bus_map.get(from_name)?;
let to_bus = *bus_map.get(to_name)?;
if from_bus == to_bus {
return None;
}
let r = reactor.r;
let x = reactor.x;
let mut z = [[0.0f64; 2]; 9];
for ph in 0..3 {
z[ph * 3 + ph] = [r, x];
}
apply_phase_mask_to_z(&mut z, phase_mask);
Some(ThreePhaseBranch {
from_bus,
to_bus,
z_matrix: PhaseImpedanceMatrix { z },
b_shunt_us: [0.0; 3], })
}
fn xfmr_to_3ph_transformer(
xfmr: &super::objects::TransformerData,
bus_map: &HashMap<String, usize>,
) -> Option<ThreePhaseTransformer> {
if xfmr.buses.len() < 2 || xfmr.kvs.len() < 2 {
return None;
}
let bus0_lc = xfmr.buses[0].to_lowercase();
let bus1_lc = xfmr.buses[1].to_lowercase();
let (from_name, phase_mask) = parse_phase_spec(&bus0_lc);
let (to_name, _) = parse_phase_spec(&bus1_lc);
let from_bus = *bus_map.get(from_name)?;
let to_bus = *bus_map.get(to_name)?;
if from_bus == to_bus {
return None;
}
let kv_primary = xfmr.kvs[0];
let kv_secondary = xfmr.kvs[1];
let turns_ratio = if kv_primary > 1e-6 && kv_secondary > 1e-6 {
kv_primary / kv_secondary
} else {
1.0
};
let kv_primary = if kv_primary > 1e-6 { kv_primary } else { 4.16 };
let phase_shift_rad = match (xfmr.conns.first(), xfmr.conns.get(1)) {
(Some(WdgConn::Delta), Some(WdgConn::Wye | WdgConn::Ln)) => (-30.0_f64).to_radians(),
(Some(WdgConn::Wye | WdgConn::Ln), Some(WdgConn::Delta)) => 30.0_f64.to_radians(),
_ => 0.0,
};
let kva_rating = xfmr.kvas.first().copied().unwrap_or(1000.0).max(1.0);
let kv_sec = if kv_secondary > 1e-6 {
kv_secondary
} else {
kv_primary
};
let z_base_secondary = kv_sec * kv_sec * 1000.0 / kva_rating;
let r_total_pct = if xfmr.pct_load_loss > 0.0 {
xfmr.pct_load_loss
} else {
xfmr.pct_rs.iter().sum::<f64>()
};
let r_ohm = r_total_pct / 100.0 * z_base_secondary;
let x_ohm = xfmr.xhl / 100.0 * z_base_secondary;
let pri_delta = xfmr
.conns
.first()
.is_some_and(|c| matches!(c, WdgConn::Delta));
let sec_delta = xfmr
.conns
.get(1)
.is_some_and(|c| matches!(c, WdgConn::Delta));
let is_delta_delta = pri_delta && sec_delta;
let has_delta_winding = pri_delta || sec_delta;
let mut z_matrix = if has_delta_winding && phase_mask == 0b111 {
let mut z = [[0.0f64; 2]; 9];
for i in 0..3usize {
for j in 0..3usize {
let scale = if i == j { 2.0 / 3.0 } else { -1.0 / 3.0 };
z[i * 3 + j] = [r_ohm * scale, x_ohm * scale];
}
}
PhaseImpedanceMatrix { z }
} else {
let mut z = PhaseImpedanceMatrix::balanced(r_ohm, x_ohm);
if phase_mask != 0b111 {
apply_phase_mask_to_z(&mut z.z, phase_mask);
}
z
};
let kv_ln_sec = kv_sec / 3.0_f64.sqrt();
let v_ln2 = kv_ln_sec * kv_ln_sec;
let g_mag_siemens = if xfmr.pct_no_load_loss > 0.0 && v_ln2 > 1e-12 {
(kva_rating * xfmr.pct_no_load_loss / 100.0 / 3.0) / (v_ln2 * 1000.0)
} else {
0.0
};
let b_mag_siemens = if xfmr.pct_imag > 0.0 && v_ln2 > 1e-12 {
-(kva_rating * xfmr.pct_imag / 100.0 / 3.0) / (v_ln2 * 1000.0)
} else {
0.0
};
Some(ThreePhaseTransformer {
from_bus,
to_bus,
z_matrix,
turns_ratio,
phase_shift_rad,
rated_kva: kva_rating,
is_delta_delta,
g_mag_siemens,
b_mag_siemens,
regulators: [None, None, None], ganged_regulator: false, })
}
fn deduplicate_transformers(
transformers: Vec<ThreePhaseTransformer>,
) -> Vec<ThreePhaseTransformer> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(usize, usize), Vec<ThreePhaseTransformer>> = BTreeMap::new();
for xfmr in transformers {
let key = if xfmr.from_bus <= xfmr.to_bus {
(xfmr.from_bus, xfmr.to_bus)
} else {
(xfmr.to_bus, xfmr.from_bus)
};
groups.entry(key).or_default().push(xfmr);
}
let mut result = Vec::new();
for (_, group) in groups {
if group.len() == 1 {
result.push(
group
.into_iter()
.next()
.expect("group.len() == 1 so iter has exactly one element"),
);
} else {
let first = &group[0];
let mut merged_z = [[0.0f64; 2]; 9];
let mut turns_ratio = first.turns_ratio;
let mut phase_shift_rad = first.phase_shift_rad;
let mut rated_kva = 0.0;
let mut phase_count = [0u32; 3];
for xfmr in &group {
rated_kva += xfmr.rated_kva;
for (ph, count) in phase_count.iter_mut().enumerate() {
let diag = ph * 3 + ph;
let self_r = xfmr.z_matrix.z[diag][0];
if self_r < 1e5 {
merged_z[diag] = xfmr.z_matrix.z[diag];
*count += 1;
}
}
turns_ratio = (turns_ratio + xfmr.turns_ratio) / 2.0;
if xfmr.phase_shift_rad.abs() > phase_shift_rad.abs() {
phase_shift_rad = xfmr.phase_shift_rad;
}
}
if group.len() == 2 {
for (ph, &count) in phase_count.iter().enumerate() {
if count == 2 {
let diag = ph * 3 + ph;
merged_z[diag] = [1e-3, 0.0]; }
}
}
for (ph, _) in phase_count.iter().enumerate() {
let diag = ph * 3 + ph;
if merged_z[diag][0] == 0.0 && merged_z[diag][1] == 0.0 {
merged_z[diag] = [1e6, 0.0];
}
}
let mut merged_regs: [Option<RegulatorControl>; 3] = [None, None, None];
for xfmr in &group {
for (ph, reg) in merged_regs.iter_mut().enumerate() {
let diag = ph * 3 + ph;
if xfmr.z_matrix.z[diag][0] < 1e5 && xfmr.regulators[ph].is_some() {
*reg = xfmr.regulators[ph].clone();
}
}
}
let merged_dd = group.iter().any(|x| x.is_delta_delta);
let merged_g_mag: f64 = group.iter().map(|x| x.g_mag_siemens).sum();
let merged_b_mag: f64 = group.iter().map(|x| x.b_mag_siemens).sum();
result.push(ThreePhaseTransformer {
from_bus: first.from_bus,
to_bus: first.to_bus,
z_matrix: PhaseImpedanceMatrix { z: merged_z },
turns_ratio,
phase_shift_rad,
rated_kva,
is_delta_delta: merged_dd,
g_mag_siemens: merged_g_mag,
b_mag_siemens: merged_b_mag,
regulators: merged_regs,
ganged_regulator: false, });
}
}
result
}
pub fn dss_to_3ph_dist(path: &Path) -> Result<(ThreePhaseNetwork, Vec<String>), DssParseError> {
let mut catalog = build_dss_catalog(path)?;
let circ = catalog.circuit.as_ref().ok_or(DssParseError::NoCircuit)?;
let source_voltage_kv = circ.base_kv;
let source_pu = circ.pu;
let (bus_map, bus_names) = build_3ph_bus_map(&catalog);
let n_buses = bus_names.len();
let source_bus = 0usize;
let mut branches = Vec::new();
let mut loads = Vec::new();
let mut transformers = Vec::new();
let system_freq_hz = catalog
.circuit
.as_ref()
.map(|c| c.frequency)
.filter(|f| *f > 0.0)
.unwrap_or(60.0);
let objects: Vec<DssObject> = catalog.objects.drain(..).collect();
for obj in &objects {
match obj {
DssObject::Line(line) => {
if let Some(br) = line_to_3ph_branch(line, &bus_map, system_freq_hz) {
branches.push(br);
}
}
DssObject::Load(load) => {
if let Some(ld) = load_to_3ph_load(load, &bus_map) {
loads.push(ld);
}
}
DssObject::Capacitor(cap) => {
if let Some(ld) = capacitor_to_3ph_load(cap, &bus_map) {
loads.push(ld);
}
}
DssObject::Transformer(xfmr) => {
if let Some(tx) = xfmr_to_3ph_transformer(xfmr, &bus_map) {
transformers.push(tx);
}
}
DssObject::AutoTrans(at) => {
if let Some(tx) = xfmr_to_3ph_transformer(&at.transformer, &bus_map) {
transformers.push(tx);
}
}
DssObject::Reactor(reactor) => {
if let Some(br) = reactor_to_3ph_branch(reactor, &bus_map) {
branches.push(br);
}
}
DssObject::Generator(gendata) => {
if let Some(ld) = generator_to_3ph_load(gendata, &bus_map) {
loads.push(ld);
}
}
DssObject::PvSystem(pv) => {
if let Some(ld) = pvsystem_to_3ph_load(pv, &bus_map) {
loads.push(ld);
}
}
DssObject::Storage(st) => {
if let Some(ld) = storage_to_3ph_load(st, &bus_map) {
loads.push(ld);
}
}
DssObject::Fault(fault) => {
if let Some(br) = fault_to_3ph_branch(fault, &bus_map) {
branches.push(br);
}
}
_ => {}
}
}
{
let mut xfmr_by_name: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut tx_idx = 0usize;
for obj in &objects {
match obj {
DssObject::Transformer(xfmr) => {
xfmr_by_name.insert(xfmr.name.to_lowercase(), tx_idx);
tx_idx += 1;
}
DssObject::AutoTrans(at) => {
xfmr_by_name.insert(at.transformer.name.to_lowercase(), tx_idx);
tx_idx += 1;
}
_ => {}
}
}
let xfmr_data_by_name: std::collections::HashMap<String, &super::objects::TransformerData> =
objects
.iter()
.filter_map(|o| {
if let DssObject::Transformer(td) = o {
Some((td.name.to_lowercase(), td))
} else {
None
}
})
.collect();
for obj in &objects {
if let DssObject::VoltageRegulator(reg) = obj {
let xfmr_name = reg.transformer.to_lowercase();
if let Some(&idx) = xfmr_by_name.get(&xfmr_name)
&& idx < transformers.len()
{
let orig = xfmr_data_by_name.get(&xfmr_name);
let is_single_phase_delta = orig.is_some_and(|td| {
td.phases == 1 && td.conns.iter().any(|c| matches!(c, WdgConn::Delta))
});
let delta_pair = if is_single_phase_delta {
orig.and_then(|td| {
let bus_spec = td.buses.first()?;
parse_delta_pair(bus_spec)
})
} else {
None
};
let v_set_pu = reg.vreg / 120.0;
let ctrl = RegulatorControl {
v_set_pu,
tap_max_pu: 0.10, tap_step_pu: 5.0 / 8.0 / 100.0, r_ldc_pu: reg.r,
x_ldc_pu: reg.x,
ct_prim: reg.ct_prim, pt_ratio: reg.pt_ratio, band_v: reg.band, delta_pair,
};
if let Some((reg_ph, _ref_ph)) = delta_pair {
if transformers[idx].regulators[reg_ph].is_none() {
transformers[idx].regulators[reg_ph] = Some(ctrl);
}
} else {
let is_3ph_ganged = orig.is_some_and(|td| td.phases >= 2);
for ph in 0..3usize {
let diag = ph * 3 + ph;
if transformers[idx].z_matrix.z[diag][0] < 1e5
&& transformers[idx].regulators[ph].is_none()
{
transformers[idx].regulators[ph] = Some(ctrl.clone());
}
}
if is_3ph_ganged {
transformers[idx].ganged_regulator = true;
}
}
}
}
}
}
let mut transformers = deduplicate_transformers(transformers);
{
use std::collections::HashSet;
let xfmr_pairs: HashSet<(usize, usize)> = transformers
.iter()
.map(|x| {
let a = x.from_bus.min(x.to_bus);
let b = x.from_bus.max(x.to_bus);
(a, b)
})
.collect();
branches.retain(|br| {
let a = br.from_bus.min(br.to_bus);
let b = br.from_bus.max(br.to_bus);
!xfmr_pairs.contains(&(a, b))
});
}
let mut source_bus = source_bus;
let mut source_voltage_kv = source_voltage_kv;
let mut source_impedance: Option<PhaseImpedanceMatrix> = None;
loop {
let xfmr_pos = transformers.iter().position(|xfmr| {
(xfmr.from_bus == source_bus || xfmr.to_bus == source_bus) && xfmr.turns_ratio > 1.1
});
if let Some(p) = xfmr_pos {
let sub = transformers.remove(p);
source_bus = if sub.from_bus == source_bus {
sub.to_bus
} else {
sub.from_bus
};
source_voltage_kv /= sub.turns_ratio;
if sub.is_delta_delta {
let mut coupled = [[0.0f64; 2]; 9];
for i in 0..3usize {
let z_diag = sub.z_matrix.z[i * 3 + i]; for j in 0..3usize {
let scale = if i == j { 2.0 / 3.0 } else { -1.0 / 3.0 };
coupled[i * 3 + j] = [z_diag[0] * scale, z_diag[1] * scale];
}
}
source_impedance = Some(PhaseImpedanceMatrix { z: coupled });
} else {
source_impedance = Some(sub.z_matrix);
}
continue;
}
let branch_pos = branches
.iter()
.position(|br| br.from_bus == source_bus || br.to_bus == source_bus);
if let Some(p) = branch_pos {
let br = &branches[p];
let other_bus = if br.from_bus == source_bus {
br.to_bus
} else {
br.from_bus
};
let has_stepdown = transformers.iter().any(|xfmr| {
(xfmr.from_bus == other_bus || xfmr.to_bus == other_bus) && xfmr.turns_ratio > 1.1
});
if has_stepdown {
branches.remove(p);
source_bus = other_bus;
continue;
}
}
break;
}
let network = ThreePhaseNetwork {
n_buses,
source_bus,
source_voltage_kv,
source_pu,
branches,
loads,
transformers,
source_impedance,
};
Ok((network, bus_names))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_phase_spec_three_phase() {
let (name, mask) = parse_phase_spec("650.1.2.3");
assert_eq!(name, "650");
assert_eq!(mask, 0b111);
}
#[test]
fn test_parse_phase_spec_single_phase_a() {
let (name, mask) = parse_phase_spec("node.1");
assert_eq!(name, "node");
assert_eq!(mask, 0b001);
}
#[test]
fn test_parse_phase_spec_single_phase_b() {
let (name, mask) = parse_phase_spec("node.2");
assert_eq!(name, "node");
assert_eq!(mask, 0b010);
}
#[test]
fn test_parse_phase_spec_single_phase_c() {
let (name, mask) = parse_phase_spec("node.3");
assert_eq!(name, "node");
assert_eq!(mask, 0b100);
}
#[test]
fn test_parse_phase_spec_two_phase_ab() {
let (name, mask) = parse_phase_spec("node.1.2");
assert_eq!(name, "node");
assert_eq!(mask, 0b011);
}
#[test]
fn test_parse_phase_spec_no_dot() {
let (name, mask) = parse_phase_spec("node");
assert_eq!(name, "node");
assert_eq!(mask, 0b111);
}
#[test]
fn test_expand_lower_tri_3x3() {
let tri = [1.0, 2.0, 5.0, 3.0, 6.0, 9.0];
let m = expand_lower_tri_3x3(&tri);
assert_eq!(m[0][0], 1.0);
assert_eq!(m[1][1], 5.0);
assert_eq!(m[2][2], 9.0);
assert_eq!(m[1][0], 2.0);
assert_eq!(m[0][1], 2.0); assert_eq!(m[2][0], 3.0);
assert_eq!(m[0][2], 3.0); }
#[test]
fn test_compile_recurses_into_child_base_dir() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("sub");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(
dir.path().join("main.dss"),
"New Circuit.main basekv=12.47 bus1=source\nCompile sub/child.dss\n",
)
.unwrap();
std::fs::write(subdir.join("child.dss"), "Compile grandchild.dss\n").unwrap();
std::fs::write(
subdir.join("grandchild.dss"),
"New Load.Load1 bus1=load.1 kw=90 kvar=30\n",
)
.unwrap();
let catalog = build_catalog_from_str(
"New Circuit.main basekv=12.47 bus1=source\nCompile sub/child.dss\n",
Some(dir.path()),
)
.expect("compile recursion should resolve nested child files");
assert!(
catalog
.objects
.iter()
.any(|obj| matches!(obj, DssObject::Load(load) if load.name == "Load1")),
"nested compile should include the grandchild load"
);
}
}