use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value, json};
use crate::convert::Conversion;
use crate::model::{
Configuration, DistGenerator, DistLoadVoltageModel, DistNetwork, DistTransformer, Mat, Winding,
WindingConn, n_winding_impedance_base, pair_keys,
};
const BMOPF_SCHEMA_ID: &str =
"https://raw.githubusercontent.com/frederikgeth/bmopf-report/main/schema/bmopf.json";
const RAW_BMOPF_TOP_LEVEL: &[&str] = &[
"capacitor",
"ibr",
"control_profile",
"dc_bus",
"dc_line",
"dc_load",
"dc_source",
];
const TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS: [&str; 4] =
["g_no_load", "b_no_load", "%noloadloss", "%imag"];
const TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS: [&str; 6] = [
"tap_min",
"tap_max",
"g_no_load",
"b_no_load",
"%noloadloss",
"%imag",
];
pub fn write_bmopf_json(net: &DistNetwork) -> Conversion {
let mut w = Writer {
warnings: Vec::new(),
grounded: net
.buses
.iter()
.map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
.collect(),
};
let doc = w.document(net);
Conversion {
text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
warnings: w.warnings,
}
}
struct Writer {
warnings: Vec<String>,
grounded: BTreeMap<String, Vec<String>>,
}
impl Writer {
fn warn(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
fn num(&mut self, v: f64, what: &str) -> Value {
if v.is_finite() {
json!(v)
} else {
self.warn(format!("{what}: nonfinite value emitted as 0"));
json!(0.0)
}
}
fn nums(&mut self, vs: &[f64], what: &str) -> Value {
Value::Array(vs.iter().map(|&v| self.num(v, what)).collect())
}
fn extras_dropped(&mut self, extras: &crate::model::Extras, what: &str) {
for key in extras.keys() {
if key == "bmopf_subtype" || key == "conn" {
continue;
}
self.warn(format!(
"{what}: `{key}` has no place in the BMOPF schema; dropped from the output"
));
}
}
fn meta() -> Value {
json!({
"$schema": BMOPF_SCHEMA_ID,
"generator": {"tool": "powerio", "version": env!("CARGO_PKG_VERSION")},
})
}
fn document(&mut self, net: &DistNetwork) -> Value {
let mut doc = Map::new();
if let Some(name) = &net.name {
doc.insert("name".into(), json!(name));
}
doc.insert("meta".into(), Self::meta());
let mut buses = Map::new();
for b in &net.buses {
let mut o = Map::new();
o.insert("terminal_names".into(), json!(b.terminals));
if !b.grounded.is_empty() {
o.insert("perfectly_grounded_terminals".into(), json!(b.grounded));
}
if let Some(v) = b.v_min {
o.insert("v_min".into(), Value::Array(vec![self.num(v, "bus v_min")]));
}
if let Some(v) = b.v_max {
o.insert("v_max".into(), Value::Array(vec![self.num(v, "bus v_max")]));
}
for (key, bound) in [
("vpn_min", &b.vpn_min),
("vpn_max", &b.vpn_max),
("vpp_min", &b.vpp_min),
("vpp_max", &b.vpp_max),
("vsym_min", &b.vsym_min),
("vsym_max", &b.vsym_max),
] {
if let Some(v) = bound {
o.insert(key.into(), self.nums(v, &format!("bus {key}")));
}
}
self.extras_dropped(&b.extras, &format!("bus {}", b.id));
buses.insert(b.id.clone(), Value::Object(o));
}
doc.insert("bus".into(), Value::Object(buses));
if !net.linecodes.is_empty() {
let mut codes = Map::new();
for c in &net.linecodes {
let mut o = Map::new();
let dim = c.r_series.len().max(c.x_series.len()).max(1);
if c.r_series.is_empty() && c.x_series.is_empty() {
self.warn(format!(
"linecode {}: no series matrix; emitted as 1 conductor \
zero impedance",
c.name
));
} else if c.r_series.is_empty() || c.x_series.is_empty() {
self.warn(format!(
"linecode {}: R_series and X_series sizes disagree; the \
empty one emitted as zeros",
c.name
));
}
self.required_matrix(&mut o, "R_series", &c.r_series, dim, &c.name);
self.required_matrix(&mut o, "X_series", &c.x_series, dim, &c.name);
self.flat_matrix(&mut o, "G_from", &c.g_from, &c.name);
self.flat_matrix(&mut o, "G_to", &c.g_to, &c.name);
self.flat_matrix(&mut o, "B_from", &c.b_from, &c.name);
self.flat_matrix(&mut o, "B_to", &c.b_to, &c.name);
if let Some(i_max) = &c.i_max {
o.insert("i_max".into(), self.nums(i_max, "linecode i_max"));
}
if let Some(s_max) = &c.s_max {
o.insert("s_max".into(), self.nums(s_max, "linecode s_max"));
}
self.extras_dropped(&c.extras, &format!("linecode {}", c.name));
codes.insert(c.name.clone(), Value::Object(o));
}
doc.insert("linecode".into(), Value::Object(codes));
}
self.branches(net, &mut doc);
self.injections(net, &mut doc);
let transformers = self.transformers(net);
if !transformers.is_empty() {
doc.insert("transformer".into(), Value::Object(transformers));
}
self.untyped_bmopf_tables(net, &mut doc);
for u in &net.untyped {
if Self::is_emitted_untyped(u) {
continue;
}
self.warn(format!(
"{} {}: class is not represented in BMOPF; dropped from the output",
u.class, u.name
));
}
self.prune_unreferenced_buses(&mut doc);
Value::Object(doc)
}
fn is_emitted_untyped(u: &crate::model::UntypedObject) -> bool {
RAW_BMOPF_TOP_LEVEL.contains(&u.class.as_str()) || u.class.starts_with("transformer.")
}
fn untyped_bmopf_tables(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
for u in &net.untyped {
let Some(value) = raw_bmopf_value(u) else {
self.warn(format!(
"{} {}: untyped BMOPF object could not be parsed as JSON; dropped from the output",
u.class, u.name
));
continue;
};
if RAW_BMOPF_TOP_LEVEL.contains(&u.class.as_str()) {
doc.entry(u.class.clone())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("BMOPF tables are objects")
.insert(u.name.clone(), value);
} else if let Some(subtype) = u.class.strip_prefix("transformer.") {
doc.entry("transformer")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("transformer table is an object")
.entry(subtype.to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("transformer subtype table is an object")
.insert(u.name.clone(), value);
}
}
}
fn prune_unreferenced_buses(&mut self, doc: &mut Map<String, Value>) {
let mut refs = BTreeMap::new();
for (key, value) in doc.iter() {
if key != "bus" {
collect_bus_usage(value, &mut refs);
}
}
let Some(buses) = doc.get_mut("bus").and_then(Value::as_object_mut) else {
return;
};
let ids: Vec<String> = buses.keys().cloned().collect();
for id in ids {
let Some(used) = refs.get(&id) else {
buses.remove(&id);
self.warn(format!(
"bus {id}: no emitted BMOPF element references this bus; dropped from the output"
));
continue;
};
let Some(bus) = buses.get_mut(&id).and_then(Value::as_object_mut) else {
continue;
};
prune_string_array(
bus,
"terminal_names",
used,
&mut self.warnings,
&format!("bus {id}"),
);
prune_string_array(
bus,
"perfectly_grounded_terminals",
used,
&mut self.warnings,
&format!("bus {id}"),
);
if matches!(
bus.get("perfectly_grounded_terminals"),
Some(Value::Array(terms)) if terms.is_empty()
) {
bus.remove("perfectly_grounded_terminals");
}
}
}
fn branches(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
if !net.lines.is_empty() {
let mut lines = Map::new();
for l in &net.lines {
let mut o = Map::new();
o.insert("length".into(), self.num(l.length, "line length"));
o.insert("linecode".into(), json!(l.linecode));
o.insert("bus_from".into(), json!(l.bus_from));
o.insert("bus_to".into(), json!(l.bus_to));
o.insert("terminal_map_from".into(), json!(l.terminal_map_from));
o.insert("terminal_map_to".into(), json!(l.terminal_map_to));
self.extras_dropped(&l.extras, &format!("line {}", l.name));
lines.insert(l.name.clone(), 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();
o.insert("bus_from".into(), json!(s.bus_from));
o.insert("bus_to".into(), json!(s.bus_to));
o.insert("terminal_map_from".into(), json!(s.terminal_map_from));
o.insert("terminal_map_to".into(), json!(s.terminal_map_to));
o.insert("open_switch".into(), json!(s.open));
if let Some(i_max) = &s.i_max {
o.insert("i_max".into(), self.nums(i_max, "switch i_max"));
}
self.extras_dropped(&s.extras, &format!("switch {}", s.name));
switches.insert(s.name.clone(), Value::Object(o));
}
doc.insert("switch".into(), Value::Object(switches));
}
}
fn injections(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
let mut loads = Map::new();
for l in &net.loads {
let mut o = Map::new();
o.insert("configuration".into(), json!(config_str(l.configuration)));
o.insert("p_nom".into(), self.nums(&l.p_nom, "load p_nom"));
o.insert("q_nom".into(), self.nums(&l.q_nom, "load q_nom"));
o.insert("bus".into(), json!(l.bus));
o.insert("terminal_map".into(), json!(l.terminal_map));
self.load_voltage_model(&mut o, &l.voltage_model, &format!("load {}", l.name));
self.extras_dropped(&l.extras, &format!("load {}", l.name));
loads.insert(l.name.clone(), Value::Object(o));
}
let mut gens = Map::new();
for g in &net.generators {
gens.insert(g.name.clone(), self.generator(g));
}
if !loads.is_empty() {
doc.insert("load".into(), Value::Object(loads));
}
if !gens.is_empty() {
doc.insert("generator".into(), Value::Object(gens));
}
if !net.shunts.is_empty() {
let mut shunts = Map::new();
for s in &net.shunts {
let mut o = Map::new();
o.insert("bus".into(), json!(s.bus));
o.insert("terminal_map".into(), json!(s.terminal_map));
let dim = s.g.len().max(s.b.len()).max(1);
if s.g.is_empty() && s.b.is_empty() {
self.warn(format!(
"shunt {}: no admittance matrix; emitted as 1 conductor \
zero admittance",
s.name
));
} else if s.g.is_empty() || s.b.is_empty() {
self.warn(format!(
"shunt {}: G and B sizes disagree; the empty one emitted \
as zeros",
s.name
));
}
self.required_matrix(&mut o, "G", &s.g, dim, &s.name);
self.required_matrix(&mut o, "B", &s.b, dim, &s.name);
self.extras_dropped(&s.extras, &format!("shunt {}", s.name));
shunts.insert(s.name.clone(), Value::Object(o));
}
doc.insert("shunt".into(), Value::Object(shunts));
}
let mut sources = Map::new();
if net.sources.is_empty() {
self.warn("network has no voltage source; BMOPF requires exactly one");
}
for (i, vs) in net.sources.iter().enumerate() {
if i > 0 {
self.warn(format!(
"voltage source {}: the BMOPF formulation expects exactly one source; \
this network has {}",
vs.name,
net.sources.len()
));
}
let mut o = Map::new();
o.insert(
"v_magnitude".into(),
self.nums(&vs.v_magnitude, "voltage_source v_magnitude"),
);
o.insert(
"v_angle".into(),
self.nums(&vs.v_angle, "voltage_source v_angle"),
);
o.insert("bus".into(), json!(vs.bus));
o.insert("terminal_map".into(), json!(vs.terminal_map));
let mut extras = vs.extras.clone();
if let Some(cost) = extras.remove("cost") {
o.insert("cost".into(), cost);
}
self.extras_dropped(&extras, &format!("voltage source {}", vs.name));
sources.insert(vs.name.clone(), Value::Object(o));
}
doc.insert("voltage_source".into(), Value::Object(sources));
}
fn load_voltage_model(
&mut self,
o: &mut Map<String, Value>,
model: &DistLoadVoltageModel,
what: &str,
) {
match model {
DistLoadVoltageModel::ConstantPower { v_nom } => {
o.insert("model".into(), json!("constant_power"));
if !v_nom.is_empty() {
o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
}
}
DistLoadVoltageModel::ConstantCurrent { v_nom } => {
o.insert("model".into(), json!("constant_current"));
o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
}
DistLoadVoltageModel::ConstantImpedance { v_nom } => {
o.insert("model".into(), json!("constant_impedance"));
o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
}
DistLoadVoltageModel::Zip {
v_nom,
alpha_z,
alpha_i,
alpha_p,
beta_z,
beta_i,
beta_p,
} => {
o.insert("model".into(), json!("zip"));
o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
o.insert(
"alpha_z".into(),
self.nums(alpha_z, &format!("{what} alpha_z")),
);
o.insert(
"alpha_i".into(),
self.nums(alpha_i, &format!("{what} alpha_i")),
);
o.insert(
"alpha_p".into(),
self.nums(alpha_p, &format!("{what} alpha_p")),
);
o.insert(
"beta_z".into(),
self.nums(beta_z, &format!("{what} beta_z")),
);
o.insert(
"beta_i".into(),
self.nums(beta_i, &format!("{what} beta_i")),
);
o.insert(
"beta_p".into(),
self.nums(beta_p, &format!("{what} beta_p")),
);
}
DistLoadVoltageModel::Exponential {
v_nom,
gamma_p,
gamma_q,
} => {
o.insert("model".into(), json!("exponential"));
o.insert("v_nom".into(), self.nums(v_nom, &format!("{what} v_nom")));
o.insert(
"gamma_p".into(),
self.nums(gamma_p, &format!("{what} gamma_p")),
);
o.insert(
"gamma_q".into(),
self.nums(gamma_q, &format!("{what} gamma_q")),
);
}
}
}
fn generator(&mut self, g: &DistGenerator) -> Value {
let mut o = Map::new();
let what = format!("generator {}", g.name);
for (key_lo, key_hi, lo, hi, nom) in [
("p_min", "p_max", &g.p_min, &g.p_max, &g.p_nom),
("q_min", "q_max", &g.q_min, &g.q_max, &g.q_nom),
] {
if lo.is_some() || hi.is_some() {
let pinned = lo.as_deref() == Some(nom) && hi.as_deref() == Some(nom);
if !nom.is_empty() && !nom.iter().all(|&v| v == 0.0) && !pinned {
self.warn(format!(
"{what}: explicit {key_lo}/{key_hi} bounds win over the setpoint, \
which has no BMOPF field"
));
}
if let Some(v) = lo {
o.insert(key_lo.into(), self.nums(v, key_lo));
}
if let Some(v) = hi {
o.insert(key_hi.into(), self.nums(v, key_hi));
}
} else if !nom.is_empty() {
o.insert(key_lo.into(), self.nums(nom, key_lo));
o.insert(key_hi.into(), self.nums(nom, key_hi));
}
}
let n_phase = if g.p_nom.is_empty() {
g.terminal_map.len().max(1)
} else {
g.p_nom.len()
};
let cost = g.cost.unwrap_or_else(|| {
self.warnings.push(format!(
"{what}: no generation cost in the source; emitted cost 0"
));
0.0
});
o.insert(
"cost".into(),
self.nums(&vec![cost; n_phase], "generator cost"),
);
o.insert("bus".into(), json!(g.bus));
o.insert("configuration".into(), json!(config_str(g.configuration)));
o.insert("terminal_map".into(), json!(g.terminal_map));
if g.configuration == Configuration::Delta {
self.warn(format!(
"{what}: the BMOPF formulation covers WYE generators; DELTA emitted as written"
));
}
self.extras_dropped(&g.extras, &what);
Value::Object(o)
}
fn transformers(&mut self, net: &DistNetwork) -> Map<String, Value> {
let mut by_subtype: Map<String, Value> = Map::new();
let insert = |sub: &str, name: String, v: Value, map: &mut Map<String, Value>| {
map.entry(sub.to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("subtype maps are objects")
.insert(name, v);
};
for t in &net.transformers {
match classify(t) {
Kind::SinglePhase => {
if t.windings.iter().any(|w| w.conn == WindingConn::Delta) {
self.warn(format!(
"transformer {}: single phase wye/delta emitted as single_phase; \
the wye/delta connection is not encoded in the subtype, only the \
line to line terminal map",
t.name
));
}
let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
insert("single_phase", t.name.clone(), v, &mut by_subtype);
}
Kind::SinglePhaseShape(sub) => {
let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0, true, true);
insert(sub, t.name.clone(), v, &mut by_subtype);
}
Kind::CenterTap => {
let v = self.center_tap(t);
insert("center_tap", t.name.clone(), v, &mut by_subtype);
}
Kind::WyeDelta => {
let v = self.three_phase(t, 0);
insert("wye_delta", t.name.clone(), v, &mut by_subtype);
}
Kind::DeltaWye => {
let v = self.three_phase(t, 1);
insert("delta_wye", t.name.clone(), v, &mut by_subtype);
}
Kind::WyeWye3 => {
for (k, v) in self.decompose_wye_wye(t) {
insert("single_phase", k, v, &mut by_subtype);
}
}
Kind::NWinding => {
let v = self.n_winding(t);
insert("n_winding", t.name.clone(), v, &mut by_subtype);
}
Kind::Unsupported(why) => {
self.warn(format!(
"transformer {}: {why}; not representable in the four BMOPF \
subtypes, dropped from the output",
t.name
));
}
}
}
by_subtype
}
fn two_winding(
&mut self,
t: &DistTransformer,
from: &Winding,
to: &Winding,
s_scale: f64,
emit_no_load: bool,
warn_extras: bool,
) -> Value {
let s = from.s_rating * s_scale;
let zb_from = from.v_ref * from.v_ref / s;
let zb_to = to.v_ref * to.v_ref / s;
let mut o = Map::new();
o.insert("bus_from".into(), json!(from.bus));
o.insert("bus_to".into(), json!(to.bus));
o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
o.insert(
"v_nom_from".into(),
self.num(from.v_ref, "transformer v_nom_from"),
);
o.insert(
"v_nom_to".into(),
self.num(to.v_ref, "transformer v_nom_to"),
);
o.insert(
"r_series_from".into(),
self.num(from.r_pct / 100.0 * zb_from, "transformer r_series_from"),
);
o.insert(
"r_series_to".into(),
self.num(to.r_pct / 100.0 * zb_to, "transformer r_series_to"),
);
if t.xsc_pct.is_empty() {
self.warn(format!(
"transformer {}: xsc_pct is empty; emitted x_series_from=0",
t.name
));
}
let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
o.insert(
"x_series_from".into(),
self.num(xhl / 100.0 * zb_from, "transformer x_series_from"),
);
o.insert("x_series_to".into(), json!(0.0));
o.insert("terminal_map_from".into(), json!(from.terminal_map));
o.insert("terminal_map_to".into(), json!(to.terminal_map));
self.transformer_tap_fields(&mut o, t, from);
if emit_no_load {
self.transformer_no_load_fields(&mut o, t, from, s);
}
if warn_extras {
self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
}
o.into()
}
fn center_tap(&mut self, t: &DistTransformer) -> Value {
let from = &t.windings[0];
let (w2, w3) = (&t.windings[1], &t.windings[2]);
let common = w2
.terminal_map
.iter()
.find(|term| w3.terminal_map.contains(term))
.cloned()
.unwrap_or_default();
let mut hots: Vec<String> = Vec::new();
for term in w2.terminal_map.iter().chain(&w3.terminal_map) {
if *term != common && !hots.contains(term) {
hots.push(term.clone());
}
}
let v_new = w2.v_ref + w3.v_ref;
let r_pct_new = (w2.r_pct * w2.v_ref * w2.v_ref * (from.s_rating / w2.s_rating)
+ w3.r_pct * w3.v_ref * w3.v_ref * (from.s_rating / w3.s_rating))
/ (v_new * v_new);
let to = Winding {
bus: w2.bus.clone(),
terminal_map: {
let mut m = hots;
m.push(common);
m
},
conn: WindingConn::Wye,
v_ref: v_new,
s_rating: from.s_rating,
r_pct: r_pct_new,
tap: 1.0,
};
self.warn(format!(
"transformer {}: center tap secondary collapsed to one winding; the \
xht/xlt impedance split is not representable and was dropped",
t.name
));
if w2.s_rating.to_bits() != from.s_rating.to_bits()
|| w3.s_rating.to_bits() != from.s_rating.to_bits()
{
self.warn(format!(
"transformer {}: center tap half winding s_ratings ({}, {}) differ \
from the primary's {}; the collapsed winding keeps the primary \
rating, the half ratings only survive in the resistance conversion",
t.name, w2.s_rating, w3.s_rating, from.s_rating
));
}
self.two_winding(t, from, &to, 1.0, true, true)
}
fn three_phase(&mut self, t: &DistTransformer, wye_idx: usize) -> Value {
let from = &t.windings[0];
let to = &t.windings[1];
let wye = &t.windings[wye_idx];
let s = from.s_rating;
let zb_wye = wye.v_ref * wye.v_ref / s;
let mut o = Map::new();
o.insert("bus_from".into(), json!(from.bus));
o.insert("bus_to".into(), json!(to.bus));
o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
o.insert(
"v_nom_from".into(),
self.num(from.v_ref, "transformer v_nom_from"),
);
o.insert(
"v_nom_to".into(),
self.num(to.v_ref, "transformer v_nom_to"),
);
o.insert(
"r_series".into(),
self.num(
(from.r_pct + to.r_pct) / 100.0 * zb_wye,
"transformer r_series",
),
);
if t.xsc_pct.is_empty() {
self.warn(format!(
"transformer {}: xsc_pct is empty; emitted x_series=0",
t.name
));
}
let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
o.insert(
"x_series".into(),
self.num(xhl / 100.0 * zb_wye, "transformer x_series"),
);
o.insert("terminal_map_from".into(), json!(from.terminal_map));
o.insert("terminal_map_to".into(), json!(to.terminal_map));
self.transformer_tap_fields(&mut o, t, from);
self.transformer_no_load_fields(&mut o, t, from, s);
self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
o.into()
}
fn n_winding(&mut self, t: &DistTransformer) -> Value {
let s = t.windings.first().map_or(f64::NAN, |w| w.s_rating);
if t.windings
.iter()
.any(|w| w.s_rating.to_bits() != s.to_bits())
{
self.warn(format!(
"transformer {}: n_winding BMOPF carries one s_rating; emitted the first winding rating",
t.name
));
}
let mut o = Map::new();
o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
let windings: Vec<Value> = t
.windings
.iter()
.map(|w| {
let mut wj = Map::new();
wj.insert("bus".into(), json!(w.bus));
wj.insert("terminal_map".into(), json!(w.terminal_map));
wj.insert(
"v_nom".into(),
self.num(n_winding_bmopf_v_nom(w), "transformer winding v_nom"),
);
wj.insert(
"configuration".into(),
json!(match w.conn {
WindingConn::Wye => "WYE",
WindingConn::Delta => "DELTA",
}),
);
let zbase = n_winding_base(w, s).unwrap_or(f64::NAN);
wj.insert(
"r_winding".into(),
self.num(w.r_pct / 100.0 * zbase, "transformer winding r_winding"),
);
Value::Object(wj)
})
.collect();
o.insert("windings".into(), Value::Array(windings));
let base_z = t
.windings
.first()
.and_then(|w| n_winding_base(w, s))
.unwrap_or(f64::NAN);
let mut x_sc = Map::new();
for (idx, (i, j)) in pair_keys(t.windings.len()).into_iter().enumerate() {
let x_pct = t.xsc_pct.get(idx).copied().unwrap_or_else(|| {
self.warn(format!(
"transformer {}: missing x_sc for winding pair {}_{}; emitted 0",
t.name,
i + 1,
j + 1
));
0.0
});
x_sc.insert(
format!("{}_{}", i + 1, j + 1),
self.num(x_pct / 100.0 * base_z, "transformer x_sc"),
);
}
o.insert("x_sc".into(), Value::Object(x_sc));
if let Some(first) = t.windings.first() {
self.transformer_no_load_fields(&mut o, t, first, s);
}
self.taps_dropped(t);
self.transformer_extras_dropped(t, &TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS);
o.into()
}
fn decompose_wye_wye(&mut self, t: &DistTransformer) -> Vec<(String, Value)> {
let mut out = Vec::new();
let (from, to) = (&t.windings[0], &t.windings[1]);
let sqrt3 = 3f64.sqrt();
for k in 0..t.phases {
let per = |w: &Winding| {
let neutral = w.terminal_map.last().cloned().unwrap_or_default();
Winding {
bus: w.bus.clone(),
terminal_map: vec![w.terminal_map[k].clone(), neutral],
conn: WindingConn::Wye,
v_ref: w.v_ref / sqrt3,
s_rating: w.s_rating / 3.0,
r_pct: w.r_pct,
tap: w.tap,
}
};
let f = per(from);
let to_1 = per(to);
let mut t1 = t.clone();
t1.windings = vec![f.clone(), to_1.clone()];
let v = self.two_winding(&t1, &f, &to_1, 1.0, false, false);
out.push((format!("{}_{}", t.name, k + 1), v));
}
self.warn(format!(
"transformer {}: three phase wye-wye decomposed into {} single_phase units",
t.name, t.phases
));
self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
out
}
fn taps_dropped(&mut self, t: &DistTransformer) {
for w in &t.windings {
if (w.tap - 1.0).abs() > 1e-12 {
self.warn(format!(
"transformer {}: off nominal tap {} has no BMOPF field; dropped",
t.name, w.tap
));
}
}
}
fn transformer_tap_fields(
&mut self,
o: &mut Map<String, Value>,
t: &DistTransformer,
from: &Winding,
) {
if (from.tap - 1.0).abs() > 1e-12 || t.extras.contains_key("tap") {
o.insert("tap".into(), self.num(from.tap, "transformer tap"));
}
for key in ["tap_min", "tap_max"] {
if let Some(v) = extras_number(&t.extras, key) {
o.insert(key.into(), self.num(v, &format!("transformer {key}")));
}
}
for w in t.windings.iter().skip(1) {
if (w.tap - 1.0).abs() > 1e-12 {
self.warn(format!(
"transformer {}: non-from-side tap {} has no BMOPF field; dropped",
t.name, w.tap
));
}
}
}
fn transformer_no_load_fields(
&mut self,
o: &mut Map<String, Value>,
t: &DistTransformer,
from: &Winding,
s: f64,
) {
if let Some(v) = t.extras.get("g_no_load") {
o.insert("g_no_load".into(), v.clone());
} else if let Some(loss_pct) = extras_number(&t.extras, "%noloadloss") {
if self.is_phase_to_phase_single_phase(from) {
self.warn(format!(
"transformer {}: phase-to-phase %noloadloss cannot be represented as a BMOPF no-load shunt; dropped",
t.name
));
} else {
let v_stamp = no_load_voltage_base(from);
if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
let y_base = s / (v_stamp * v_stamp);
o.insert(
"g_no_load".into(),
self.num(loss_pct / 100.0 * y_base, "transformer g_no_load"),
);
} else {
self.warn(format!(
"transformer {}: %noloadloss cannot be converted without a positive s_rating and v_nom_from",
t.name
));
}
}
}
if let Some(v) = t.extras.get("b_no_load") {
o.insert("b_no_load".into(), v.clone());
} else if let Some(imag_pct) = extras_number(&t.extras, "%imag") {
if self.is_phase_to_phase_single_phase(from) {
self.warn(format!(
"transformer {}: phase-to-phase %imag cannot be represented as a BMOPF no-load shunt; dropped",
t.name
));
} else {
let v_stamp = no_load_voltage_base(from);
if s.is_finite() && s > 0.0 && v_stamp.is_finite() && v_stamp > 0.0 {
let y_base = s / (v_stamp * v_stamp);
o.insert(
"b_no_load".into(),
self.num(imag_pct / 100.0 * y_base, "transformer b_no_load"),
);
} else {
self.warn(format!(
"transformer {}: %imag cannot be converted without a positive s_rating and v_nom_from",
t.name
));
}
}
} else if !self.is_phase_to_phase_single_phase(from)
&& extras_number(&t.extras, "%noloadloss").is_some()
{
o.insert("b_no_load".into(), json!(0.0));
}
}
fn is_phase_to_phase_single_phase(&self, winding: &Winding) -> bool {
n_winding_phase_count(winding) == 1
&& !self
.grounded
.get(&winding.bus.to_ascii_lowercase())
.is_some_and(|g| winding.terminal_map.iter().any(|t| g.contains(t)))
}
fn transformer_extras_dropped(&mut self, t: &DistTransformer, allowed: &[&str]) {
for key in t.extras.keys() {
if key == "bmopf_subtype" || key == "tap" || allowed.contains(&key.as_str()) {
continue;
}
self.warn(format!(
"transformer {}: `{key}` has no place in the BMOPF schema; dropped from the output",
t.name
));
}
}
fn required_matrix(
&mut self,
o: &mut Map<String, Value>,
prefix: &str,
m: &Mat,
dim: usize,
name: &str,
) {
if m.is_empty() {
self.flat_matrix(o, prefix, &vec![vec![0.0; dim]; dim], name);
} else {
self.flat_matrix(o, prefix, m, name);
}
}
fn flat_matrix(&mut self, o: &mut Map<String, Value>, prefix: &str, m: &Mat, name: &str) {
for (i, row) in m.iter().enumerate() {
for (j, &v) in row.iter().enumerate() {
o.insert(
format!("{prefix}_{}_{}", i + 1, j + 1),
self.num(v, &format!("{name} {prefix}")),
);
}
}
}
}
fn collect_bus_usage(value: &Value, refs: &mut BTreeMap<String, BTreeSet<String>>) {
match value {
Value::Object(o) => {
add_bus_usage(o, refs, "bus", "terminal_map");
add_bus_usage(o, refs, "bus_from", "terminal_map_from");
add_bus_usage(o, refs, "bus_to", "terminal_map_to");
for value in o.values() {
collect_bus_usage(value, refs);
}
}
Value::Array(values) => {
for value in values {
collect_bus_usage(value, refs);
}
}
_ => {}
}
}
fn add_bus_usage(
o: &Map<String, Value>,
refs: &mut BTreeMap<String, BTreeSet<String>>,
bus_key: &str,
map_key: &str,
) {
let Some(id) = o.get(bus_key).and_then(Value::as_str) else {
return;
};
let entry = refs.entry(id.to_string()).or_default();
if let Some(terms) = o.get(map_key).and_then(Value::as_array) {
entry.extend(terms.iter().filter_map(Value::as_str).map(str::to_string));
}
}
fn prune_string_array(
o: &mut Map<String, Value>,
key: &str,
used: &BTreeSet<String>,
warnings: &mut Vec<String>,
what: &str,
) {
let Some(Value::Array(values)) = o.get_mut(key) else {
return;
};
let old = std::mem::take(values);
let mut kept = Vec::new();
let mut dropped = Vec::new();
for value in old {
if value.as_str().is_some_and(|s| used.contains(s)) {
kept.push(value);
} else {
dropped.push(value);
}
}
if !dropped.is_empty() {
let names: Vec<String> = dropped
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
warnings.push(format!(
"{what}: `{key}` entries {names:?} are not referenced by emitted BMOPF elements; dropped from the output"
));
}
*values = kept;
}
enum Kind {
SinglePhase,
SinglePhaseShape(&'static str),
CenterTap,
WyeDelta,
DeltaWye,
WyeWye3,
NWinding,
Unsupported(String),
}
fn classify(t: &DistTransformer) -> Kind {
if let Some(sub) = t.extras.get("bmopf_subtype").and_then(|v| v.as_str()) {
if t.windings.len() == 2 {
match sub {
"single_phase" => return Kind::SinglePhase,
"center_tap" => return Kind::SinglePhaseShape("center_tap"),
"wye_delta" => return Kind::WyeDelta,
"delta_wye" => return Kind::DeltaWye,
_ => {}
}
}
if sub == "n_winding" && t.windings.len() >= 2 {
return Kind::NWinding;
}
}
let conns: Vec<WindingConn> = t.windings.iter().map(|w| w.conn).collect();
match (t.phases, conns.as_slice()) {
(
1,
[WindingConn::Wye | WindingConn::Delta, WindingConn::Wye]
| [WindingConn::Wye, WindingConn::Delta],
) => Kind::SinglePhase,
(1, [WindingConn::Wye, WindingConn::Wye, WindingConn::Wye]) => Kind::CenterTap,
(3, [WindingConn::Wye, WindingConn::Delta]) => Kind::WyeDelta,
(3, [WindingConn::Delta, WindingConn::Wye]) => Kind::DeltaWye,
(3, [WindingConn::Wye, WindingConn::Wye])
if t.windings
.iter()
.all(|w| w.terminal_map.len() == t.phases + 1) =>
{
Kind::WyeWye3
}
(3, [WindingConn::Wye, WindingConn::Wye]) => Kind::Unsupported(
"three phase wye-wye whose terminal maps do not list each phase plus a neutral".into(),
),
(_, _) if t.windings.len() >= 3 => Kind::NWinding,
_ => Kind::Unsupported(format!(
"{} phase with {} windings ({:?})",
t.phases,
t.windings.len(),
conns
)),
}
}
fn raw_bmopf_value(u: &crate::model::UntypedObject) -> Option<Value> {
let (_, text) = u.props.first()?;
serde_json::from_str(text).ok()
}
fn extras_number(extras: &crate::model::Extras, key: &str) -> Option<f64> {
let v = extras.get(key)?;
v.as_f64()
.or_else(|| v.as_i64().map(|v| v as f64))
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.filter(|v| v.is_finite())
}
fn n_winding_phase_count(w: &Winding) -> usize {
crate::model::n_winding_phase_count(w.conn, &w.terminal_map)
}
fn n_winding_bmopf_v_nom(w: &Winding) -> f64 {
if w.conn == WindingConn::Wye && n_winding_phase_count(w) >= 2 {
w.v_ref / 3f64.sqrt()
} else {
w.v_ref
}
}
fn n_winding_base(w: &Winding, s: f64) -> Option<f64> {
n_winding_impedance_base(n_winding_phase_count(w), n_winding_bmopf_v_nom(w), s)
}
fn no_load_voltage_base(from: &Winding) -> f64 {
let phases = match from.conn {
WindingConn::Wye => from.terminal_map.len().saturating_sub(1),
WindingConn::Delta => from.terminal_map.len(),
};
if phases >= 3 {
from.v_ref / 3f64.sqrt()
} else {
from.v_ref
}
}
fn config_str(c: Configuration) -> &'static str {
match c {
Configuration::Wye => "WYE",
Configuration::Delta => "DELTA",
Configuration::SinglePhase => "SINGLE_PHASE",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bmopf::parse_bmopf_str;
use crate::model::DistLoadVoltageModel;
#[test]
fn load_voltage_models_round_trip_through_bmopf() {
let text = r#"{
"bus": {
"b1": {"terminal_names": ["1", "2", "3", "4"], "perfectly_grounded_terminals": ["4"]}
},
"voltage_source": {
"source": {
"bus": "b1", "terminal_map": ["1", "2", "3", "4"],
"v_magnitude": [7200.0, 7200.0, 7200.0, 0.0],
"v_angle": [0.0, -120.0, 120.0, 0.0]
}
},
"load": {
"zip": {
"bus": "b1", "terminal_map": ["1", "2", "3", "4"],
"configuration": "WYE", "p_nom": [1.0, 2.0, 3.0], "q_nom": [0.1, 0.2, 0.3],
"model": "zip", "v_nom": [7200.0, 7200.0, 7200.0],
"alpha_z": [0.2, 0.2, 0.2], "alpha_i": [0.3, 0.3, 0.3], "alpha_p": [0.5, 0.5, 0.5],
"beta_z": [0.1, 0.1, 0.1], "beta_i": [0.4, 0.4, 0.4], "beta_p": [0.5, 0.5, 0.5]
},
"exp": {
"bus": "b1", "terminal_map": ["1", "2", "3", "4"],
"configuration": "WYE", "p_nom": [1.0, 1.0, 1.0], "q_nom": [0.0, 0.0, 0.0],
"model": "exponential", "v_nom": [7200.0, 7200.0, 7200.0],
"gamma_p": [1.2, 1.2, 1.2], "gamma_q": [2.1, 2.1, 2.1]
}
}
}"#;
let net = parse_bmopf_str(text).unwrap();
let zip = net.loads.iter().find(|l| l.name == "zip").unwrap();
let exp = net.loads.iter().find(|l| l.name == "exp").unwrap();
assert!(matches!(
&zip.voltage_model,
DistLoadVoltageModel::Zip { alpha_z, .. } if alpha_z == &vec![0.2, 0.2, 0.2]
));
assert!(matches!(
&exp.voltage_model,
DistLoadVoltageModel::Exponential { gamma_q, .. } if gamma_q == &vec![2.1, 2.1, 2.1]
));
let out = write_bmopf_json(&net);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let v: Value = serde_json::from_str(&out.text).unwrap();
assert_eq!(
v["load"]["zip"]["alpha_i"],
serde_json::json!([0.3, 0.3, 0.3])
);
assert_eq!(
v["load"]["exp"]["gamma_p"],
serde_json::json!([1.2, 1.2, 1.2])
);
}
}