use std::collections::BTreeSet;
use powerio_core::TimeSeries;
use serde_json::{Map, Value};
use crate::diagnostics::codes as C;
use crate::error::{Error, Result};
use crate::model::{
ConductorMatrix, Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad,
DistLoadVoltageModel, DistShunt, DistSourceFormat, DistSwitch, DistTransformer, DistWinding,
DistWindingConn, Extras, MulticonductorNetwork, MulticonductorNetworkTables, UntypedObject,
VoltageSource,
};
pub(crate) fn parse_pmd_collecting(
text: &str,
diags: &mut crate::collect::Diagnostics,
) -> Result<MulticonductorNetwork> {
let doc = engineering_document(text)?;
if is_multinetwork_document(&doc) {
let steps = doc.get("nw").and_then(Value::as_object).map_or(0, Map::len);
return Err(Error::Json {
format: "PMD",
message: format!(
"the document is a multinetwork ENGINEERING model with {steps} time steps \
under `nw`, and one MulticonductorNetwork holds one network; read it with \
powerio::parse, which returns a TimeSeries<MulticonductorNetwork>, or \
export the model before `make_multinetwork`"
),
});
}
Ok(network_from_document(&doc, diags))
}
fn is_multinetwork_document(doc: &Map<String, Value>) -> bool {
doc.get("multinetwork").and_then(Value::as_bool) == Some(true)
}
pub(crate) fn text_is_multinetwork(text: &str) -> bool {
#[derive(serde::Deserialize)]
struct Header {
#[serde(default)]
multinetwork: Option<Value>,
}
serde_json::from_str::<Header>(text)
.ok()
.and_then(|header| header.multinetwork)
.and_then(|flag| flag.as_bool())
== Some(true)
}
const MULTINETWORK_KEYS: &[&str] = &["multinetwork", "nw", "mn_lookup"];
pub(crate) fn parse_pmd_multinetwork_collecting(
text: &str,
diags: &mut crate::collect::Diagnostics,
) -> Result<TimeSeries<MulticonductorNetwork>> {
let doc = engineering_document(text)?;
let Some(Value::Object(steps)) = doc.get("nw") else {
return Err(Error::Json {
format: "PMD",
message: "`multinetwork` is true but `nw` is not an object of time steps".into(),
});
};
let lookup = doc.get("mn_lookup").and_then(Value::as_object);
let time_of = |key: &str, step: &Value| -> f64 {
lookup
.and_then(|l| l.get(key))
.or_else(|| step.get("time"))
.and_then(Value::as_f64)
.unwrap_or(f64::NAN)
};
let mut order: Vec<(&String, &Value)> = steps.iter().collect();
order.sort_by(|(a, va), (b, vb)| {
time_of(a, va).total_cmp(&time_of(b, vb)).then_with(|| {
match (a.parse::<i64>(), b.parse::<i64>()) {
(Ok(x), Ok(y)) => x.cmp(&y),
_ => a.cmp(b),
}
})
});
let mut seen = BTreeSet::new();
let mut time_points = Vec::with_capacity(order.len());
let mut values = Vec::with_capacity(order.len());
for (key, step) in order {
let Value::Object(step) = step else {
diags.push(
&C::READ_PMD_RECORD_DROPPED,
format!("multinetwork time step {key} is not an object; dropped"),
);
continue;
};
let mut merged = step.clone();
for (k, v) in &doc {
if !MULTINETWORK_KEYS.contains(&k.as_str()) && !merged.contains_key(k) {
merged.insert(k.clone(), v.clone());
}
}
let elapsed = merged.remove("time_elapsed");
let time = merged.remove("time");
let mut found = crate::collect::Diagnostics::new();
let mut net = network_from_document(&merged, &mut found);
if let Some(time) = time {
net.extras_mut().insert("pmd_time".into(), time);
}
let duration = elapsed.as_ref().and_then(|v| {
let duration = v
.as_f64()
.filter(|hours| hours.is_finite() && *hours >= 0.0)
.and_then(|hours| std::time::Duration::try_from_secs_f64(hours * 3600.0).ok());
if duration.is_none() {
found.push(
&C::READ_PMD_SOURCE_MALFORMED,
format!(
"multinetwork time step {key}: `time_elapsed` {v} is not a finite \
nonnegative number of hours; the time point has no duration"
),
);
}
duration
});
for record in found.into_records() {
if seen.insert((record.code().to_string(), record.message().to_string())) {
diags.absorb([record]);
}
}
let point =
powerio_core::TimePoint::new(key.clone(), duration).map_err(|e| Error::Json {
format: "PMD",
message: format!("multinetwork time step {key}: {e}"),
})?;
time_points.push(point);
values.push(net);
}
if values.is_empty() {
return Err(Error::Json {
format: "PMD",
message: "`multinetwork` is true but `nw` holds no time step".into(),
});
}
TimeSeries::new(time_points, values).map_err(|e| Error::Json {
format: "PMD",
message: e.to_string(),
})
}
fn network_from_document(
doc: &Map<String, Value>,
diags: &mut crate::collect::Diagnostics,
) -> MulticonductorNetwork {
let mut net = MulticonductorNetwork::from_tables(MulticonductorNetworkTables {
source_format: Some(DistSourceFormat::PmdJson),
base_frequency: 60.0,
..MulticonductorNetworkTables::default()
});
let mut rd = Reader {
net: &mut net,
diagnostics: crate::diagnostics::Diagnostics::new(),
};
rd.document(doc);
let found = std::mem::take(&mut rd.diagnostics);
diags.absorb(found);
crate::model::warn_unresolved_references(&net, diags);
net
}
fn engineering_document(text: &str) -> Result<Map<String, Value>> {
let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json {
format: "PMD",
message: e.to_string(),
})?;
let Value::Object(doc) = doc else {
return Err(Error::Json {
format: "PMD",
message: "top level is not an object".into(),
});
};
if let Some(dm) = doc.get("data_model").and_then(Value::as_str)
&& !dm.eq_ignore_ascii_case("ENGINEERING")
{
return Err(Error::Json {
format: "PMD",
message: format!(
"`data_model` is `{dm}`; this reader supports the ENGINEERING model \
(convert MATHEMATICAL output back with \
PowerModelsDistribution.transform_solution or export the ENGINEERING \
model)"
),
});
}
Ok(doc)
}
struct Reader<'a> {
net: &'a mut MulticonductorNetwork,
diagnostics: crate::diagnostics::Diagnostics,
}
fn restore(key: &str, v: &Value) -> f64 {
if v.is_null() {
if key.ends_with("_ub") || key.ends_with("max") {
f64::INFINITY
} else if key.ends_with("_lb") || key.ends_with("min") {
f64::NEG_INFINITY
} else {
f64::NAN
}
} else {
v.as_f64().unwrap_or(f64::NAN)
}
}
fn floats(key: &str, v: Option<&Value>) -> Option<Vec<f64>> {
v?.as_array()
.map(|a| a.iter().map(|x| restore(key, x)).collect())
}
const MAX_MATRIX_DIM: usize = 64;
fn matrix(
key: &str,
v: Option<&Value>,
what: &str,
diagnostics: &mut crate::diagnostics::Diagnostics,
) -> Option<ConductorMatrix> {
let v = v?;
let Some(cols) = v.as_array() else {
diagnostics.push(
&C::READ_PMD_SOURCE_MALFORMED,
format!("{what}: `{key}` is not an array of columns; dropped"),
);
return None;
};
let n = cols.len();
if n > MAX_MATRIX_DIM {
diagnostics.push(
&C::READ_PMD_VALUE_CLAMPED,
format!(
"{key}: matrix dimension {n} exceeds the supported maximum of \
{MAX_MATRIX_DIM}; dropped"
),
);
return None;
}
let mut m = vec![vec![0.0; n]; n];
for (j, col) in cols.iter().enumerate() {
let Some(col) = col.as_array() else {
diagnostics.push(
&C::READ_PMD_SOURCE_MALFORMED,
format!("{what}: `{key}` column {j} is not an array; kept as zeros"),
);
continue;
};
for (i, x) in col.iter().enumerate().take(n) {
m[i][j] = restore(key, x);
}
}
Some(m)
}
fn ints_as_strings(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|x| {
x.as_i64().map_or_else(
|| x.as_str().unwrap_or_default().to_string(),
|i| i.to_string(),
)
})
.collect()
})
.unwrap_or_default()
}
fn string(v: Option<&Value>) -> String {
v.and_then(Value::as_str).unwrap_or_default().to_string()
}
fn pad_to(m: ConductorMatrix, n: usize) -> ConductorMatrix {
if m.len() >= n {
return m;
}
let mut out = vec![vec![0.0; n]; n];
for (i, row) in m.into_iter().enumerate() {
for (j, v) in row.into_iter().enumerate() {
out[i][j] = v;
}
}
out
}
fn take_extras(o: &Map<String, Value>, known: &[&str]) -> Extras {
o.iter()
.filter(|(k, _)| !known.contains(&k.as_str()) && k.as_str() != "name")
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn stash_status(
o: &Map<String, Value>,
extras: &mut Extras,
what: &str,
diagnostics: &mut crate::diagnostics::Diagnostics,
) {
if let Some(s) = o.get("status").and_then(Value::as_str)
&& s != "ENABLED"
{
extras.insert("pmd_status".into(), Value::String(s.to_string()));
diagnostics.push(
&C::READ_PMD_RETAINED_SOURCE_ONLY,
format!("{what}: status {s} kept in extras; other formats emit the element enabled"),
);
}
}
fn linecode_from(
name: &str,
o: &Map<String, Value>,
base_frequency: f64,
diagnostics: &mut crate::diagnostics::Diagnostics,
) -> DistLineCode {
let what = format!("linecode {name}");
let mut mat = |key: &str| matrix(key, o.get(key), &what, diagnostics);
let mats = [
mat("rs"),
mat("xs"),
mat("g_fr"),
mat("g_to"),
mat("b_fr"),
mat("b_to"),
];
let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0);
if mats.iter().flatten().any(|m| m.len() < n) {
diagnostics.push(
&C::READ_PMD_VALUE_DEFAULTED,
format!(
"linecode {name}: matrix sizes disagree; smaller ones padded \
with zeros to {n}x{n}"
),
);
}
let [r, x, gf, gt, bf, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n));
let omega = std::f64::consts::TAU * base_frequency * 1e-9;
let to_b = |m: ConductorMatrix| -> ConductorMatrix {
m.into_iter()
.map(|row| row.into_iter().map(|v| v * omega).collect())
.collect()
};
DistLineCode {
name: name.to_string(),
n_conductors: n,
x_series: x,
g_from: gf,
g_to: gt,
b_from: to_b(bf),
b_to: to_b(bt),
r_series: r,
i_max: floats("cm_ub", o.get("cm_ub")),
s_max: floats("sm_ub", o.get("sm_ub")),
source: None,
extras: {
let mut extras = Extras::new();
if let Some(b) = o.get("b_fr") {
extras.insert("pmd_b_fr".into(), b.clone());
}
if let Some(b) = o.get("b_to") {
extras.insert("pmd_b_to".into(), b.clone());
}
extras
},
}
}
#[allow(clippy::float_cmp)]
fn representative_taps(tm_set: Option<&Value>) -> (Vec<f64>, bool) {
let mut firsts = Vec::new();
let mut differ = false;
for w in tm_set
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
let taps: Vec<f64> = w
.as_array()
.map(|p| p.iter().map(|v| restore("tm_set", v)).collect())
.unwrap_or_default();
let first = taps.first().copied().unwrap_or(1.0);
differ |= taps.iter().any(|&t| t != first);
firsts.push(first);
}
(firsts, differ)
}
struct WindingNums<'a> {
rw: &'a [f64],
xsc: &'a [f64],
sm_nom: &'a [f64],
vm_nom: &'a [f64],
tm_set: &'a [f64],
}
fn build_windings(
buses: &[String],
configs: &[DistWindingConn],
polarity: &[i64],
o: &Map<String, Value>,
nums: &WindingNums,
) -> (Vec<DistWinding>, usize, bool) {
let _ = nums.xsc;
let mut windings = Vec::with_capacity(buses.len());
let mut phases = 1;
let mut unrolled = false;
for (w, bus) in buses.iter().enumerate() {
let mut map = ints_as_strings(
o.get("connections")
.and_then(Value::as_array)
.and_then(|a| a.get(w)),
);
let conn = configs.get(w).copied().unwrap_or(DistWindingConn::Wye);
if polarity.get(w) == Some(&-1)
&& conn == DistWindingConn::Wye
&& configs.first() == Some(&DistWindingConn::Delta)
&& map.len() > 1
{
let phases_part = map.len() - 1;
map[..phases_part].rotate_right(1);
unrolled = true;
}
if conn == DistWindingConn::Wye {
phases = phases.max(map.len().saturating_sub(1));
} else {
phases = phases.max(map.len());
}
windings.push(DistWinding {
bus: bus.clone(),
terminal_map: map,
conn,
v_ref: nums.vm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3,
s_rating: nums.sm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3,
r_pct: nums.rw.get(w).copied().unwrap_or(0.0) * 100.0,
tap: nums.tm_set.get(w).copied().unwrap_or(1.0),
r_neutral: None,
x_neutral: None,
});
}
(windings, phases, unrolled)
}
const SECTIONS: &[&str] = &[
"bus",
"linecode",
"line",
"switch",
"load",
"generator",
"shunt",
"voltage_source",
"transformer",
];
fn inferred_terminal_roles(doc: &Map<String, Value>) -> Option<Value> {
let mut neutral = BTreeSet::new();
for class in ["load", "generator", "voltage_source", "shunt"] {
for component in doc
.get(class)
.and_then(Value::as_object)
.into_iter()
.flat_map(|v| v.values())
{
let wye = component.get("configuration").and_then(Value::as_str) == Some("WYE");
let connections = ints_as_strings(component.get("connections"));
let has_neutral = if class == "voltage_source" {
component
.get("vm")
.and_then(Value::as_array)
.and_then(|v| v.last())
.and_then(Value::as_f64)
== Some(0.0)
} else {
connections.len() == 2 || connections.len() == 4
};
if wye
&& has_neutral
&& let Some(terminal) = connections.last()
{
neutral.insert(terminal.clone());
}
}
}
for transformer in doc
.get("transformer")
.and_then(Value::as_object)
.into_iter()
.flat_map(|v| v.values())
{
if let (Some(configurations), Some(connections)) = (
transformer.get("configuration").and_then(Value::as_array),
transformer.get("connections").and_then(Value::as_array),
) {
for (configuration, terminals) in configurations.iter().zip(connections) {
if configuration.as_str() == Some("WYE")
&& terminals
.as_array()
.is_some_and(|v| v.len() == 2 || v.len() == 4)
&& let Some(terminal) = ints_as_strings(Some(terminals)).last()
{
neutral.insert(terminal.clone());
}
}
}
}
(!neutral.is_empty()).then(|| serde_json::json!({"neutral": neutral}))
}
impl Reader<'_> {
fn document(&mut self, doc: &Map<String, Value>) {
if let Some(roles) = inferred_terminal_roles(doc) {
self.net
.extras_mut()
.insert("bmopf_terminal_conventions".into(), roles);
}
if let Some(name) = doc.get("name").and_then(Value::as_str) {
*self.net.name_mut() = Some(name.to_string());
}
let settings = doc.get("settings").and_then(Value::as_object);
let stated = settings
.and_then(|s| s.get("base_frequency"))
.and_then(Value::as_f64);
if let Some(f) = stated {
*self.net.base_frequency_mut() = f;
}
if let Some(settings) = settings {
self.net
.extras_mut()
.insert("pmd_settings".into(), Value::Object(settings.clone()));
}
for key in ["data_model", "files", "conductor_ids", "per_unit"] {
if let Some(v) = doc.get(key) {
self.net
.extras_mut()
.insert(format!("pmd_{key}"), v.clone());
}
}
for &key in SECTIONS {
let Some(Value::Object(items)) = doc.get(key) else {
continue;
};
match key {
"bus" => self.buses(items),
"linecode" => self.line_codes(items),
"line" => self.lines(items),
"switch" => self.switches(items),
"load" => self.loads(items),
"generator" => self.generators(items),
"shunt" => self.shunts(items),
"voltage_source" => self.sources(items),
"transformer" => self.transformers(items),
_ => unreachable!(),
}
}
let mut top_level = Map::new();
for (key, value) in doc {
if SECTIONS.contains(&key.as_str())
|| ["settings", "name", "data_model", "files", "conductor_ids", "per_unit"]
.contains(&key.as_str())
|| (key == "multinetwork" && value == &Value::Bool(false))
{
continue;
}
let Value::Object(items) = value else {
self.diagnostics.push(
&C::READ_PMD_RETAINED_SOURCE_ONLY,
format!("ENGINEERING top level `{key}` is not typed; kept in extras"),
);
top_level.insert(key.clone(), value.clone());
continue;
};
self.diagnostics.push(
&C::READ_PMD_RETAINED_SOURCE_ONLY,
format!(
"ENGINEERING `{key}` components are not typed; kept untyped (the PMD \
writer reproduces them, other formats drop them)"
),
);
for (name, v) in items {
self.net.untyped_objects_mut().push(UntypedObject {
class: key.clone(),
name: name.clone(),
props: vec![(None, v.to_string())],
});
}
}
if !top_level.is_empty() {
self.net
.extras_mut()
.insert("pmd_top_level".into(), Value::Object(top_level));
}
if stated.is_none() {
crate::model::warn_defaulted_frequency(
self.net,
"settings.base_frequency",
&mut self.diagnostics,
);
}
}
fn buses(&mut self, items: &Map<String, Value>) {
for (id, v) in items {
let Value::Object(o) = v else { continue };
let mut extras = take_extras(
o,
&[
"terminals",
"grounded",
"rg",
"xg",
"status",
"lat",
"lon",
"vm_lb",
"vm_ub",
],
);
if let Some(x) = o.get("lon") {
extras.insert("x".into(), x.clone());
}
if let Some(y) = o.get("lat") {
extras.insert("y".into(), y.clone());
}
let rg = floats("rg", o.get("rg")).unwrap_or_default();
let xg = floats("xg", o.get("xg")).unwrap_or_default();
if rg.iter().any(|&r| r != 0.0) || xg.iter().any(|&x| x != 0.0) {
self.diagnostics.push(
&C::READ_PMD_RETAINED_SOURCE_ONLY,
format!("bus {id}: nonzero grounding impedance is not typed; kept in extras"),
);
extras.insert("rg".into(), o.get("rg").cloned().unwrap_or(Value::Null));
extras.insert("xg".into(), o.get("xg").cloned().unwrap_or(Value::Null));
}
stash_status(o, &mut extras, &format!("bus {id}"), &mut self.diagnostics);
let mut bus = DistBus {
id: id.clone(),
terminals: ints_as_strings(o.get("terminals")),
grounded: ints_as_strings(o.get("grounded")),
extras,
..DistBus::default()
};
let indices = bus.phase_indices(self.net.extras().get("bmopf_terminal_conventions"));
let bound = |key: &str| -> (Option<f64>, Option<Vec<f64>>) {
let Some(values) = floats(key, o.get(key)) else {
return (None, None);
};
let phase_values: Option<Vec<_>> = indices
.iter()
.map(|&i| values.get(i).map(|v| v * 1e3))
.collect();
let Some(phase_values) = phase_values else {
return (None, None);
};
if let Some(&first) = phase_values.first()
&& phase_values.iter().all(|&v| v.to_bits() == first.to_bits())
{
(Some(first), None)
} else {
(None, Some(phase_values))
}
};
(bus.v_min, bus.v_min_phase) = bound("vm_lb");
(bus.v_max, bus.v_max_phase) = bound("vm_ub");
for (key, default) in [("vm_lb", serde_json::json!(0.0)), ("vm_ub", Value::Null)] {
if let Some(value) = o.get(key) {
let needs_retention = value.as_array().is_none_or(|values| {
values.len() != bus.terminals.len()
|| values
.iter()
.enumerate()
.any(|(i, v)| !indices.contains(&i) && *v != default)
});
if needs_retention {
bus.extras.insert(format!("pmd_{key}"), value.clone());
}
}
}
self.net.buses_mut().push(bus);
}
}
fn line_codes(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let mut lc = linecode_from(name, o, self.net.base_frequency(), &mut self.diagnostics);
let mut extras = take_extras(
o,
&["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub", "sm_ub"],
);
extras.append(&mut lc.extras);
lc.extras = extras;
self.net.line_codes_mut().push(lc);
}
}
fn lines(&mut self, items: &Map<String, Value>) {
let mut linecode_names: BTreeSet<String> = self
.net
.line_codes()
.iter()
.map(|c| c.name.to_ascii_lowercase())
.collect();
for (name, v) in items {
let Value::Object(o) = v else { continue };
let mut known = vec![
"f_bus",
"t_bus",
"f_connections",
"t_connections",
"linecode",
"length",
"status",
"source_id",
];
let mut linecode = string(o.get("linecode"));
let mut extras;
let mut i_max = None;
let mut s_max = None;
if linecode.is_empty() && o.get("rs").is_some() {
known.extend(["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub", "sm_ub"]);
extras = take_extras(o, &known);
let mut lc_name = format!("{name}_z");
let mut k = 2;
while linecode_names.contains(&lc_name.to_ascii_lowercase()) {
lc_name = format!("{name}_z{k}");
k += 1;
}
linecode_names.insert(lc_name.to_ascii_lowercase());
let lc = linecode_from(
&lc_name,
o,
self.net.base_frequency(),
&mut self.diagnostics,
);
self.net.line_codes_mut().push(lc);
self.diagnostics.push(&C::READ_PMD_VALUE_INLINED, format!(
"line {name}: inline impedance materialized as linecode {lc_name}; the PMD writer re-inlines it"
));
extras.insert("pmd_inline".into(), Value::Bool(true));
linecode = lc_name;
} else {
known.extend(["cm_ub", "sm_ub"]);
extras = take_extras(o, &known);
i_max = floats("cm_ub", o.get("cm_ub"));
s_max = floats("sm_ub", o.get("sm_ub"));
}
stash_status(
o,
&mut extras,
&format!("line {name}"),
&mut self.diagnostics,
);
self.net.lines_mut().push(DistLine {
name: name.clone(),
bus_from: string(o.get("f_bus")),
bus_to: string(o.get("t_bus")),
terminal_map_from: ints_as_strings(o.get("f_connections")),
terminal_map_to: ints_as_strings(o.get("t_connections")),
linecode,
length: o.get("length").map_or(f64::NAN, |v| restore("length", v)),
route: None,
i_max,
s_max,
extras,
});
}
}
fn switches(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let mut extras = take_extras(
o,
&[
"f_bus",
"t_bus",
"f_connections",
"t_connections",
"state",
"cm_ub",
"status",
"source_id",
"dispatchable",
"rs",
"xs",
"g_fr",
"g_to",
"b_fr",
"b_to",
],
);
for key in ["rs", "xs"] {
if let Some(m) = o.get(key) {
extras.insert(format!("pmd_{key}"), m.clone());
}
}
stash_status(
o,
&mut extras,
&format!("switch {name}"),
&mut self.diagnostics,
);
self.net.switches_mut().push(DistSwitch {
name: name.clone(),
bus_from: string(o.get("f_bus")),
bus_to: string(o.get("t_bus")),
terminal_map_from: ints_as_strings(o.get("f_connections")),
terminal_map_to: ints_as_strings(o.get("t_connections")),
open: o.get("state").and_then(Value::as_str) == Some("OPEN"),
i_max: floats("cm_ub", o.get("cm_ub")),
extras,
});
}
}
fn loads(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let connections = ints_as_strings(o.get("connections"));
let configuration = match o.get("configuration").and_then(Value::as_str) {
Some("DELTA") if connections.len() > 2 => Configuration::Delta,
_ if connections.len() <= 2 => Configuration::SinglePhase,
Some("DELTA") => Configuration::Delta,
_ => Configuration::Wye,
};
let scale = |key: &str| {
floats(key, o.get(key))
.unwrap_or_default()
.iter()
.map(|v| v * 1e3)
.collect::<Vec<_>>()
};
let mut extras = take_extras(
o,
&[
"bus",
"connections",
"configuration",
"pd_nom",
"qd_nom",
"status",
"source_id",
"dispatchable",
"vm_nom",
"model",
],
);
if let Some(kv) = o.get("vm_nom").filter(|v| !v.is_array()) {
extras.insert("kv".into(), kv.clone());
}
if let Some(model) = o.get("model").and_then(Value::as_str) {
let dss_model = match model {
"IMPEDANCE" => 2,
"CURRENT" => 5,
"ZIPV" => 8,
_ => 1,
};
if dss_model != 1 {
extras.insert("model".into(), dss_model.into());
}
}
let v_nom: Vec<f64> = floats("vm_nom", o.get("vm_nom"))
.or_else(|| o.get("vm_nom").map(|v| vec![restore("vm_nom", v)]))
.unwrap_or_default()
.iter()
.map(|v| v * 1e3)
.collect();
let voltage_model = match o.get("model").and_then(Value::as_str) {
Some("IMPEDANCE") => DistLoadVoltageModel::ConstantImpedance { v_nom },
Some("CURRENT") => DistLoadVoltageModel::ConstantCurrent { v_nom },
Some("ZIPV") => DistLoadVoltageModel::Zip {
v_nom,
alpha_z: Vec::new(),
alpha_i: Vec::new(),
alpha_p: Vec::new(),
beta_z: Vec::new(),
beta_i: Vec::new(),
beta_p: Vec::new(),
},
_ => DistLoadVoltageModel::ConstantPower { v_nom },
};
stash_status(
o,
&mut extras,
&format!("load {name}"),
&mut self.diagnostics,
);
self.net.loads_mut().push(DistLoad {
name: name.clone(),
bus: string(o.get("bus")),
terminal_map: connections,
configuration,
p_nom: scale("pd_nom"),
q_nom: scale("qd_nom"),
voltage_model,
extras,
});
}
}
fn generators(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let scale = |key: &str| {
floats(key, o.get(key)).map(|v| v.iter().map(|x| x * 1e3).collect::<Vec<f64>>())
};
let mut extras = take_extras(
o,
&[
"bus",
"connections",
"configuration",
"pg",
"qg",
"pg_lb",
"pg_ub",
"qg_lb",
"qg_ub",
"status",
"source_id",
],
);
stash_status(
o,
&mut extras,
&format!("generator {name}"),
&mut self.diagnostics,
);
self.net.generators_mut().push(DistGenerator {
name: name.clone(),
bus: string(o.get("bus")),
terminal_map: ints_as_strings(o.get("connections")),
configuration: match o.get("configuration").and_then(Value::as_str) {
Some("DELTA") => Configuration::Delta,
_ => Configuration::Wye,
},
p_nom: scale("pg").unwrap_or_default(),
q_nom: scale("qg").unwrap_or_default(),
p_min: scale("pg_lb"),
p_max: scale("pg_ub"),
q_min: scale("qg_lb"),
q_max: scale("qg_ub"),
cost: None,
s_max: None,
i_max: None,
extras,
});
}
}
fn shunts(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let what = format!("shunt {name}");
let g = matrix("gs", o.get("gs"), &what, &mut self.diagnostics).unwrap_or_default();
let b = matrix("bs", o.get("bs"), &what, &mut self.diagnostics).unwrap_or_default();
let mut extras = take_extras(
o,
&["bus", "connections", "gs", "bs", "status", "source_id"],
);
stash_status(
o,
&mut extras,
&format!("shunt {name}"),
&mut self.diagnostics,
);
self.net.shunts_mut().push(DistShunt {
name: name.clone(),
bus: string(o.get("bus")),
terminal_map: ints_as_strings(o.get("connections")),
g,
b,
extras,
});
}
}
fn sources(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let mut extras = take_extras(
o,
&["bus", "connections", "vm", "va", "status", "source_id"],
);
stash_status(
o,
&mut extras,
&format!("voltage source {name}"),
&mut self.diagnostics,
);
self.net.sources_mut().push(VoltageSource {
reference_terminal: None,
name: name.clone(),
bus: string(o.get("bus")),
terminal_map: ints_as_strings(o.get("connections")),
v_magnitude: floats("vm", o.get("vm"))
.unwrap_or_default()
.iter()
.map(|v| v * 1e3)
.collect(),
v_angle: floats("va", o.get("va"))
.unwrap_or_default()
.iter()
.map(|a| a.to_radians())
.collect(),
energy_cost_rate: None,
extras,
});
}
}
fn transformers(&mut self, items: &Map<String, Value>) {
for (name, v) in items {
let Value::Object(o) = v else { continue };
let t = self.transformer(name, o);
self.net.transformers_mut().push(t);
}
}
fn stash_polarity(
&mut self,
name: &str,
o: &Map<String, Value>,
windings: &[DistWinding],
polarity: &[i64],
unrolled: bool,
extras: &mut Extras,
) {
let file_polarity: Vec<i64> = (0..windings.len())
.map(|w| polarity.get(w).copied().unwrap_or(1))
.collect();
if file_polarity == super::write::lag_polarity(windings) {
return;
}
extras.insert(
"pmd_polarity".into(),
o.get("polarity")
.cloned()
.unwrap_or_else(|| file_polarity.clone().into()),
);
if unrolled && let Some(c) = o.get("connections") {
extras.insert("pmd_connections".into(), c.clone());
}
self.diagnostics.push(&C::READ_PMD_RETAINED_SOURCE_ONLY, format!(
"transformer {name}: polarity {file_polarity:?} is not the lag convention; kept in extras (other formats assume lag)"
));
}
#[expect(clippy::too_many_lines)]
fn transformer(&mut self, name: &str, o: &Map<String, Value>) -> DistTransformer {
let mut buses = ints_as_strings(o.get("bus"));
if buses.len() > MAX_MATRIX_DIM {
self.diagnostics.push(
&C::READ_PMD_RECORD_DROPPED,
format!(
"transformer {name}: winding count {} exceeds the supported maximum of \
{MAX_MATRIX_DIM}; extra windings dropped",
buses.len()
),
);
buses.truncate(MAX_MATRIX_DIM);
}
let configs: Vec<DistWindingConn> = o
.get("configuration")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|c| {
if c.as_str() == Some("DELTA") {
DistWindingConn::Delta
} else {
DistWindingConn::Wye
}
})
.collect()
})
.unwrap_or_default();
let polarity: Vec<i64> = o
.get("polarity")
.and_then(Value::as_array)
.map(|a| a.iter().map(|p| p.as_i64().unwrap_or(1)).collect())
.unwrap_or_default();
let rw = floats("rw", o.get("rw")).unwrap_or_default();
let xsc = floats("xsc", o.get("xsc")).unwrap_or_default();
let sm_nom = floats("sm_nom", o.get("sm_nom")).unwrap_or_default();
let vm_nom = floats("vm_nom", o.get("vm_nom")).unwrap_or_default();
let (tm_set, taps_differ) = representative_taps(o.get("tm_set"));
if taps_differ {
self.diagnostics.push(&C::READ_PMD_VALUE_COLLAPSED, format!(
"transformer {name}: per phase taps differ; the winding tap keeps the first phase (full arrays in extras)"
));
}
let (windings, phases, unrolled) = build_windings(
&buses,
&configs,
&polarity,
o,
&WindingNums {
rw: &rw,
xsc: &xsc,
sm_nom: &sm_nom,
vm_nom: &vm_nom,
tm_set: &tm_set,
},
);
if o.get("controls").is_some() {
self.diagnostics.push(
&C::READ_PMD_RETAINED_SOURCE_ONLY,
format!("transformer {name}: regulator controls are not typed; kept in extras"),
);
}
let mut extras = take_extras(
o,
&[
"bus",
"connections",
"configuration",
"polarity",
"rw",
"xsc",
"sm_nom",
"vm_nom",
"tm_set",
"tm_fix",
"tm_lb",
"tm_ub",
"tm_step",
"status",
"source_id",
"noloadloss",
"cmag",
"sm_ub",
],
);
for (field, extra) in [
("noloadloss", "%noloadloss"),
("cmag", "%imag"),
("sm_ub", "emerghkva"),
] {
if let Some(value) = o.get(field) {
if let Some(number) = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0) {
extras.insert(
extra.into(),
serde_json::json!(number * if field == "sm_ub" { 1.0 } else { 100.0 }),
);
} else {
extras.insert(field.into(), value.clone());
self.diagnostics.push(&C::READ_PMD_SOURCE_MALFORMED,
format!("transformer {name}: {field} requires a finite nonnegative number; retained in extras"));
}
}
}
for key in ["tm_set", "tm_lb", "tm_ub", "tm_fix", "tm_step"] {
if let Some(v) = o.get(key) {
extras.insert(format!("pmd_{key}"), v.clone());
}
}
self.stash_polarity(name, o, &windings, &polarity, unrolled, &mut extras);
stash_status(
o,
&mut extras,
&format!("transformer {name}"),
&mut self.diagnostics,
);
DistTransformer {
name: name.to_string(),
windings,
xsc_pct: xsc.iter().map(|x| x * 100.0).collect(),
phases,
extras,
}
}
}