use serde_json::{Map, Value, json};
use crate::convert::Conversion;
use crate::model::{Configuration, DistNetwork, DistTransformer, Mat, Winding, WindingConn};
const BMOPF_SCHEMA_ID: &str =
"https://github.com/frederikgeth/bmopf-report/tree/main/draft_schema_and_networks";
pub fn write_bmopf_json(net: &DistNetwork) -> Conversion {
let mut w = Writer {
warnings: Vec::new(),
};
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>,
}
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" {
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(), self.num(v, "bus v_min"));
}
if let Some(v) = b.v_max {
o.insert("v_max".into(), 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 n = c.n_conductors;
if n > 9 {
self.warn(format!(
"linecode {}: {n} conductors produce double digit matrix keys, \
which the draft schema's `^R_series_\\d_\\d` patterns reject; \
emitted anyway",
c.name
));
}
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));
}
for u in &net.untyped {
self.warn(format!(
"{} {}: class is not represented in BMOPF; dropped from the output",
u.class, u.name
));
}
Value::Object(doc)
}
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>) {
if !net.loads.is_empty() {
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.extras_dropped(&l.extras, &format!("load {}", l.name));
loads.insert(l.name.clone(), Value::Object(o));
}
doc.insert("load".into(), Value::Object(loads));
}
if !net.generators.is_empty() {
let mut gens = Map::new();
for g in &net.generators {
gens.insert(g.name.clone(), self.generator(g));
}
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));
self.extras_dropped(&vs.extras, &format!("voltage source {}", vs.name));
sources.insert(vs.name.clone(), Value::Object(o));
}
doc.insert("voltage_source".into(), Value::Object(sources));
}
fn generator(&mut self, g: &crate::model::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 {
self.extras_dropped(&t.extras, &format!("transformer {}", t.name));
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);
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);
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::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,
) -> 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_ref_from".into(),
self.num(from.v_ref, "transformer v_ref_from"),
);
o.insert(
"v_ref_to".into(),
self.num(to.v_ref, "transformer v_ref_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.taps_dropped(t);
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)
}
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_ref_from".into(),
self.num(from.v_ref, "transformer v_ref_from"),
);
o.insert(
"v_ref_to".into(),
self.num(to.v_ref, "transformer v_ref_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.taps_dropped(t);
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);
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
));
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 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}")),
);
}
}
}
}
enum Kind {
SinglePhase,
SinglePhaseShape(&'static str),
CenterTap,
WyeDelta,
DeltaWye,
WyeWye3,
Unsupported(String),
}
fn classify(t: &DistTransformer) -> Kind {
if let Some(sub) = t.extras.get("bmopf_subtype").and_then(|v| v.as_str())
&& 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,
_ => {}
}
}
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(),
),
_ => Kind::Unsupported(format!(
"{} phase with {} windings ({:?})",
t.phases,
t.windings.len(),
conns
)),
}
}
fn config_str(c: Configuration) -> &'static str {
match c {
Configuration::Wye => "WYE",
Configuration::Delta => "DELTA",
Configuration::SinglePhase => "SINGLE_PHASE",
}
}