use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value, json};
use crate::convert::TextEmission;
use crate::diagnostics::codes as C;
use crate::geo::CoordinateSpace;
use crate::model::{
ConductorMatrix, Configuration, DistBus, DistLine, DistLineCode, DistLoadVoltageModel,
DistTransformer, DistWinding, DistWindingConn, Extras, MulticonductorNetwork, VoltageSource,
};
pub(crate) fn emit_pmd_json_text(net: &MulticonductorNetwork) -> TextEmission {
let mut w = Writer {
warnings: crate::diagnostics::Diagnostics::new(),
renamed_terminals: renamed_terminals(net),
};
for source in net
.sources()
.iter()
.filter(|source| source.energy_cost_rate.is_some())
{
w.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!(
"voltage source {}: energy_cost_rate has no target field",
source.name
),
);
}
let doc = w.document(net);
TextEmission::new(
serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
Vec::new(),
w.warnings,
)
}
const MAX_DIM: usize = 64;
struct Writer {
warnings: crate::diagnostics::Diagnostics,
renamed_terminals: BTreeMap<String, i64>,
}
fn renamed_terminals(net: &MulticonductorNetwork) -> BTreeMap<String, i64> {
let mut used = BTreeSet::new();
let mut names = BTreeSet::new();
for terminal in all_terminal_names(net) {
match terminal.parse::<i64>() {
Ok(value) => {
used.insert(value);
}
Err(_) => {
names.insert(terminal.to_string());
}
}
}
let mut out = BTreeMap::new();
let mut next = 1i64;
for name in names {
while used.contains(&next) {
next += 1;
}
used.insert(next);
out.insert(name, next);
next += 1;
}
out
}
fn linecodes_by_name(net: &MulticonductorNetwork) -> BTreeMap<String, &DistLineCode> {
let mut by_name = BTreeMap::new();
for c in net.line_codes() {
by_name.entry(c.name.to_ascii_lowercase()).or_insert(c);
}
by_name
}
fn buses_by_id(net: &MulticonductorNetwork) -> BTreeMap<String, &DistBus> {
let mut by_id = BTreeMap::new();
for b in net.buses() {
by_id.entry(b.id.to_ascii_lowercase()).or_insert(b);
}
by_id
}
impl Writer {
fn conns(&mut self, map: &[String], what: &str) -> Vec<i64> {
map.iter()
.map(|t| {
if let Ok(value) = t.parse::<i64>() {
return value;
}
let id = self.renamed_terminals.get(t).copied().unwrap_or(0);
self.warnings.push(
&C::EMIT_PMD_VALUE_SUBSTITUTED,
format!("{what}: terminal `{t}` is not numeric; emitted as {id}"),
);
id
})
.collect()
}
}
fn all_terminal_names(net: &MulticonductorNetwork) -> impl Iterator<Item = &str> {
let maps = net
.lines()
.iter()
.flat_map(|l| [&l.terminal_map_from, &l.terminal_map_to])
.chain(
net.switches()
.iter()
.flat_map(|s| [&s.terminal_map_from, &s.terminal_map_to]),
)
.chain(net.loads().iter().map(|l| &l.terminal_map))
.chain(net.generators().iter().map(|g| &g.terminal_map))
.chain(net.capacitors().iter().map(|c| &c.terminal_map))
.chain(net.shunts().iter().map(|s| &s.terminal_map))
.chain(net.sources().iter().map(|s| &s.terminal_map))
.chain(net.ibrs().iter().map(|i| &i.terminal_map))
.chain(
net.transformers()
.iter()
.flat_map(|t| t.windings.iter().map(|w| &w.terminal_map)),
);
net.buses()
.iter()
.flat_map(|b| [&b.terminals, &b.grounded])
.chain(maps)
.flat_map(|m| m.iter().map(String::as_str))
}
fn capacitor_susceptance(
c: &crate::model::DistCapacitor,
) -> std::result::Result<ConductorMatrix, String> {
let finite_positive = |v: f64| v.is_finite() && v > 0.0;
if !finite_positive(c.v_nom) || !c.q_rated.is_finite() {
return Err("nameplate q_rated or v_nom is missing or nonpositive".into());
}
let n = c.terminal_map.len();
let mut bs = vec![vec![0.0; n]; n];
let mut couple = |a: usize, b: usize, y: f64| {
bs[a][a] += y;
bs[b][b] += y;
bs[a][b] -= y;
bs[b][a] -= y;
};
match c.configuration {
crate::model::Configuration::SinglePhase => {
if n != 2 {
return Err(format!("single phase bank maps {n} terminals"));
}
couple(0, 1, c.q_rated / (c.v_nom * c.v_nom));
}
crate::model::Configuration::Wye => {
let phases = n.saturating_sub(1);
if phases == 0 {
return Err("wye bank maps no phase terminal".into());
}
let v_ln = c.v_nom / 3f64.sqrt();
let b = c.q_rated / (phases as f64) / (v_ln * v_ln);
for phase in 0..phases {
couple(phase, phases, b);
}
}
crate::model::Configuration::Delta => {
if n < 2 {
return Err(format!("delta bank maps {n} terminals"));
}
let loops = if n == 2 { 1 } else { n };
let b = c.q_rated / (loops as f64) / (c.v_nom * c.v_nom);
for pair in 0..loops {
couple(pair, (pair + 1) % n, b);
}
}
}
Ok(bs)
}
fn matrix(m: &ConductorMatrix) -> Value {
let n = m.len();
let cols: Vec<Value> = (0..n)
.map(|j| {
Value::Array(
(0..n)
.map(|i| json!(m[i].get(j).copied().unwrap_or(0.0)))
.collect(),
)
})
.collect();
Value::Array(cols)
}
fn zero_matrix(n: usize) -> ConductorMatrix {
vec![vec![0.0; n]; n]
}
fn shunt_is_delta(extras: &Extras) -> bool {
extras
.get("conn")
.and_then(|v| v.as_str())
.is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
}
fn scale(m: &ConductorMatrix, k: f64) -> ConductorMatrix {
m.iter()
.map(|row| row.iter().map(|v| v * k).collect())
.collect()
}
impl Writer {
fn warn(&mut self, info: &'static crate::diagnostics::DiagnosticInfo, msg: impl Into<String>) {
self.warnings.push(info, msg);
}
fn bounded_dim(&mut self, n: usize, what: &str) -> usize {
if n > MAX_DIM {
self.warn(
&C::EMIT_PMD_VALUE_CLAMPED,
format!(
"{what}: {n} conductors exceed the supported maximum of \
{MAX_DIM}; matrices emitted at {MAX_DIM}"
),
);
MAX_DIM
} else {
n
}
}
fn extras_dropped(&mut self, extras: &crate::model::Extras, consumed: &[&str], what: &str) {
for key in extras.keys() {
if consumed.contains(&key.as_str()) || key.starts_with("pmd_") || key == "bmopf_subtype"
{
continue;
}
self.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!("{what}: `{key}` has no ENGINEERING field; dropped from the output"),
);
}
}
fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
extras.get(key).and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
}
fn status(extras: &Extras) -> Value {
extras
.get("pmd_status")
.cloned()
.unwrap_or_else(|| json!("ENABLED"))
}
fn bus_coordinates(
&mut self,
o: &mut Map<String, Value>,
b: &DistBus,
net: &MulticonductorNetwork,
) {
if let Some(location) = b.location {
if !matches!(
net.geo().as_ref().map(|geo| &geo.space),
Some(CoordinateSpace::Geographic { .. })
) {
self.warnings.push(&C::EMIT_PMD_FIELD_DROPPED, format!(
"bus {}: non-geographic or undeclared location is not emitted to PMD lon/lat",
b.id
));
return;
}
if location.x.is_finite() && location.y.is_finite() {
o.insert("lon".into(), json!(location.x));
o.insert("lat".into(), json!(location.y));
} else {
self.warnings.push(
&C::EMIT_PMD_FIELD_DROPPED,
format!(
"bus {}: nonfinite location is not emitted to PMD JSON",
b.id
),
);
}
return;
}
if let Some(x) = Self::extras_f64(&b.extras, "x") {
o.insert("lon".into(), json!(x));
}
if let Some(y) = Self::extras_f64(&b.extras, "y") {
o.insert("lat".into(), json!(y));
}
}
#[expect(clippy::too_many_lines)]
fn document(&mut self, net: &MulticonductorNetwork) -> Value {
let mut doc = Map::new();
doc.insert("data_model".into(), json!("ENGINEERING"));
doc.insert(
"name".into(),
json!(net.name().clone().unwrap_or_default().to_lowercase()),
);
doc.insert(
"files".into(),
net.extras()
.get("pmd_files")
.cloned()
.unwrap_or_else(|| json!([])),
);
let settings = net
.extras()
.get("pmd_settings")
.cloned()
.unwrap_or_else(|| synthesized_settings(net));
doc.insert("settings".into(), settings);
let max_conductor = net
.buses()
.iter()
.flat_map(|b| &b.terminals)
.filter_map(|t| t.parse::<i64>().ok())
.chain(self.renamed_terminals.values().copied())
.max()
.unwrap_or(4);
let max_dim = i64::try_from(MAX_DIM).expect("MAX_DIM is small");
if max_conductor > max_dim {
self.warn(
&C::EMIT_PMD_VALUE_CLAMPED,
format!(
"terminal {max_conductor} exceeds the supported maximum conductor id \
of {MAX_DIM}; conductor_ids enumerated to {MAX_DIM}"
),
);
}
let max_conductor = max_conductor.clamp(4, max_dim);
doc.insert(
"conductor_ids".into(),
Value::Array((1..=max_conductor).map(|i| json!(i)).collect()),
);
let mut buses = Map::new();
for b in net.buses() {
let mut o = Map::new();
o.insert(
"terminals".into(),
json!(self.conns(&b.terminals, &format!("bus {}", b.id))),
);
let grounded = self.conns(&b.grounded, &format!("bus {}", b.id));
for key in ["rg", "xg"] {
let v = b
.extras
.get(key)
.cloned()
.unwrap_or_else(|| json!(vec![0.0; grounded.len()]));
o.insert(key.into(), v);
}
o.insert("grounded".into(), json!(grounded));
o.insert("status".into(), Self::status(&b.extras));
self.bus_coordinates(&mut o, b, net);
let phase_indices = b.phase_indices(net.extras().get("bmopf_terminal_conventions"));
for (key, scalar, phases, default) in [
("vm_lb", b.v_min, &b.v_min_phase, 0.0),
("vm_ub", b.v_max, &b.v_max_phase, f64::INFINITY),
] {
let retained = b.extras.get(&format!("pmd_{key}"));
let mut values = retained
.and_then(Value::as_array)
.cloned()
.filter(|values| values.len() == b.terminals.len())
.unwrap_or_else(|| vec![json!(default); b.terminals.len()]);
if scalar.is_some() || phases.is_some() {
for (phase, &terminal) in phase_indices.iter().enumerate() {
if let Some(v) =
scalar.or_else(|| phases.as_ref().and_then(|v| v.get(phase)).copied())
{
values[terminal] = json!(v / 1e3);
}
}
o.insert(key.into(), Value::Array(values));
} else if let Some(retained) = retained {
o.insert(key.into(), retained.clone());
}
}
for (key, present) in [
("vpn_min", b.vpn_min.is_some()),
("vpn_max", b.vpn_max.is_some()),
("vpp_min", b.vpp_min.is_some()),
("vpp_max", b.vpp_max.is_some()),
("vpos_min", b.vpos_min.is_some()),
("vpos_max", b.vpos_max.is_some()),
("vneg_max", b.vneg_max.is_some()),
("vzero_max", b.vzero_max.is_some()),
("vn_max", b.vn_max.is_some()),
] {
if present {
self.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!(
"bus {}: `{key}` volt bounds have no ENGINEERING field; dropped",
b.id
),
);
}
}
buses.insert(b.id.to_lowercase(), Value::Object(o));
}
doc.insert("bus".into(), Value::Object(buses));
self.line_codes(net, &mut doc);
self.branches(net, &mut doc);
self.injections(net, &mut doc);
self.transformers(net, &mut doc);
for u in net.untyped_objects() {
self.warn(
&C::EMIT_PMD_RECORD_DROPPED,
format!(
"{} {}: class is not converted to ENGINEERING; dropped from the output",
u.class, u.name
),
);
}
Value::Object(doc)
}
fn line_codes(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
let inlined = inlined_codes(net);
let mut codes = Map::new();
for c in net.line_codes() {
if inlined.contains(&c.name.to_lowercase()) {
continue;
}
let mut o = Map::new();
insert_impedance_matrices(&mut o, c, net.base_frequency());
if let Some(i_max) = &c.i_max {
o.insert("cm_ub".into(), json!(i_max));
}
if let Some(s_max) = &c.s_max {
o.insert("sm_ub".into(), json!(s_max));
}
if c.source.is_some() {
self.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!(
"linecode {}: matrix provenance `source` has no ENGINEERING field; dropped",
c.name
),
);
}
codes.insert(c.name.to_lowercase(), Value::Object(o));
}
if !codes.is_empty() {
doc.insert("linecode".into(), Value::Object(codes));
}
}
fn line_ratings(o: &mut Map<String, Value>, l: &DistLine) {
if let Some(i_max) = &l.i_max {
o.insert("cm_ub".into(), json!(i_max));
}
if let Some(s_max) = &l.s_max {
o.insert("sm_ub".into(), json!(s_max));
}
}
#[allow(clippy::too_many_lines)]
fn branches(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
if !net.lines().is_empty() {
let mut lines = Map::new();
let linecodes_by_name = linecodes_by_name(net);
for l in net.lines() {
let mut o = Map::new();
o.insert("f_bus".into(), json!(l.bus_from.to_lowercase()));
o.insert("t_bus".into(), json!(l.bus_to.to_lowercase()));
let what = format!("line {}", l.name);
o.insert(
"f_connections".into(),
json!(self.conns(&l.terminal_map_from, &what)),
);
o.insert(
"t_connections".into(),
json!(self.conns(&l.terminal_map_to, &what)),
);
o.insert("length".into(), json!(l.length));
let inline = l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true);
let lc = linecodes_by_name.get(l.linecode.to_ascii_lowercase().as_str());
match lc.copied() {
Some(c) if inline => {
insert_impedance_matrices(&mut o, c, net.base_frequency());
if let Some(i_max) = &c.i_max {
o.insert("cm_ub".into(), json!(i_max));
}
if let Some(s_max) = &c.s_max {
o.insert("sm_ub".into(), json!(s_max));
}
}
_ => {
if inline {
self.warn(&C::EMIT_PMD_VALUE_SUBSTITUTED, format!(
"{what}: linecode `{}` is missing; emitted the reference instead of inline impedance",
l.linecode
));
}
o.insert("linecode".into(), json!(l.linecode.to_lowercase()));
}
}
Self::line_ratings(&mut o, l);
o.insert("status".into(), Self::status(&l.extras));
o.insert(
"source_id".into(),
json!(format!("line.{}", l.name.to_lowercase())),
);
self.extras_dropped(&l.extras, &["units"], &what);
lines.insert(l.name.to_lowercase(), Value::Object(o));
}
doc.insert("line".into(), Value::Object(lines));
}
if !net.switches().is_empty() {
let mut switches = Map::new();
for s in net.switches() {
let mut o = Map::new();
let what = format!("switch {}", s.name);
let n = self.bounded_dim(s.terminal_map_from.len(), &what);
o.insert("f_bus".into(), json!(s.bus_from.to_lowercase()));
o.insert("t_bus".into(), json!(s.bus_to.to_lowercase()));
o.insert(
"f_connections".into(),
json!(self.conns(&s.terminal_map_from, &what)),
);
o.insert(
"t_connections".into(),
json!(self.conns(&s.terminal_map_to, &what)),
);
let rs = s.extras.get("pmd_rs").cloned().unwrap_or_else(|| {
let mut rs = zero_matrix(n);
for (i, row) in rs.iter_mut().enumerate() {
row[i] = 1e-4 * 0.001;
}
matrix(&rs)
});
o.insert("rs".into(), rs);
let xs = s
.extras
.get("pmd_xs")
.cloned()
.unwrap_or_else(|| matrix(&zero_matrix(n)));
o.insert("xs".into(), xs);
o.insert("g_fr".into(), matrix(&zero_matrix(n)));
o.insert("g_to".into(), matrix(&zero_matrix(n)));
o.insert("b_fr".into(), matrix(&zero_matrix(n)));
o.insert("b_to".into(), matrix(&zero_matrix(n)));
if let Some(i_max) = &s.i_max {
o.insert("cm_ub".into(), json!(i_max));
}
o.insert(
"state".into(),
json!(if s.open { "OPEN" } else { "CLOSED" }),
);
o.insert("dispatchable".into(), json!("YES"));
o.insert("status".into(), Self::status(&s.extras));
o.insert(
"source_id".into(),
json!(format!("line.{}", s.name.to_lowercase())),
);
self.extras_dropped(&s.extras, &[], &what);
switches.insert(s.name.to_lowercase(), Value::Object(o));
}
doc.insert("switch".into(), Value::Object(switches));
}
}
fn loads(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
if !net.loads().is_empty() {
let mut loads = Map::new();
let buses_by_id = buses_by_id(net);
for l in net.loads() {
let mut o = Map::new();
let what = format!("load {}", l.name);
let connections = self.conns(&l.terminal_map, &what);
let configuration = match l.configuration {
Configuration::Delta => "DELTA",
Configuration::Wye => "WYE",
Configuration::SinglePhase => {
let grounded_return = l
.terminal_map
.last()
.zip(
buses_by_id
.get(l.bus.to_ascii_lowercase().as_str())
.copied(),
)
.is_some_and(|(t, b)| b.grounded.contains(t));
if grounded_return { "WYE" } else { "DELTA" }
}
};
o.insert("configuration".into(), json!(configuration));
o.insert("connections".into(), json!(connections));
o.insert(
"pd_nom".into(),
json!(l.p_nom.iter().map(|p| p / 1e3).collect::<Vec<_>>()),
);
o.insert(
"qd_nom".into(),
json!(l.q_nom.iter().map(|q| q / 1e3).collect::<Vec<_>>()),
);
o.insert("bus".into(), json!(l.bus.to_lowercase()));
let mut insert_vm_nom = |v_nom: &[f64]| {
if let Some(value) = source_vm_nom(&l.extras, v_nom) {
o.insert("vm_nom".into(), value);
} else if !v_nom.is_empty() {
let value = if v_nom.len() == 1 {
json!(v_nom[0] / 1e3)
} else {
json!(v_nom.iter().map(|v| v / 1e3).collect::<Vec<_>>())
};
o.insert("vm_nom".into(), value);
} else if let Some(kv) = Self::extras_f64(&l.extras, "kv") {
o.insert("vm_nom".into(), json!(kv));
}
};
let model = match &l.voltage_model {
DistLoadVoltageModel::ConstantImpedance { v_nom } => {
insert_vm_nom(v_nom);
"IMPEDANCE"
}
DistLoadVoltageModel::ConstantCurrent { v_nom } => {
insert_vm_nom(v_nom);
"CURRENT"
}
DistLoadVoltageModel::Zip { v_nom, .. } => {
insert_vm_nom(v_nom);
"ZIPV"
}
DistLoadVoltageModel::Exponential { v_nom, .. } => {
insert_vm_nom(v_nom);
self.warn(&C::EMIT_PMD_VALUE_SUBSTITUTED, format!(
"{what}: exponential load model has no ENGINEERING field; emitted POWER"
));
"POWER"
}
DistLoadVoltageModel::ConstantPower { v_nom } => {
insert_vm_nom(v_nom);
"POWER"
}
};
o.insert("model".into(), json!(model));
o.insert("dispatchable".into(), json!("NO"));
o.insert("status".into(), Self::status(&l.extras));
o.insert(
"source_id".into(),
json!(format!("load.{}", l.name.to_lowercase())),
);
self.extras_dropped(&l.extras, &["kv", "model", "pf"], &what);
loads.insert(l.name.to_lowercase(), Value::Object(o));
}
doc.insert("load".into(), Value::Object(loads));
}
}
fn generators(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
if !net.generators().is_empty() {
let mut gens = Map::new();
for g in net.generators() {
let mut o = Map::new();
let what = format!("generator {}", g.name);
o.insert("bus".into(), json!(g.bus.to_lowercase()));
o.insert(
"connections".into(),
json!(self.conns(&g.terminal_map, &what)),
);
o.insert(
"configuration".into(),
json!(match g.configuration {
Configuration::Delta => "DELTA",
_ => "WYE",
}),
);
let kw = |w: &[f64]| w.iter().map(|v| v / 1e3).collect::<Vec<_>>();
o.insert("pg".into(), json!(kw(&g.p_nom)));
o.insert("qg".into(), json!(kw(&g.q_nom)));
if let Some(b) = &g.q_min {
o.insert("qg_lb".into(), json!(kw(b)));
}
if let Some(b) = &g.q_max {
o.insert("qg_ub".into(), json!(kw(b)));
}
if let Some(b) = &g.p_min {
o.insert("pg_lb".into(), json!(kw(b)));
}
if let Some(b) = &g.p_max {
o.insert("pg_ub".into(), json!(kw(b)));
}
if g.cost.is_some() {
self.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!("{what}: generation cost has no ENGINEERING field; dropped"),
);
}
for (key, present) in [("s_max", g.s_max.is_some()), ("i_max", g.i_max.is_some())] {
if present {
self.warn(
&C::EMIT_PMD_FIELD_DROPPED,
format!(
"{what}: `{key}` has no ENGINEERING generator mapping yet; dropped"
),
);
}
}
o.insert("control_mode".into(), json!("FREQUENCYDROOP"));
o.insert("status".into(), Self::status(&g.extras));
o.insert(
"source_id".into(),
json!(format!("generator.{}", g.name.to_lowercase())),
);
self.extras_dropped(&g.extras, &["kv"], &what);
gens.insert(g.name.to_lowercase(), Value::Object(o));
}
doc.insert("generator".into(), Value::Object(gens));
}
}
fn injections(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
self.loads(net, doc);
self.generators(net, doc);
let mut capacitor_shunts = Map::new();
for c in net.capacitors() {
let what = format!("capacitor {}", c.name);
match capacitor_susceptance(c) {
Ok(bs) => {
let n = bs.len();
let mut o = Map::new();
o.insert("bus".into(), json!(c.bus.to_lowercase()));
o.insert(
"connections".into(),
json!(self.conns(&c.terminal_map, &what)),
);
o.insert("gs".into(), matrix(&vec![vec![0.0; n]; n]));
o.insert("bs".into(), matrix(&bs));
o.insert(
"configuration".into(),
json!(match c.configuration {
Configuration::Delta => "DELTA",
_ => "WYE",
}),
);
o.insert("model".into(), json!("CAPACITOR"));
o.insert("dispatchable".into(), json!("NO"));
o.insert("status".into(), Self::status(&c.extras));
o.insert(
"source_id".into(),
json!(format!("capacitor.{}", c.name.to_lowercase())),
);
self.extras_dropped(&c.extras, &[], &what);
capacitor_shunts.insert(c.name.to_lowercase(), Value::Object(o));
}
Err(reason) => {
self.warn(
&C::EMIT_PMD_RECORD_DROPPED,
format!("{what}: {reason}; dropped"),
);
}
}
}
if !net.shunts().is_empty() || !capacitor_shunts.is_empty() {
let mut shunts = capacitor_shunts;
for s in net.shunts() {
let mut o = Map::new();
let what = format!("shunt {}", s.name);
o.insert("bus".into(), json!(s.bus.to_lowercase()));
o.insert(
"connections".into(),
json!(self.conns(&s.terminal_map, &what)),
);
o.insert("gs".into(), matrix(&s.g));
o.insert("bs".into(), matrix(&s.b));
let configuration = if shunt_is_delta(&s.extras) {
"DELTA"
} else {
"WYE"
};
o.insert("configuration".into(), json!(configuration));
o.insert("model".into(), json!("CAPACITOR"));
o.insert("dispatchable".into(), json!("NO"));
o.insert("status".into(), Self::status(&s.extras));
o.insert(
"source_id".into(),
json!(format!("capacitor.{}", s.name.to_lowercase())),
);
self.extras_dropped(&s.extras, &["kv", "kvar", "conn"], &what);
shunts.insert(s.name.to_lowercase(), Value::Object(o));
}
doc.insert("shunt".into(), Value::Object(shunts));
}
let mut sources = Map::new();
for vs in net.sources() {
sources.insert(vs.name.to_lowercase(), self.voltage_source(vs));
}
doc.insert("voltage_source".into(), Value::Object(sources));
}
fn voltage_source(&mut self, vs: &VoltageSource) -> Value {
let mut o = Map::new();
let what = format!("voltage source {}", vs.name);
let connections = self.conns(&vs.terminal_map, &what);
let n = self.bounded_dim(connections.len(), &what);
o.insert("bus".into(), json!(vs.bus.to_lowercase()));
o.insert("connections".into(), json!(connections));
o.insert("configuration".into(), json!("WYE"));
o.insert(
"vm".into(),
json!(vs.v_magnitude.iter().map(|v| v / 1e3).collect::<Vec<_>>()),
);
o.insert(
"va".into(),
json!(
vs.v_angle
.iter()
.map(|a| a.to_degrees())
.collect::<Vec<_>>()
),
);
if let (Some(rs), Some(xs)) = (vs.extras.get("rs"), vs.extras.get("xs")) {
o.insert("rs".into(), rs.clone());
o.insert("xs".into(), xs.clone());
} else {
let (rs, xs) = thevenin(vs, n);
if rs.iter().flatten().all(|&v| v == 0.0) {
self.warn(
&C::EMIT_PMD_VALUE_DEFAULTED,
format!("{what}: no short circuit data; emitted an ideal source (zero rs/xs)"),
);
}
o.insert("rs".into(), matrix(&rs));
o.insert("xs".into(), matrix(&xs));
}
o.insert("status".into(), Self::status(&vs.extras));
o.insert(
"source_id".into(),
json!(format!("vsource.{}", vs.name.to_lowercase())),
);
self.extras_dropped(
&vs.extras,
&[
"basekv",
"basemva",
"pu",
"angle",
"mvasc1",
"mvasc3",
"x1r1",
"x0r0",
"rs",
"xs",
"isc1",
"isc3",
"configuration",
],
&what,
);
Value::Object(o)
}
fn transformers(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
if net.transformers().is_empty() {
return;
}
let mut out = Map::new();
for t in net.transformers() {
out.insert(t.name.to_lowercase(), self.transformer(t));
}
doc.insert("transformer".into(), Value::Object(out));
}
fn transformer(&mut self, t: &DistTransformer) -> Value {
let mut o = Map::new();
let what = format!("transformer {}", t.name);
let phases = t.phases;
let stashed = t.extras.contains_key("pmd_polarity");
let mut buses = Vec::new();
let mut connections: Vec<Value> = Vec::new();
for (w_idx, w) in t.windings.iter().enumerate() {
buses.push(json!(w.bus.to_lowercase()));
let mut c = self.conns(&w.terminal_map, &what);
if !stashed
&& w_idx > 0
&& t.windings[0].conn == DistWindingConn::Delta
&& w.conn == DistWindingConn::Wye
&& c.len() > 1
{
let phases_part = c.len() - 1;
c[..phases_part].rotate_left(1);
}
connections.push(json!(c));
}
o.insert("bus".into(), Value::Array(buses));
o.insert(
"connections".into(),
t.extras
.get("pmd_connections")
.cloned()
.unwrap_or(Value::Array(connections)),
);
o.insert(
"polarity".into(),
t.extras
.get("pmd_polarity")
.cloned()
.unwrap_or_else(|| json!(lag_polarity(&t.windings))),
);
o.insert(
"configuration".into(),
Value::Array(
t.windings
.iter()
.map(|w| {
json!(match w.conn {
DistWindingConn::Wye => "WYE",
DistWindingConn::Delta => "DELTA",
})
})
.collect(),
),
);
o.insert(
"rw".into(),
json!(
t.windings
.iter()
.map(|w| w.r_pct / 100.0)
.collect::<Vec<_>>()
),
);
o.insert(
"xsc".into(),
json!(t.xsc_pct.iter().map(|x| x / 100.0).collect::<Vec<_>>()),
);
o.insert(
"sm_nom".into(),
json!(
t.windings
.iter()
.map(|w| w.s_rating / 1e3)
.collect::<Vec<_>>()
),
);
o.insert(
"vm_nom".into(),
json!(t.windings.iter().map(|w| w.v_ref / 1e3).collect::<Vec<_>>()),
);
let sm_ub = Self::extras_f64(&t.extras, "emerghkva")
.unwrap_or_else(|| t.windings.first().map_or(0.0, |w| w.s_rating / 1e3 * 1.5));
o.insert("sm_ub".into(), json!(sm_ub));
insert_tap_fields(&mut o, t, phases);
if let Some(controls) = t.extras.get("controls") {
o.insert("controls".into(), controls.clone());
}
self.transformer_no_load_fields(t, &mut o, &what);
o.insert("status".into(), Self::status(&t.extras));
o.insert(
"source_id".into(),
json!(format!("transformer.{}", t.name.to_lowercase())),
);
Value::Object(o)
}
fn transformer_no_load_fields(
&mut self,
t: &DistTransformer,
o: &mut Map<String, Value>,
what: &str,
) {
let explicit_shunt = crate::model::transformer_no_load_percentages(t);
let (loss, imag) = explicit_shunt.unwrap_or_else(|| {
(
Self::extras_f64(&t.extras, "%noloadloss").unwrap_or(0.0),
Self::extras_f64(&t.extras, "%imag").unwrap_or(0.0),
)
});
let noloadloss = loss / 100.0;
let cmag = imag / 100.0;
o.insert("noloadloss".into(), json!(noloadloss));
o.insert("cmag".into(), json!(cmag));
let mut allowed = vec!["controls", "%loadloss", "%noloadloss", "%imag", "emerghkva"];
if explicit_shunt.is_some() {
allowed.push("no_load_shunt");
}
self.extras_dropped(&t.extras, &allowed, what);
}
}
fn insert_tap_fields(o: &mut Map<String, Value>, t: &DistTransformer, phases: usize) {
let nw = t.windings.len();
let mut insert = |key: &str, default: fn(&DistTransformer, usize, usize) -> Value| {
let v = t
.extras
.get(&format!("pmd_{key}"))
.cloned()
.unwrap_or_else(|| default(t, nw, phases));
o.insert(key.into(), v);
};
insert("tm_set", |t, _, phases| {
Value::Array(
t.windings
.iter()
.map(|w| json!(vec![w.tap; phases]))
.collect(),
)
});
insert("tm_fix", |_, nw, phases| {
Value::Array((0..nw).map(|_| json!(vec![true; phases])).collect())
});
insert("tm_lb", |_, nw, phases| {
Value::Array((0..nw).map(|_| json!(vec![0.9; phases])).collect())
});
insert("tm_ub", |_, nw, phases| {
Value::Array((0..nw).map(|_| json!(vec![1.1; phases])).collect())
});
insert("tm_step", |_, nw, phases| {
Value::Array((0..nw).map(|_| json!(vec![1.0 / 32.0; phases])).collect())
});
}
fn synthesized_settings(net: &MulticonductorNetwork) -> Value {
let mut settings = Map::new();
settings.insert("base_frequency".into(), json!(net.base_frequency()));
settings.insert("power_scale_factor".into(), json!(1000.0));
settings.insert("voltage_scale_factor".into(), json!(1000.0));
let sbase = net
.sources()
.first()
.and_then(|vs| Writer::extras_f64(&vs.extras, "basemva"))
.map_or(100_000.0, |mva| mva * 1e3);
settings.insert("sbase_default".into(), json!(sbase));
let mut vbases = Map::new();
for vs in net.sources() {
let phases = count_phases(vs).max(1) as f64;
let vln_kv = Writer::extras_f64(&vs.extras, "basekv").map_or_else(
|| {
let pu = Writer::extras_f64(&vs.extras, "pu").unwrap_or(1.0);
vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 / pu
},
|kv| kv / phases.sqrt(),
);
vbases.insert(vs.bus.to_lowercase(), json!(vln_kv));
}
settings.insert("vbases_default".into(), Value::Object(vbases));
Value::Object(settings)
}
pub(super) fn lag_polarity(windings: &[DistWinding]) -> Vec<i64> {
let nw = windings.len();
let mut polarity = vec![1i64; nw];
for (w_idx, w) in windings.iter().enumerate().skip(1) {
if windings[0].conn == DistWindingConn::Delta
&& w.conn == DistWindingConn::Wye
&& w.terminal_map.len() > 1
{
polarity[w_idx] = -1;
}
if w_idx == 2 && nw == 3 && windings[1].terminal_map.last() == w.terminal_map.first() {
polarity[w_idx] = -1;
}
}
polarity
}
fn inlined_codes(net: &MulticonductorNetwork) -> BTreeSet<String> {
let mut inlined = BTreeSet::new();
for c in net.line_codes() {
let mut refs = net
.lines()
.iter()
.filter(|l| l.linecode.eq_ignore_ascii_case(&c.name))
.peekable();
if refs.peek().is_some()
&& refs.all(|l| l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true))
{
inlined.insert(c.name.to_lowercase());
}
}
inlined
}
fn insert_impedance_matrices(o: &mut Map<String, Value>, c: &DistLineCode, base_frequency: f64) {
o.insert("rs".into(), matrix(&c.r_series));
o.insert("xs".into(), matrix(&c.x_series));
o.insert("g_fr".into(), matrix(&c.g_from));
o.insert("g_to".into(), matrix(&c.g_to));
if let (Some(fr), Some(to)) = (c.extras.get("pmd_b_fr"), c.extras.get("pmd_b_to")) {
o.insert("b_fr".into(), fr.clone());
o.insert("b_to".into(), to.clone());
} else {
let to_nf = 1.0 / (std::f64::consts::TAU * base_frequency * 1e-9);
o.insert("b_fr".into(), matrix(&scale(&c.b_from, to_nf)));
o.insert("b_to".into(), matrix(&scale(&c.b_to, to_nf)));
}
}
fn thevenin(vs: &VoltageSource, n_cond: usize) -> (ConductorMatrix, ConductorMatrix) {
let get = |key: &str| Writer::extras_f64(&vs.extras, key);
let basekv = get("basekv").unwrap_or_else(|| {
vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 * (count_phases(vs) as f64).sqrt()
});
let phases = count_phases(vs);
if basekv <= 0.0 || phases == 0 {
return (zero_matrix(n_cond), zero_matrix(n_cond));
}
let mvasc3 = get("mvasc3").unwrap_or(2000.0);
let mvasc1 = get("mvasc1").unwrap_or(2100.0);
let x1r1 = get("x1r1").unwrap_or(4.0);
let x0r0 = get("x0r0").unwrap_or(3.0);
let factor = if phases == 1 { 1.0 } else { 3f64.sqrt() };
let isc1 = mvasc1 * 1e3 / (basekv * factor);
let x1 = basekv * basekv / mvasc3 / (1.0 + 1.0 / (x1r1 * x1r1)).sqrt();
let r1 = x1 / x1r1;
let a = 1.0 + x0r0 * x0r0;
let b = 4.0 * (r1 + x1 * x0r0);
let c = 4.0 * (r1 * r1 + x1 * x1) - (3.0 * basekv * 1000.0 / factor / isc1).powi(2);
let disc = (b * b - 4.0 * a * c).max(0.0).sqrt();
let r0 = ((-b + disc) / (2.0 * a)).max((-b - disc) / (2.0 * a));
let x0 = r0 * x0r0;
let r_self = (2.0 * r1 + r0) / 3.0;
let x_self = (2.0 * x1 + x0) / 3.0;
let r_mutual = (r0 - r1) / 3.0;
let x_mutual = (x0 - x1) / 3.0;
let mut r_mat = vec![vec![r_mutual; n_cond]; n_cond];
let mut x_mat = vec![vec![x_mutual; n_cond]; n_cond];
for i in 0..n_cond {
r_mat[i][i] = r_self;
x_mat[i][i] = x_self;
}
(r_mat, x_mat)
}
fn count_phases(vs: &VoltageSource) -> usize {
vs.v_magnitude.iter().filter(|&&v| v > 0.0).count()
}
fn source_vm_nom(extras: &Extras, v_nom: &[f64]) -> Option<Value> {
let raw = extras.get("kv")?;
if v_nom.is_empty() {
return Some(raw.clone());
}
if let Some(kv) = raw
.as_f64()
.or_else(|| raw.as_str().and_then(|s| s.parse().ok()))
&& v_nom.iter().all(|v| same_voltage(*v, kv * 1e3))
{
return Some(json!(kv));
}
let vals: Vec<f64> = raw
.as_array()?
.iter()
.filter_map(serde_json::Value::as_f64)
.collect();
if vals.len() == 1 && v_nom.iter().all(|v| same_voltage(*v, vals[0] * 1e3)) {
return Some(raw.clone());
}
if vals.len() == v_nom.len()
&& vals
.iter()
.zip(v_nom)
.all(|(a, b)| same_voltage(*b, *a * 1e3))
{
return Some(raw.clone());
}
None
}
fn same_voltage(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}