use std::collections::{BTreeMap, BTreeSet};
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use serde_json::{Map, Value, json};
use crate::convert::Conversion;
use crate::diagnostics::{DiagnosticSeverity, DiagnosticStage, StructuredDiagnostic};
use crate::geo::CoordinateSpace;
use crate::model::{
ActivePowerReference, ActivePowerUnit, Configuration, ControlVoltageReference,
DistControlProfile, DistGenerator, DistIbr, DistLoadVoltageModel, DistNetwork, DistTransformer,
Extras, Mat, ReactivePowerReference, ReactivePowerUnit, VoltVarControl, VoltWattControl,
VoltageSource, Winding, WindingConn, n_winding_impedance_base, pair_keys,
};
pub const BMOPF_SCHEMA_ID: &str = "https://raw.githubusercontent.com/frederikgeth/bmopf-report/main/draft_schema_and_networks/draft_bmopf_schema.json";
pub const BMOPF_SCHEMA_VERSION: &str = "0.1.0";
const RAW_BMOPF_EXTRAS_TABLES: &[&str] = &[
"ibr",
"control_profile",
"dc_bus",
"dc_line",
"dc_load",
"dc_source",
"time_series",
"capacitor",
];
const BMOPF_EXTRAS_STASH: &str = "bmopf_extras";
const BMOPF_META_STASH: &str = "bmopf_meta";
const BMOPF_TERMINAL_CONVENTIONS_STASH: &str = "bmopf_terminal_conventions";
const IBR_EXTRA_FIELDS: &[&str] = &[
"dc_link_coupled",
"p_dc_min",
"p_dc_max",
"dc_bus",
"dc_terminal_map",
"dc_control",
"dc_v_set",
"dc_p_ref",
"dc_droop",
"dc_deadband",
"r_filter",
"x_filter",
"b_filter_shunt",
"grid_forming",
"v_ref_internal",
"cost",
"time_series",
];
const BMOPF_DELTA_ROLLS_EXTRA: &str = "bmopf_delta_rolls";
const MAX_DIM: usize = 64;
const TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS: [&str; 5] = [
"g_no_load",
"b_no_load",
"%noloadloss",
"%imag",
BMOPF_DELTA_ROLLS_EXTRA,
];
const TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS: [&str; 18] = [
"tap_min",
"tap_max",
"mintap",
"maxtap",
"numtaps",
"pmd_tm_set",
"pmd_tm_lb",
"pmd_tm_ub",
"pmd_tm_fix",
"pmd_tm_step",
"g_no_load",
"b_no_load",
"r_neutral_from",
"x_neutral_from",
"r_neutral_to",
"x_neutral_to",
"%noloadloss",
"%imag",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct BmopfWriteOptions {
pub sideload_coordinates: bool,
}
pub fn write_bmopf_json(net: &DistNetwork) -> Conversion {
write_bmopf_json_with_options(net, &BmopfWriteOptions::default())
}
pub fn write_bmopf_json_with_options(net: &DistNetwork, options: &BmopfWriteOptions) -> Conversion {
let mut w = Writer {
options: *options,
warnings: Vec::new(),
diagnostics: Vec::new(),
grounded: net
.buses
.iter()
.map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone()))
.collect(),
transformer_overflow: Map::new(),
};
let doc = w.document(net);
Conversion {
text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
sidecars: Vec::new(),
warnings: w.warnings,
diagnostics: w.diagnostics,
}
}
struct Writer {
options: BmopfWriteOptions,
warnings: Vec<String>,
diagnostics: Vec<StructuredDiagnostic>,
grounded: BTreeMap<String, Vec<String>>,
transformer_overflow: Map<String, Value>,
}
impl Writer {
fn warn(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
fn diagnostic(
&mut self,
code: &'static str,
element_path: impl Into<String>,
message: impl Into<String>,
details: Map<String, Value>,
) {
let message = message.into();
self.warnings.push(format!("{message} [{code}]"));
self.diagnostics.push(
StructuredDiagnostic::new(
code,
DiagnosticSeverity::Warning,
DiagnosticStage::Emit,
message,
)
.with_element_path(element_path)
.with_details(details),
);
}
fn transformer_diagnostic(
&mut self,
t: &DistTransformer,
code: &'static str,
message: impl Into<String>,
mut details: Map<String, Value>,
) {
details.insert("transformer".into(), json!(&t.name));
self.diagnostic(code, format!("transformer {}", t.name), message, details);
}
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 bounds(&mut self, vs: &[f64], what: &str) -> Option<Value> {
if vs.iter().all(|v| v.is_finite()) {
Some(json!(vs))
} else {
self.warn(format!(
"{what}: nonfinite entries (an unbounded phase) have no BMOPF spelling; \
field dropped"
));
None
}
}
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(&mut self, net: &DistNetwork) -> Value {
let mut m = Map::new();
m.insert("$schema".into(), json!(BMOPF_SCHEMA_ID));
m.insert(
"frequency".into(),
self.num(net.base_frequency, "meta frequency"),
);
m.insert(
"case_study_generator".into(),
json!({"tool": "powerio", "version": env!("CARGO_PKG_VERSION")}),
);
if let Some(Value::Object(stash)) = net.extras.get(BMOPF_META_STASH) {
for (key, value) in stash {
match key.as_str() {
"$schema" | "frequency" | "case_study_generator" => {}
"title" | "description" | "license" | "authors" | "data_sources"
| "created" | "modified" | "provenance" | "version" => {
m.insert(key.clone(), value.clone());
}
other => self.warn(format!(
"meta `{other}` has no slot in the BMOPF schema; dropped"
)),
}
}
}
Value::Object(m)
}
fn document(&mut self, net: &DistNetwork) -> Value {
let mut doc = Map::new();
if let Some(name) = &net.name {
doc.insert("name".into(), json!(name));
}
let meta = self.meta(net);
doc.insert("meta".into(), meta);
if let Some(Value::Object(tc)) = net.extras.get(BMOPF_TERMINAL_CONVENTIONS_STASH) {
doc.insert("terminal_conventions".into(), Value::Object(tc.clone()));
}
self.buses(net, &mut doc);
self.linecodes(net, &mut doc);
self.branches(net, &mut doc);
self.injections(net, &mut doc);
self.capacitors(net, &mut doc);
let transformers = self.transformers(net);
if !transformers.is_empty() {
doc.insert("transformer".into(), Value::Object(transformers));
}
let mut extras = Map::new();
if let Some(Value::Object(stash)) = net.extras.get(BMOPF_EXTRAS_STASH) {
extras.extend(stash.clone());
}
self.control_profiles(net, &mut extras);
self.ibrs(net, &mut extras);
self.untyped_bmopf_tables(net, &mut doc, &mut extras);
if !self.transformer_overflow.is_empty() {
let overflow = std::mem::take(&mut self.transformer_overflow);
extras.insert("transformer".into(), Value::Object(overflow));
}
if !extras.is_empty() {
doc.insert("extras".into(), Value::Object(extras));
}
self.warn_unemitted_untyped(net);
self.prune_unreferenced_buses(&mut doc);
Value::Object(doc)
}
fn buses(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
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),
] {
if let Some(v) = bound {
o.insert(key.into(), self.nums(v, &format!("bus {key}")));
}
}
for (key, bound) in [
("vpos_min", b.vpos_min),
("vpos_max", b.vpos_max),
("vneg_max", b.vneg_max),
("vzero_max", b.vzero_max),
("vn_max", b.vn_max),
] {
if let Some(v) = bound {
o.insert(key.into(), self.num(v, &format!("bus {key}")));
}
}
self.bus_location(&mut o, b, net);
self.extras_dropped(&b.extras, &format!("bus {}", b.id));
buses.insert(b.id.clone(), Value::Object(o));
}
doc.insert("bus".into(), Value::Object(buses));
}
fn linecodes(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
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
&& let Some(v) = self.bounds(i_max, &format!("linecode {} i_max", c.name))
{
o.insert("i_max".into(), v);
}
if let Some(s_max) = &c.s_max
&& let Some(v) = self.bounds(s_max, &format!("linecode {} s_max", c.name))
{
o.insert("s_max".into(), v);
}
if let Some(source) = &c.source {
o.insert("source".into(), json!(source));
}
self.extras_dropped(&c.extras, &format!("linecode {}", c.name));
codes.insert(c.name.clone(), Value::Object(o));
}
doc.insert("linecode".into(), Value::Object(codes));
}
}
fn bus_location(
&mut self,
o: &mut Map<String, Value>,
b: &crate::model::DistBus,
net: &DistNetwork,
) {
let Some(location) = b.location else {
return;
};
if !self.options.sideload_coordinates {
self.diagnostic(
"EMIT.BMOPF.BUS_LOCATION_DROPPED",
format!("bus {}", b.id),
format!(
"bus {}: location has no place in the BMOPF schema; dropped from the output",
b.id
),
json!({
"bus": b.id,
"x": location.x,
"y": location.y,
})
.as_object()
.expect("object literal")
.clone(),
);
return;
}
if !matches!(
net.geo.as_ref().map(|geo| &geo.space),
Some(CoordinateSpace::Geographic { .. })
) {
self.diagnostic(
"EMIT.BMOPF.BUS_LOCATION_DROPPED",
format!("bus {}", b.id),
format!(
"bus {}: non-geographic or undeclared location cannot be emitted as BMOPF longitude/latitude",
b.id
),
json!({
"bus": b.id,
"x": location.x,
"y": location.y,
})
.as_object()
.expect("object literal")
.clone(),
);
return;
}
if !location.x.is_finite() || !location.y.is_finite() {
self.diagnostic(
"EMIT.BMOPF.BUS_LOCATION_DROPPED",
format!("bus {}", b.id),
format!(
"bus {}: nonfinite location cannot be emitted as BMOPF longitude/latitude",
b.id
),
json!({
"bus": b.id,
"x": location.x,
"y": location.y,
})
.as_object()
.expect("object literal")
.clone(),
);
return;
}
o.insert("longitude".into(), self.num(location.x, "bus longitude"));
o.insert("latitude".into(), self.num(location.y, "bus latitude"));
}
fn warn_unemitted_untyped(&mut self, net: &DistNetwork) {
for u in &net.untyped {
if Self::is_emitted_untyped(u) {
continue;
}
let message = format!(
"{} {}: class is not represented in BMOPF; dropped from the output",
u.class, u.name
);
if u.class == "regcontrol" || u.class == "autotrans" {
let mut details = Map::new();
details.insert("class".into(), json!(&u.class));
details.insert("name".into(), json!(&u.name));
let code = if u.class == "regcontrol" {
"EMIT.BMOPF.REGCONTROL_DROPPED"
} else {
"EMIT.BMOPF.AUTOTRANSFORMER_DROPPED"
};
self.diagnostic(code, format!("{} {}", u.class, u.name), message, details);
} else {
self.warn(message);
}
}
}
fn is_emitted_untyped(u: &crate::model::UntypedObject) -> bool {
RAW_BMOPF_EXTRAS_TABLES.contains(&u.class.as_str()) || u.class.starts_with("transformer.")
}
fn untyped_bmopf_tables(
&mut self,
net: &DistNetwork,
doc: &mut Map<String, Value>,
extras: &mut Map<String, Value>,
) {
self.clear_non_table_extras_slots(net, extras);
for u in &net.untyped {
let subtype = u.class.strip_prefix("transformer.");
if subtype.is_none() && !RAW_BMOPF_EXTRAS_TABLES.contains(&u.class.as_str()) {
continue;
}
let mut unplaced = Vec::new();
let Some(value) = raw_bmopf_value(u, &mut unplaced) else {
self.warn(format!(
"{} {}: the untyped BMOPF object carries no field this writer can \
place; dropped from the output",
u.class, u.name
));
continue;
};
for text in unplaced {
self.warn(format!(
"{} {}: the value `{text}` has no field name; dropped from the \
output, the named fields beside it are kept",
u.class, u.name
));
}
let slot_path = match subtype {
Some(sub) => format!("transformer.{sub}"),
None => format!("extras.{}", u.class),
};
let slot = match subtype {
Some(sub) => doc
.entry("transformer")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("the writer builds the transformer table as an object")
.entry(sub.to_string())
.or_insert_with(|| Value::Object(Map::new())),
None => extras
.entry(u.class.clone())
.or_insert_with(|| Value::Object(Map::new())),
};
let Some(table) = slot.as_object_mut() else {
self.warn(format!(
"{} {}: the `{slot_path}` slot is not a table; dropped from the output",
u.class, u.name
));
continue;
};
if table.insert(u.name.clone(), value).is_some() {
self.warn(format!(
"{} {}: the source `{slot_path}` carried an entry of the same name; \
the top-level object replaced it",
u.class, u.name
));
}
}
}
fn clear_non_table_extras_slots(&mut self, net: &DistNetwork, extras: &mut Map<String, Value>) {
let classes: BTreeSet<&str> = net
.untyped
.iter()
.map(|u| u.class.as_str())
.filter(|class| RAW_BMOPF_EXTRAS_TABLES.contains(class))
.collect();
for class in classes {
if extras.get(class).is_some_and(|v| !v.is_object()) {
self.warn(format!(
"extras `{class}`: the source value is not a table; replaced by the \
top-level `{class}` objects"
));
extras.insert(class.to_string(), Value::Object(Map::new()));
}
}
}
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;
};
let grounded: BTreeSet<String> = match bus.get("perfectly_grounded_terminals") {
Some(Value::Array(terms)) => terms
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
_ => BTreeSet::new(),
};
prune_string_array(
bus,
"terminal_names",
used,
&grounded,
&mut self.warnings,
&format!("bus {id}"),
);
prune_string_array(
bus,
"perfectly_grounded_terminals",
used,
&grounded,
&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));
let what = format!("line {}", l.name);
if let Some(i_max) = &l.i_max
&& let Some(v) = self.bounds(i_max, &format!("{what} i_max"))
{
o.insert("i_max".into(), v);
}
if let Some(s_max) = &l.s_max
&& let Some(v) = self.bounds(s_max, &format!("{what} s_max"))
{
o.insert("s_max".into(), v);
}
self.extras_dropped(&l.extras, &what);
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
&& let Some(v) = self.bounds(i_max, &format!("switch {} i_max", s.name))
{
o.insert("i_max".into(), v);
}
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 capacitors(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
if net.capacitors.is_empty() {
return;
}
let mut caps = Map::new();
for c in &net.capacitors {
let mut o = Map::new();
o.insert("bus".into(), json!(c.bus));
o.insert("terminal_map".into(), json!(c.terminal_map));
o.insert("configuration".into(), json!(config_str(c.configuration)));
o.insert("q_rated".into(), self.num(c.q_rated, "capacitor q_rated"));
o.insert("v_nom".into(), self.num(c.v_nom, "capacitor v_nom"));
self.extras_dropped(&c.extras, &format!("capacitor {}", c.name));
caps.insert(c.name.clone(), Value::Object(o));
}
doc.insert("capacitor".into(), Value::Object(caps));
}
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 emitted_sources = bmopf_voltage_sources(net);
let mut sources = Map::new();
if emitted_sources.is_empty() {
self.warn("network has no voltage source; BMOPF requires exactly one");
}
for (i, vs) in emitted_sources.iter().enumerate() {
if i > 0 {
self.warn(format!(
"voltage source {}: the BMOPF formulation expects exactly one source; \
this network has {}",
vs.name,
emitted_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));
for (name, extras) in &vs.dropped_extras {
self.extras_dropped(extras, &format!("voltage source {name}"));
}
sources.insert(vs.name.clone(), Value::Object(o));
}
doc.insert("voltage_source".into(), Value::Object(sources));
}
fn control_profiles(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
if net.control_profiles.is_empty() {
return;
}
let mut profiles = Map::new();
for profile in &net.control_profiles {
profiles.insert(profile.name.clone(), self.control_profile(profile));
}
doc.insert("control_profile".into(), Value::Object(profiles));
}
fn control_profile(&mut self, profile: &DistControlProfile) -> Value {
let mut o = Map::new();
if let Some(pf) = &profile.power_factor {
o.insert(
"power_factor".into(),
json!({ "pf": self.num(pf.pf, "power factor") }),
);
}
if let Some(vv) = &profile.volt_var {
o.insert("volt_var".into(), self.volt_var(vv));
}
if let Some(vw) = &profile.volt_watt {
o.insert("volt_watt".into(), self.volt_watt(vw));
}
for (key, value) in &profile.extras {
if value.is_object() {
o.insert(key.clone(), value.clone());
} else {
self.warn(format!(
"control_profile {}: extra `{key}` is not an object; dropped from the output",
profile.name
));
}
}
Value::Object(o)
}
fn volt_var(&mut self, vv: &VoltVarControl) -> Value {
let mut o = Map::new();
if let Some(v) = vv.voltage_reference {
o.insert("voltage_reference".into(), json_enum(v));
}
o.insert(
"breakpoints".into(),
self.nums(&vv.breakpoints, "volt_var breakpoints"),
);
o.insert(
"q_limits".into(),
self.nums(&vv.q_limits, "volt_var q_limits"),
);
if let Some(v) = vv.q_unit {
o.insert("q_unit".into(), json_enum::<ReactivePowerUnit>(v));
}
if let Some(v) = vv.q_ref {
o.insert("q_ref".into(), json_enum::<ReactivePowerReference>(v));
}
if let Some(v) = vv.p_min_for_q {
o.insert("p_min_for_q".into(), self.num(v, "volt_var p_min_for_q"));
}
if let Some(v) = vv.p_min_for_q_max {
o.insert(
"p_min_for_q_max".into(),
self.num(v, "volt_var p_min_for_q_max"),
);
}
Value::Object(o)
}
fn volt_watt(&mut self, vw: &VoltWattControl) -> Value {
let mut o = Map::new();
if let Some(v) = vw.voltage_reference {
o.insert(
"voltage_reference".into(),
json_enum::<ControlVoltageReference>(v),
);
}
o.insert(
"breakpoints".into(),
self.nums(&vw.breakpoints, "volt_watt breakpoints"),
);
o.insert(
"p_limits".into(),
self.nums(&vw.p_limits, "volt_watt p_limits"),
);
if let Some(v) = vw.p_unit {
o.insert("p_unit".into(), json_enum::<ActivePowerUnit>(v));
}
if let Some(v) = vw.p_ref {
o.insert("p_ref".into(), json_enum::<ActivePowerReference>(v));
}
Value::Object(o)
}
fn ibrs(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
if net.ibrs.is_empty() {
return;
}
let mut ibrs = Map::new();
for ibr in &net.ibrs {
ibrs.insert(ibr.name.clone(), self.ibr(ibr));
}
doc.insert("ibr".into(), Value::Object(ibrs));
}
fn ibr(&mut self, ibr: &DistIbr) -> Value {
let mut o = Map::new();
o.insert("bus".into(), json!(ibr.bus));
o.insert("terminal_map".into(), json!(ibr.terminal_map));
o.insert("topology".into(), json_enum(ibr.topology));
o.insert("prime_mover".into(), json_enum(ibr.prime_mover));
o.insert("s_max".into(), self.nums(&ibr.s_max, "ibr s_max"));
if let Some(v) = &ibr.i_max {
o.insert("i_max".into(), self.nums(v, "ibr i_max"));
}
if let Some(v) = ibr.p_avail {
o.insert("p_avail".into(), self.num(v, "ibr p_avail"));
}
if let Some(v) = &ibr.p_min {
o.insert("p_min".into(), self.nums(v, "ibr p_min"));
}
if let Some(v) = &ibr.p_max {
o.insert("p_max".into(), self.nums(v, "ibr p_max"));
}
if let Some(v) = &ibr.q_min {
o.insert("q_min".into(), self.nums(v, "ibr q_min"));
}
if let Some(v) = &ibr.q_max {
o.insert("q_max".into(), self.nums(v, "ibr q_max"));
}
if let Some(v) = &ibr.control_profile {
o.insert("control_profile".into(), json!(v));
}
if let Some(v) = ibr.voltage_aggregation {
o.insert("voltage_aggregation".into(), json_enum(v));
}
for (key, value) in &ibr.extras {
if IBR_EXTRA_FIELDS.contains(&key.as_str()) {
o.insert(key.clone(), value.clone());
} else {
self.warn(format!(
"ibr {}: extra `{key}` has no place in the BMOPF schema; dropped from the output",
ibr.name
));
}
}
Value::Object(o)
}
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
&& let Some(v) = self.bounds(v, &format!("{what} {key_lo}"))
{
o.insert(key_lo.into(), v);
}
if let Some(v) = hi
&& let Some(v) = self.bounds(v, &format!("{what} {key_hi}"))
{
o.insert(key_hi.into(), v);
}
} 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"),
);
if let Some(s_max) = &g.s_max
&& let Some(v) = self.bounds(s_max, &format!("{what} s_max"))
{
o.insert("s_max".into(), v);
}
if let Some(i_max) = &g.i_max
&& let Some(v) = self.bounds(i_max, &format!("{what} i_max"))
{
o.insert("i_max".into(), v);
}
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.warn_nonuniform_per_phase_taps(t);
match classify(t) {
Kind::SinglePhase => {
if t.windings.iter().any(|w| w.conn == WindingConn::Delta) {
let connection = match (t.windings[0].conn, t.windings[1].conn) {
(WindingConn::Wye, WindingConn::Delta) => "wye/delta",
(WindingConn::Delta, WindingConn::Wye) => "delta/wye",
_ => "delta",
};
let mut details = Map::new();
details.insert("connection".into(), json!(connection));
details.insert("emitted_subtype".into(), json!("single_phase"));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_CONNECTION_LOSSY",
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
),
details,
);
}
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) => {
let mut details = Map::new();
details.insert("reason".into(), json!(&why));
details.insert("phases".into(), json!(t.phases));
details.insert("windings".into(), json!(t.windings.len()));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_UNSUPPORTED",
format!(
"transformer {}: {why}; not representable in the four BMOPF \
subtypes, dropped from the output",
t.name
),
details,
);
}
}
}
self.split_transformer_overflow(&mut by_subtype);
by_subtype
}
fn split_transformer_overflow(&mut self, by_subtype: &mut Map<String, Value>) {
const MOVED_FIELDS: &[&str] = &[
"tap",
"tap_min",
"tap_max",
"r_neutral_from",
"x_neutral_from",
"r_neutral_to",
"x_neutral_to",
"g_no_load",
"b_no_load",
];
for subtype in ["single_phase", "center_tap", "wye_delta", "delta_wye"] {
let Some(Value::Object(table)) = by_subtype.get_mut(subtype) else {
continue;
};
for (name, entry) in table.iter_mut() {
let Value::Object(o) = entry else { continue };
let moved: Vec<String> = MOVED_FIELDS
.iter()
.filter(|k| o.contains_key(**k))
.map(|k| (*k).to_string())
.collect();
if moved.is_empty() {
continue;
}
let mut overflow = Map::new();
for key in &moved {
if let Some(v) = o.remove(key) {
overflow.insert(key.clone(), v);
}
}
self.warnings.push(format!(
"transformer {name}: {} have no {subtype} slot in BMOPF schema 0.1.0; \
kept under extras.transformer",
moved.join(", ")
));
self.transformer_overflow
.entry(subtype.to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("overflow subtype tables are objects")
.insert(name.clone(), Value::Object(overflow));
}
}
}
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 = base_impedance(from.v_ref, s);
let zb_to = base_impedance(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"),
);
self.referred_ohms(&mut o, "r_series_from", from.r_pct, zb_from, t, "from");
self.referred_ohms(&mut o, "r_series_to", to.r_pct, zb_to, t, "to");
if t.xsc_pct.is_empty() {
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
format!(
"transformer {}: xsc_pct is empty; emitted x_series_from=0",
t.name
),
Map::new(),
);
}
let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
self.referred_ohms(&mut o, "x_series_from", xhl, zb_from, t, "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_neutral_fields(&mut o, t, from, to);
self.transformer_tap_fields(&mut o, t, from, to);
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 = center_tap_common_terminal(w2, w3);
let r_neutral = self.center_tap_neutral(t, "r_neutral", w2.r_neutral, w3.r_neutral);
let x_neutral = self.center_tap_neutral(t, "x_neutral", w2.x_neutral, w3.x_neutral);
if (w2.tap - w3.tap).abs() > 1e-9 {
let mut details = Map::new();
details.insert("from_tap".into(), json!(from.tap));
details.insert("secondary_taps".into(), json!([w2.tap, w3.tap]));
details.insert("emitted_secondary_tap".into(), json!(w2.tap));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_CENTER_TAP_TAP_COLLAPSED",
format!(
"transformer {}: center tap secondary half winding taps ({}, {}) differ; emitted the first half tap",
t.name, w2.tap, w3.tap
),
details,
);
}
let to = center_tap_to_winding(w2, w3, &common, from.s_rating, r_neutral, x_neutral);
if w2.s_rating.to_bits() != from.s_rating.to_bits()
|| w3.s_rating.to_bits() != from.s_rating.to_bits()
{
let mut details = Map::new();
details.insert("from_s_rating".into(), json!(from.s_rating));
details.insert("half_s_ratings".into(), json!([w2.s_rating, w3.s_rating]));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_CENTER_TAP_RATING_COLLAPSED",
format!(
"transformer {}: center tap half winding s_ratings ({}, {}) differ \
from the primary's {}; BMOPF carries one transformer rating, and \
the first secondary half rating is used for the to-side impedance base",
t.name, w2.s_rating, w3.s_rating, from.s_rating
),
details,
);
}
let s = from.s_rating;
let zb_from = winding_base(from);
let zb_to = winding_base(w2);
if t.xsc_pct.is_empty() {
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
format!(
"transformer {}: xsc_pct is empty; emitted x_series_from=0",
t.name
),
Map::new(),
);
}
let (x_from_pct, x_to_pct) = self.center_tap_leakage_percentages(t);
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"),
);
self.referred_ohms(&mut o, "r_series_from", from.r_pct, zb_from, t, "from");
self.referred_ohms(&mut o, "r_series_to", w2.r_pct, zb_to, t, "to");
self.referred_ohms(&mut o, "x_series_from", x_from_pct, zb_from, t, "from");
self.referred_ohms(&mut o, "x_series_to", x_to_pct, zb_to, t, "to");
o.insert("terminal_map_from".into(), json!(from.terminal_map));
o.insert("terminal_map_to".into(), json!(to.terminal_map));
self.transformer_neutral_fields(&mut o, t, from, &to);
self.transformer_tap_fields(&mut o, t, from, &to);
self.transformer_no_load_fields(&mut o, t, from, s);
self.transformer_extras_dropped(t, &TRANSFORMER_TWO_WINDING_ALLOWED_EXTRAS);
o.into()
}
fn referred_resistance(
&mut self,
t: &DistTransformer,
from: &Winding,
to: &Winding,
v_wye2: f64,
) -> f64 {
let mut total = 0.0;
for (side, w) in [("from", from), ("to", to)] {
if w.s_rating > 0.0 && w.s_rating.is_finite() {
total += w.r_pct / w.s_rating;
} else if w.r_pct != 0.0 {
self.warn(format!(
"transformer {}: the `{side}` winding rating is not positive, so its \
resistance has no base to refer to; the term is dropped from r_series",
t.name
));
}
}
total / 100.0 * v_wye2
}
fn referred_ohms(
&mut self,
o: &mut Map<String, Value>,
key: &str,
pct: f64,
base: Option<f64>,
t: &DistTransformer,
side: &str,
) {
match base {
Some(zb) => {
let value = self.num(pct / 100.0 * zb, key);
o.insert(key.into(), value);
}
None => self.warn(format!(
"transformer {}: the `{side}` winding rating is not positive, so its \
percent impedance has no base to refer to; `{key}` is dropped from \
the output",
t.name
)),
}
}
fn center_tap_leakage_percentages(&mut self, t: &DistTransformer) -> (f64, f64) {
let (x_from_pct, x_to_pct) = center_tap_star_percentages(&t.xsc_pct);
if x_from_pct.is_finite()
&& x_to_pct.is_finite()
&& x_from_pct >= -1e-12
&& x_to_pct >= -1e-12
{
return (x_from_pct.max(0.0), x_to_pct.max(0.0));
}
let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
let emitted_from = if xhl.is_finite() { xhl.max(0.0) } else { 0.0 };
let mut details = Map::new();
details.insert("xsc_pct".into(), json!(&t.xsc_pct));
details.insert("star_percentages".into(), json!([x_from_pct, x_to_pct]));
details.insert("emitted_percentages".into(), json!([emitted_from, 0.0]));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_CENTER_TAP_LEAKAGE_UNREPRESENTABLE",
format!(
"transformer {}: center tap leakage star arms ({x_from_pct}, {x_to_pct}) \
are not representable as nonnegative BMOPF fields; emitted xhl on the \
from side and zero on the to side",
t.name
),
details,
);
(emitted_from, 0.0)
}
fn three_phase(&mut self, t: &DistTransformer, wye_idx: usize) -> Value {
let from = &t.windings[0];
let to = &t.windings[1];
let s = from.s_rating;
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"),
);
if t.xsc_pct.is_empty() {
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
format!(
"transformer {}: xsc_pct is empty; emitted x_series=0",
t.name,
),
Map::new(),
);
}
let xhl = t.xsc_pct.first().copied().unwrap_or(0.0);
let wye = &t.windings[wye_idx];
let v_wye2 = wye.v_ref * wye.v_ref;
let r_series = self.referred_resistance(t, from, to, v_wye2);
o.insert(
"r_series".into(),
self.num(r_series, "transformer r_series"),
);
let x_base = base_impedance(wye.v_ref, s);
self.referred_ohms(&mut o, "x_series", xhl, x_base, t, "from");
o.insert("terminal_map_from".into(), json!(from.terminal_map));
o.insert("terminal_map_to".into(), json!(to.terminal_map));
self.transformer_neutral_fields(&mut o, t, from, to);
self.transformer_tap_fields(&mut o, t, from, to);
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())
{
let mut details = Map::new();
details.insert(
"s_ratings".into(),
json!(t.windings.iter().map(|w| w.s_rating).collect::<Vec<_>>()),
);
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_N_WINDING_RATING_COLLAPSED",
format!(
"transformer {}: n_winding BMOPF carries one s_rating; emitted the first winding rating",
t.name
),
details,
);
}
let mut o = Map::new();
o.insert("s_rating".into(), self.num(s, "transformer s_rating"));
let windings: Vec<Value> = t
.windings
.iter()
.enumerate()
.map(|(idx, 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"),
);
if let Some(delta_roll) = bmopf_delta_roll(t, idx, w) {
wj.insert("delta_roll".into(), json!(delta_roll));
}
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 x_sc = self.n_winding_x_sc(t, base_z);
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.warn_unrepresented_neutral_fields(t, "n_winding BMOPF");
self.taps_dropped(t);
self.transformer_extras_dropped(t, &TRANSFORMER_NO_LOAD_ALLOWED_EXTRAS);
o.into()
}
fn n_winding_x_sc(&mut self, t: &DistTransformer, base_z: f64) -> Map<String, Value> {
let mut x_sc = Map::new();
let n_windings = t.windings.len();
if n_windings > MAX_DIM {
let mut details = Map::new();
details.insert("windings".into(), json!(n_windings));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_WINDINGS_CLAMPED",
format!(
"transformer {}: {n_windings} windings exceed the supported \
maximum of {MAX_DIM}; x_sc pairs beyond it are dropped",
t.name
),
details,
);
}
for (idx, (i, j)) in pair_keys(n_windings.min(MAX_DIM)).into_iter().enumerate() {
let x_pct = t.xsc_pct.get(idx).copied().unwrap_or_else(|| {
let mut details = Map::new();
details.insert("winding_pair".into(), json!(format!("{}_{}", i + 1, j + 1)));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_MISSING_XSC",
format!(
"transformer {}: missing x_sc for winding pair {}_{}; emitted 0",
t.name,
i + 1,
j + 1
),
details,
);
0.0
});
x_sc.insert(
format!("{}_{}", i + 1, j + 1),
self.num(x_pct / 100.0 * base_z, "transformer x_sc"),
);
}
x_sc
}
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,
r_neutral: if k == 0 { w.r_neutral } else { None },
x_neutral: if k == 0 { w.x_neutral } else { None },
}
};
let f = per(from);
let to_1 = per(to);
let mut t1 = t.clone();
t1.windings = vec![f.clone(), to_1.clone()];
split_no_load_extras(&mut t1, t.phases);
let v = self.two_winding(&t1, &f, &to_1, 1.0, true, false);
out.push((format!("{}_{}", t.name, k + 1), v));
}
let mut details = Map::new();
details.insert("emitted_subtype".into(), json!("single_phase"));
details.insert("units".into(), json!(t.phases));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_WYE_WYE_DECOMPOSED",
format!(
"transformer {}: three phase wye-wye decomposed into {} single_phase units",
t.name, t.phases
),
details,
);
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 {
let mut details = Map::new();
details.insert("tap".into(), json!(w.tap));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_TAP_DROPPED",
format!(
"transformer {}: off nominal tap {} has no BMOPF field; dropped",
t.name, w.tap
),
details,
);
}
}
}
fn transformer_tap_fields(
&mut self,
o: &mut Map<String, Value>,
t: &DistTransformer,
from: &Winding,
to: &Winding,
) {
if to.tap.abs() <= 1e-12 {
if (from.tap - 1.0).abs() > 1e-12 || (to.tap - 1.0).abs() > 1e-12 {
let mut details = Map::new();
details.insert("from_tap".into(), json!(from.tap));
details.insert("to_tap".into(), json!(to.tap));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_TAP_DROPPED",
format!(
"transformer {}: to-side tap {} cannot form a finite BMOPF ratio; dropped",
t.name, to.tap
),
details,
);
}
} else {
let tap = from.tap / to.tap;
if (tap - 1.0).abs() > 1e-12 || t.extras.contains_key("tap") {
o.insert("tap".into(), self.num(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}")));
}
}
}
fn warn_nonuniform_per_phase_taps(&mut self, t: &DistTransformer) {
let Some(tm_set) = t.extras.get("pmd_tm_set").and_then(Value::as_array) else {
return;
};
for (idx, raw) in tm_set.iter().enumerate() {
let Some(taps) = tap_values(raw) else {
continue;
};
let Some(first) = taps.first().copied() else {
continue;
};
if taps.iter().any(|tap| (tap - first).abs() > 1e-9) {
let mut details = Map::new();
details.insert("winding".into(), json!(idx + 1));
details.insert("source_taps".into(), json!(taps));
details.insert("emitted_winding_tap".into(), json!(first));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_PER_PHASE_TAP_COLLAPSED",
format!(
"transformer {}: winding {} has non-uniform per phase taps; emitted the first phase tap",
t.name,
idx + 1
),
details,
);
}
}
}
fn transformer_neutral_fields(
&mut self,
o: &mut Map<String, Value>,
t: &DistTransformer,
from: &Winding,
to: &Winding,
) {
self.transformer_neutral_field(o, t, "r_neutral_from", from.r_neutral);
self.transformer_neutral_field(o, t, "x_neutral_from", from.x_neutral);
self.transformer_neutral_field(o, t, "r_neutral_to", to.r_neutral);
self.transformer_neutral_field(o, t, "x_neutral_to", to.x_neutral);
}
fn transformer_neutral_field(
&mut self,
o: &mut Map<String, Value>,
t: &DistTransformer,
key: &str,
value: Option<f64>,
) {
let Some(v) = value else {
return;
};
if v.is_finite() && v >= 0.0 {
o.insert(key.into(), json!(v));
} else {
let mut details = Map::new();
details.insert("field".into(), json!(key));
details.insert("value".into(), json!(v));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NEUTRAL_DROPPED",
format!(
"transformer {}: {key}={v} is not a nonnegative finite BMOPF neutral impedance; dropped",
t.name
),
details,
);
}
}
fn center_tap_neutral(
&mut self,
t: &DistTransformer,
field: &str,
a: Option<f64>,
b: Option<f64>,
) -> Option<f64> {
if let (Some(a), Some(b)) = (a, b) {
let mut details = Map::new();
details.insert("field".into(), json!(field));
details.insert("values".into(), json!([a, b]));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_CENTER_TAP_NEUTRAL_COLLAPSED",
format!(
"transformer {}: center tap secondary has two {field} values ({a}, {b}); emitted the first",
t.name
),
details,
);
}
a.or(b)
}
fn warn_unrepresented_neutral_fields(&mut self, t: &DistTransformer, target: &str) {
for (idx, w) in t.windings.iter().enumerate() {
if w.r_neutral.is_some() || w.x_neutral.is_some() {
let mut details = Map::new();
details.insert("target".into(), json!(target));
details.insert("winding".into(), json!(idx + 1));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NEUTRAL_DROPPED",
format!(
"transformer {} winding {}: neutral impedance has no {target} field; dropped",
t.name,
idx + 1
),
details,
);
}
}
}
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) {
let mut details = Map::new();
details.insert("field".into(), json!("%noloadloss"));
details.insert("reason".into(), json!("phase_to_phase_single_phase"));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_DROPPED",
format!(
"transformer {}: phase-to-phase %noloadloss cannot be represented as a BMOPF no-load shunt; dropped",
t.name
),
details,
);
} 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 {
let mut details = Map::new();
details.insert("field".into(), json!("%noloadloss"));
details.insert("s_rating".into(), json!(s));
details.insert("v_nom_from".into(), json!(v_stamp));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_UNCONVERTIBLE",
format!(
"transformer {}: %noloadloss cannot be converted without a positive s_rating and v_nom_from",
t.name
),
details,
);
}
}
}
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) {
let mut details = Map::new();
details.insert("field".into(), json!("%imag"));
details.insert("reason".into(), json!("phase_to_phase_single_phase"));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_DROPPED",
format!(
"transformer {}: phase-to-phase %imag cannot be represented as a BMOPF no-load shunt; dropped",
t.name
),
details,
);
} 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 {
let mut details = Map::new();
details.insert("field".into(), json!("%imag"));
details.insert("s_rating".into(), json!(s));
details.insert("v_nom_from".into(), json!(v_stamp));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_NO_LOAD_SHUNT_UNCONVERTIBLE",
format!(
"transformer {}: %imag cannot be converted without a positive s_rating and v_nom_from",
t.name
),
details,
);
}
}
} 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;
}
let mut details = Map::new();
details.insert("field".into(), json!(key));
self.transformer_diagnostic(
t,
"EMIT.BMOPF.TRANSFORMER_EXTRA_DROPPED",
format!(
"transformer {}: `{key}` has no place in the BMOPF schema; dropped from the output",
t.name
),
details,
);
}
}
fn required_matrix(
&mut self,
o: &mut Map<String, Value>,
prefix: &str,
m: &Mat,
dim: usize,
name: &str,
) {
if m.is_empty() {
let dim = if dim > MAX_DIM {
self.warn(format!(
"{name}: {prefix} dimension {dim} exceeds the supported \
maximum of {MAX_DIM}; zero matrix clamped"
));
MAX_DIM
} else {
dim
};
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>,
also: &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) || also.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;
}
#[derive(Clone)]
struct SourceEmit {
name: String,
bus: String,
terminal_map: Vec<String>,
v_magnitude: Vec<f64>,
v_angle: Vec<f64>,
extras: Extras,
dropped_extras: Vec<(String, Extras)>,
}
impl From<&VoltageSource> for SourceEmit {
fn from(source: &VoltageSource) -> Self {
Self {
name: source.name.clone(),
bus: source.bus.clone(),
terminal_map: source.terminal_map.clone(),
v_magnitude: source.v_magnitude.clone(),
v_angle: source.v_angle.clone(),
extras: source.extras.clone(),
dropped_extras: Vec::new(),
}
}
}
#[derive(Clone)]
struct PhaseSource {
label: String,
magnitude: f64,
angle: f64,
neutral: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PhaseArrangement {
Zero,
Quadrature,
Positive,
Negative,
AntiPhase,
Incoherent,
}
fn bmopf_voltage_sources(net: &DistNetwork) -> Vec<SourceEmit> {
let emitted: Vec<SourceEmit> = net.sources.iter().map(SourceEmit::from).collect();
let bus_ids: BTreeMap<String, String> = net
.buses
.iter()
.map(|bus| (bus.id.to_ascii_lowercase(), bus.id.clone()))
.collect();
let mut by_bus: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, source) in emitted.iter().enumerate() {
by_bus
.entry(source.bus.to_ascii_lowercase())
.or_default()
.push(i);
}
let mut replacements = BTreeMap::new();
let mut removed = BTreeSet::new();
for (bus_key, indices) in by_bus.iter().filter(|(_, indices)| indices.len() > 1) {
if let Some((keep, merged)) =
merge_voltage_source_group(&emitted, indices, bus_ids.get(bus_key))
{
replacements.insert(keep, merged);
removed.extend(indices.iter().copied().filter(|i| *i != keep));
}
}
emitted
.into_iter()
.enumerate()
.filter_map(|(i, source)| {
if removed.contains(&i) {
None
} else {
Some(replacements.remove(&i).unwrap_or(source))
}
})
.collect()
}
fn merge_voltage_source_group(
sources: &[SourceEmit],
indices: &[usize],
bus_id: Option<&String>,
) -> Option<(usize, SourceEmit)> {
let mut sorted = indices.to_vec();
sorted.sort_by(|a, b| sources[*a].name.cmp(&sources[*b].name));
let mut phases = Vec::new();
let mut seen = BTreeSet::new();
for index in &sorted {
let source = &sources[*index];
if source_has_bounds_or_cost(&source.extras) {
return None;
}
let (rank, phase) = single_phase_source(source)?;
if !seen.insert(rank) {
return None;
}
phases.push((rank, phase));
}
if phases.len() < 2 {
return None;
}
phases.sort_by_key(|(rank, _)| *rank);
let neutral = phases.first()?.1.neutral.clone();
if phases.iter().any(|(_, phase)| phase.neutral != neutral) {
return None;
}
match phase_arrangement(phases.iter().map(|(_, phase)| phase.angle)) {
PhaseArrangement::Zero | PhaseArrangement::Positive | PhaseArrangement::Negative => {}
PhaseArrangement::Quadrature
| PhaseArrangement::AntiPhase
| PhaseArrangement::Incoherent => {
return None;
}
}
let keep = sorted
.iter()
.copied()
.find(|i| sources[*i].name == "source")
.unwrap_or(sorted[0]);
let mut merged = sources[keep].clone();
if let Some(bus_id) = bus_id {
merged.bus.clone_from(bus_id);
}
merged.terminal_map = phases
.iter()
.map(|(_, phase)| phase.label.clone())
.collect();
merged.v_magnitude = phases.iter().map(|(_, phase)| phase.magnitude).collect();
merged.v_angle = phases.iter().map(|(_, phase)| phase.angle).collect();
if let Some(neutral) = neutral {
merged.terminal_map.push(neutral);
merged.v_magnitude.push(0.0);
merged.v_angle.push(0.0);
}
merged.dropped_extras = sorted
.iter()
.copied()
.filter(|i| *i != keep)
.map(|i| (sources[i].name.clone(), sources[i].extras.clone()))
.collect();
Some((keep, merged))
}
fn source_has_bounds_or_cost(extras: &Extras) -> bool {
["p_min", "p_max", "q_min", "q_max", "cost"]
.iter()
.any(|key| extras.contains_key(*key))
}
fn single_phase_source(source: &SourceEmit) -> Option<(usize, PhaseSource)> {
let mut phase = None;
let mut neutral = None;
for (i, label) in source.terminal_map.iter().enumerate() {
if let Some(rank) = phase_rank(label) {
if phase.replace((rank, label.clone(), i)).is_some() {
return None;
}
} else if neutral.replace(label.clone()).is_some() {
return None;
}
}
let (rank, label, index) = phase?;
Some((
rank,
PhaseSource {
label,
magnitude: *source.v_magnitude.get(index)?,
angle: *source.v_angle.get(index)?,
neutral,
},
))
}
fn phase_rank(label: &str) -> Option<usize> {
match label {
"1" | "a" | "A" => Some(0),
"2" | "b" | "B" => Some(1),
"3" | "c" | "C" => Some(2),
_ => None,
}
}
fn phase_arrangement(angles: impl IntoIterator<Item = f64>) -> PhaseArrangement {
let angles: Vec<f64> = angles.into_iter().collect();
if angles.len() < 2 {
return PhaseArrangement::Incoherent;
}
if angles.len() == 2 {
return separation_of_diff(angles[0] - angles[1]);
}
let mut arrangement = None;
for i in 0..angles.len() {
let next = (i + 1) % angles.len();
let current = separation_of_diff(angles[i] - angles[next]);
if current == PhaseArrangement::Incoherent {
return PhaseArrangement::Incoherent;
}
if let Some(previous) = arrangement {
if previous != current {
return PhaseArrangement::Incoherent;
}
} else {
arrangement = Some(current);
}
}
arrangement.unwrap_or(PhaseArrangement::Incoherent)
}
fn separation_of_diff(diff: f64) -> PhaseArrangement {
let diff = wrap_pi(diff);
let adiff = diff.abs();
if adiff <= PI / 6.0 {
PhaseArrangement::Zero
} else if (adiff - FRAC_PI_2).abs() <= PI / 12.0 {
PhaseArrangement::Quadrature
} else if (adiff - TAU / 3.0).abs() <= PI / 6.0 {
if diff > 0.0 {
PhaseArrangement::Positive
} else {
PhaseArrangement::Negative
}
} else if (adiff - PI).abs() <= PI / 12.0 {
PhaseArrangement::AntiPhase
} else {
PhaseArrangement::Incoherent
}
}
fn wrap_pi(angle: f64) -> f64 {
let wrapped = (angle + PI).rem_euclid(TAU) - PI;
if (wrapped + PI).abs() < f64::EPSILON {
PI
} else {
wrapped
}
}
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, unplaced: &mut Vec<String>) -> Option<Value> {
if let [(None, text)] = u.props.as_slice() {
return serde_json::from_str(text).ok();
}
let mut o = Map::new();
for (key, text) in &u.props {
let Some(key) = key.as_ref() else {
unplaced.push(text.clone());
continue;
};
let value = serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()));
o.insert(key.clone(), value);
}
(!o.is_empty()).then_some(Value::Object(o))
}
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 tap_values(v: &Value) -> Option<Vec<f64>> {
if let Some(items) = v.as_array() {
let out: Vec<f64> = items.iter().filter_map(value_number).collect();
Some(out)
} else {
value_number(v).map(|tap| vec![tap])
}
}
fn value_number(v: &Value) -> Option<f64> {
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 split_no_load_extras(t: &mut DistTransformer, phases: usize) {
let phases = phases.max(1) as f64;
for key in ["g_no_load", "b_no_load"] {
if let Some(v) = extras_number(&t.extras, key) {
t.extras.insert(key.into(), json!(v / phases));
}
}
}
fn center_tap_common_terminal(w2: &Winding, w3: &Winding) -> String {
w2.terminal_map
.iter()
.find(|term| w3.terminal_map.contains(term))
.cloned()
.unwrap_or_default()
}
fn center_tap_to_winding(
w2: &Winding,
w3: &Winding,
common: &str,
s_rating: f64,
r_neutral: Option<f64>,
x_neutral: Option<f64>,
) -> Winding {
let terminal_map = center_tap_terminal_map(w2, w3, common);
Winding {
bus: w2.bus.clone(),
terminal_map,
conn: WindingConn::Wye,
v_ref: w2.v_ref,
s_rating,
r_pct: w2.r_pct,
tap: w2.tap,
r_neutral,
x_neutral,
}
}
fn center_tap_terminal_map(w2: &Winding, w3: &Winding, common: &str) -> Vec<String> {
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 first = hots.first().cloned().unwrap_or_default();
let second = hots.get(1).cloned().unwrap_or_default();
vec![first, common.to_string(), second]
}
fn center_tap_star_percentages(xsc_pct: &[f64]) -> (f64, f64) {
let xhl = xsc_pct.first().copied().unwrap_or(0.0);
let xht = xsc_pct.get(1).copied().unwrap_or(xhl);
let xlt = xsc_pct.get(2).copied().unwrap_or(0.0);
((xhl + xht - xlt) / 2.0, (xhl + xlt - xht) / 2.0)
}
fn winding_base(w: &Winding) -> Option<f64> {
base_impedance(w.v_ref, w.s_rating)
}
fn base_impedance(v_ref: f64, s: f64) -> Option<f64> {
(s > 0.0 && s.is_finite()).then(|| v_ref * v_ref / s)
}
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 bmopf_delta_roll(t: &DistTransformer, idx: usize, w: &Winding) -> Option<i64> {
if w.conn != WindingConn::Delta {
return None;
}
t.extras
.get(BMOPF_DELTA_ROLLS_EXTRA)
.and_then(Value::as_object)
.and_then(|rolls| rolls.get(&(idx + 1).to_string()))
.and_then(Value::as_i64)
.filter(|roll| *roll == 1 || *roll == -1)
.or(Some(-1))
}
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",
}
}
fn json_enum<T: serde::Serialize>(value: T) -> Value {
serde_json::to_value(value).expect("enum serializes to a string")
}
#[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])
);
}
#[test]
fn oversized_model_dimensions_are_clamped_not_expanded() {
use crate::model::{DistLineCode, DistShunt, DistTransformer, Winding, WindingConn};
let mut net = crate::model::DistNetwork::default();
let mut lc = DistLineCode::new("big", Vec::new(), Vec::new());
lc.r_series = vec![Vec::new(); 100_000];
net.linecodes.push(lc);
net.shunts.push(DistShunt::new(
"big",
"b",
Vec::new(),
vec![Vec::new(); 100_000],
Vec::new(),
));
let winding = Winding::new("b", Vec::new(), WindingConn::Wye, 1.0, 1.0);
net.transformers.push(DistTransformer::new(
"many",
vec![winding; MAX_DIM + 6],
Vec::new(),
3,
));
let out = write_bmopf_json(&net);
let v: Value = serde_json::from_str(&out.text).unwrap();
let count_keys = |m: &Value, prefix: &str| {
m.as_object()
.unwrap()
.keys()
.filter(|k| k.starts_with(prefix))
.count()
};
assert_eq!(
count_keys(&v["linecode"]["big"], "X_series_"),
MAX_DIM * MAX_DIM
);
assert_eq!(count_keys(&v["shunt"]["big"], "B_"), MAX_DIM * MAX_DIM);
assert_eq!(
v["transformer"]["n_winding"]["many"]["x_sc"]
.as_object()
.unwrap()
.len(),
MAX_DIM * (MAX_DIM - 1) / 2
);
assert!(
out.warnings
.iter()
.any(|w| w.contains("exceeds the supported maximum")),
"{:?}",
out.warnings
);
}
}