Skip to main content

powerio_dist/pmd/
write.rs

1//! [`MulticonductorNetwork`] into PMD ENGINEERING JSON.
2//!
3//! The output reproduces what PMD's own dss2eng emits for the same network
4//! wherever the model carries the data: terminal integers, `ENABLED`
5//! status, `source_id`, the materialized grounded neutral with zero
6//! `rg`/`xg`, linecode `cm_ub` from the emergency rating, transformer
7//! `tm_*` tap fields, the delta wye barrel roll with `polarity` -1 on the
8//! lagging wye winding, and the voltage source Thevenin matrices computed
9//! from the short circuit data when the source format carried it. The
10//! reader's `pmd_*` stashes (status, settings, files, grounding and switch
11//! impedance, tap arrays, polarity, inline line impedance) win over the
12//! recomputed defaults, so PMD in, PMD out does not alter fields.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use serde_json::{Map, Value, json};
17
18use crate::convert::Conversion;
19use crate::geo::CoordinateSpace;
20use crate::model::{
21    Configuration, DistBus, DistLine, DistLineCode, DistLoadVoltageModel, DistTransformer, Extras,
22    Mat, MulticonductorNetwork, VoltageSource, Winding, WindingConn,
23};
24
25/// Writes the ENGINEERING document.
26///
27/// # Panics
28///
29/// Never in practice: the document is maps, strings, finite numbers, and
30/// nulls, which always serialize.
31pub fn write_pmd_json(net: &MulticonductorNetwork) -> Conversion {
32    let mut w = Writer {
33        warnings: Vec::new(),
34        renamed_terminals: renamed_terminals(net),
35    };
36    let doc = w.document(net);
37    Conversion {
38        text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
39        sidecars: Vec::new(),
40        warnings: w.warnings,
41        diagnostics: Vec::new(),
42    }
43}
44
45/// Upper bound on the conductor counts this writer expands quadratically:
46/// the switch series and shunt matrices and the voltage source Thevenin
47/// matrices are all sized from a terminal map, a linear model array. The
48/// readers cap the same quantity on their way in, but a `MulticonductorNetwork` can
49/// also arrive without those caps (the model JSON C entry point
50/// deserializes one unchecked), and a linear-size model could otherwise
51/// demand O(n²) memory here. No physical element comes near this bound.
52const MAX_DIM: usize = 64;
53
54struct Writer {
55    warnings: Vec<String>,
56    /// One id per terminal name that is not numeric, for the whole network.
57    renamed_terminals: BTreeMap<String, i64>,
58}
59
60/// One id for each terminal name in the network that PMD cannot spell.
61///
62/// PMD requires integer connections. A name such as `n` therefore needs an
63/// id, and that id must be the same everywhere the name appears, or two
64/// elements at one bus stop sharing a conductor. Each name takes the
65/// smallest positive integer that no numeric terminal name already uses, so
66/// the ids stay as small as the model allows: an id far above the conductor
67/// count would drive the `conductor_ids` enumeration to its cap and produce
68/// a document with 64 conductors for a 4 conductor network.
69///
70/// Counting up from the largest numeric name would look simpler, but a
71/// document may name a terminal `9223372036854775807`. That saturates every
72/// id to the same value, and two conductors then merge into one. Taking the
73/// smallest free integer cannot collide and cannot overflow: the search
74/// stops after at most one step per terminal already in the document.
75fn renamed_terminals(net: &MulticonductorNetwork) -> BTreeMap<String, i64> {
76    let mut used = BTreeSet::new();
77    let mut names = BTreeSet::new();
78    for terminal in all_terminal_names(net) {
79        match terminal.parse::<i64>() {
80            Ok(value) => {
81                used.insert(value);
82            }
83            Err(_) => {
84                names.insert(terminal.to_string());
85            }
86        }
87    }
88    let mut out = BTreeMap::new();
89    let mut next = 1i64;
90    for name in names {
91        while used.contains(&next) {
92            next += 1;
93        }
94        used.insert(next);
95        out.insert(name, next);
96        next += 1;
97    }
98    out
99}
100
101impl Writer {
102    /// Terminal names as PMD integer connections. A name that is not numeric
103    /// takes the id [`renamed_terminals`] gave it, so the same name is the
104    /// same conductor everywhere in the document.
105    fn conns(&mut self, map: &[String], what: &str) -> Vec<i64> {
106        map.iter()
107            .map(|t| {
108                if let Ok(value) = t.parse::<i64>() {
109                    return value;
110                }
111                let id = self.renamed_terminals.get(t).copied().unwrap_or(0);
112                self.warnings.push(format!(
113                    "{what}: terminal `{t}` is not numeric; emitted as {id}"
114                ));
115                id
116            })
117            .collect()
118    }
119}
120
121fn all_terminal_names(net: &MulticonductorNetwork) -> impl Iterator<Item = &str> {
122    let maps = net
123        .lines
124        .iter()
125        .flat_map(|l| [&l.terminal_map_from, &l.terminal_map_to])
126        .chain(
127            net.switches
128                .iter()
129                .flat_map(|s| [&s.terminal_map_from, &s.terminal_map_to]),
130        )
131        .chain(net.loads.iter().map(|l| &l.terminal_map))
132        .chain(net.generators.iter().map(|g| &g.terminal_map))
133        .chain(net.capacitors.iter().map(|c| &c.terminal_map))
134        .chain(net.shunts.iter().map(|s| &s.terminal_map))
135        .chain(net.sources.iter().map(|s| &s.terminal_map))
136        .chain(net.ibrs.iter().map(|i| &i.terminal_map))
137        .chain(
138            net.transformers
139                .iter()
140                .flat_map(|t| t.windings.iter().map(|w| &w.terminal_map)),
141        );
142    net.buses
143        .iter()
144        .flat_map(|b| [&b.terminals, &b.grounded])
145        .chain(maps)
146        .flat_map(|m| m.iter().map(String::as_str))
147}
148
149/// A matrix as PMD serializes it: array of columns (`hcat` rebuilds it).
150/// Rows shorter than the row count read as 0, so a degenerate model matrix
151/// emits a square block instead of panicking.
152fn matrix(m: &Mat) -> Value {
153    let n = m.len();
154    let cols: Vec<Value> = (0..n)
155        .map(|j| {
156            Value::Array(
157                (0..n)
158                    .map(|i| json!(m[i].get(j).copied().unwrap_or(0.0)))
159                    .collect(),
160            )
161        })
162        .collect();
163    Value::Array(cols)
164}
165
166fn zero_matrix(n: usize) -> Mat {
167    vec![vec![0.0; n]; n]
168}
169
170/// A shunt whose stashed `conn` marks it a delta (line to line) bank.
171fn shunt_is_delta(extras: &Extras) -> bool {
172    extras
173        .get("conn")
174        .and_then(|v| v.as_str())
175        .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
176}
177
178fn scale(m: &Mat, k: f64) -> Mat {
179    m.iter()
180        .map(|row| row.iter().map(|v| v * k).collect())
181        .collect()
182}
183
184impl Writer {
185    fn warn(&mut self, msg: impl Into<String>) {
186        self.warnings.push(msg.into());
187    }
188
189    /// The dimension a matrix is materialized at, clamped to [`MAX_DIM`]
190    /// with a diagnostic naming the element.
191    fn bounded_dim(&mut self, n: usize, what: &str) -> usize {
192        if n > MAX_DIM {
193            self.warn(format!(
194                "{what}: {n} conductors exceed the supported maximum of \
195                 {MAX_DIM}; matrices emitted at {MAX_DIM}"
196            ));
197            MAX_DIM
198        } else {
199            n
200        }
201    }
202
203    /// Reports extras the ENGINEERING model has no field for. `consumed`
204    /// names keys a field already represents; `pmd_*` bookkeeping and the
205    /// BMOPF subtype marker pass silently.
206    fn extras_dropped(&mut self, extras: &crate::model::Extras, consumed: &[&str], what: &str) {
207        for key in extras.keys() {
208            if consumed.contains(&key.as_str()) || key.starts_with("pmd_") || key == "bmopf_subtype"
209            {
210                continue;
211            }
212            self.warn(format!(
213                "{what}: `{key}` has no ENGINEERING field; dropped from the output"
214            ));
215        }
216    }
217
218    fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
219        extras.get(key).and_then(|v| {
220            v.as_f64()
221                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
222        })
223    }
224
225    /// The element status: the reader's stash when the source carried a non
226    /// ENABLED status, `ENABLED` otherwise.
227    fn status(extras: &Extras) -> Value {
228        extras
229            .get("pmd_status")
230            .cloned()
231            .unwrap_or_else(|| json!("ENABLED"))
232    }
233
234    fn bus_coordinates(
235        &mut self,
236        o: &mut Map<String, Value>,
237        b: &DistBus,
238        net: &MulticonductorNetwork,
239    ) {
240        if let Some(location) = b.location {
241            if !matches!(
242                net.geo.as_ref().map(|geo| &geo.space),
243                Some(CoordinateSpace::Geographic { .. })
244            ) {
245                self.warnings.push(format!(
246                    "bus {}: non-geographic or undeclared location is not emitted to PMD lon/lat",
247                    b.id
248                ));
249                return;
250            }
251            if location.x.is_finite() && location.y.is_finite() {
252                o.insert("lon".into(), json!(location.x));
253                o.insert("lat".into(), json!(location.y));
254            } else {
255                self.warnings.push(format!(
256                    "bus {}: nonfinite location is not emitted to PMD JSON",
257                    b.id
258                ));
259            }
260            return;
261        }
262        if let Some(x) = Self::extras_f64(&b.extras, "x") {
263            o.insert("lon".into(), json!(x));
264        }
265        if let Some(y) = Self::extras_f64(&b.extras, "y") {
266            o.insert("lat".into(), json!(y));
267        }
268    }
269
270    fn document(&mut self, net: &MulticonductorNetwork) -> Value {
271        let mut doc = Map::new();
272        doc.insert("data_model".into(), json!("ENGINEERING"));
273        doc.insert(
274            "name".into(),
275            json!(net.name.clone().unwrap_or_default().to_lowercase()),
276        );
277        doc.insert(
278            "files".into(),
279            net.extras
280                .get("pmd_files")
281                .cloned()
282                .unwrap_or_else(|| json!([])),
283        );
284
285        // The reader's stash wins; synthesis covers dss/bmopf sourced
286        // models.
287        let settings = net
288            .extras
289            .get("pmd_settings")
290            .cloned()
291            .unwrap_or_else(|| synthesized_settings(net));
292        doc.insert("settings".into(), settings);
293
294        // `conductor_ids` enumerates 1..=max, so its length is the numeric
295        // value of a terminal name rather than a count of anything. A single
296        // terminal named `4444444444444444` would ask for petabytes, so the
297        // enumeration is clamped like every other model driven dimension.
298        // A terminal name that is not numeric holds an id from
299        // `renamed_terminals`. Those ids are the smallest free positive
300        // integers, so a numeric name does not bound them and the enumeration
301        // must include them explicitly. Without them the document lists fewer
302        // conductors than its own `connections` arrays use, and a read back
303        // and rewrite then produces a longer list.
304        let max_conductor = net
305            .buses
306            .iter()
307            .flat_map(|b| &b.terminals)
308            .filter_map(|t| t.parse::<i64>().ok())
309            .chain(self.renamed_terminals.values().copied())
310            .max()
311            .unwrap_or(4);
312        let max_dim = i64::try_from(MAX_DIM).expect("MAX_DIM is small");
313        if max_conductor > max_dim {
314            self.warn(format!(
315                "terminal {max_conductor} exceeds the supported maximum conductor id \
316                 of {MAX_DIM}; conductor_ids enumerated to {MAX_DIM}"
317            ));
318        }
319        let max_conductor = max_conductor.clamp(4, max_dim);
320        doc.insert(
321            "conductor_ids".into(),
322            Value::Array((1..=max_conductor).map(|i| json!(i)).collect()),
323        );
324
325        let mut buses = Map::new();
326        for b in &net.buses {
327            let mut o = Map::new();
328            o.insert(
329                "terminals".into(),
330                json!(self.conns(&b.terminals, &format!("bus {}", b.id))),
331            );
332            let grounded = self.conns(&b.grounded, &format!("bus {}", b.id));
333            // Nonzero grounding impedance rides in extras (the reader's
334            // stash); zero vectors are the materialized default.
335            for key in ["rg", "xg"] {
336                let v = b
337                    .extras
338                    .get(key)
339                    .cloned()
340                    .unwrap_or_else(|| json!(vec![0.0; grounded.len()]));
341                o.insert(key.into(), v);
342            }
343            o.insert("grounded".into(), json!(grounded));
344            o.insert("status".into(), Self::status(&b.extras));
345            self.bus_coordinates(&mut o, b, net);
346            // Voltage bound families have no ENGINEERING fields in volts;
347            // they drop loudly (PMD bounds are per unit).
348            for (key, present) in [
349                ("v_min", b.v_min.is_some()),
350                ("v_max", b.v_max.is_some()),
351                ("vpn_min", b.vpn_min.is_some()),
352                ("vpn_max", b.vpn_max.is_some()),
353                ("vpp_min", b.vpp_min.is_some()),
354                ("vpp_max", b.vpp_max.is_some()),
355                ("vpos_min", b.vpos_min.is_some()),
356                ("vpos_max", b.vpos_max.is_some()),
357                ("vneg_max", b.vneg_max.is_some()),
358                ("vzero_max", b.vzero_max.is_some()),
359                ("vn_max", b.vn_max.is_some()),
360            ] {
361                if present {
362                    self.warn(format!(
363                        "bus {}: `{key}` volt bounds have no ENGINEERING field; dropped",
364                        b.id
365                    ));
366                }
367            }
368            buses.insert(b.id.to_lowercase(), Value::Object(o));
369        }
370        doc.insert("bus".into(), Value::Object(buses));
371
372        self.linecodes(net, &mut doc);
373        self.branches(net, &mut doc);
374        self.injections(net, &mut doc);
375        self.transformers(net, &mut doc);
376
377        for u in &net.untyped {
378            self.warn(format!(
379                "{} {}: class is not converted to ENGINEERING; dropped from the output",
380                u.class, u.name
381            ));
382        }
383        Value::Object(doc)
384    }
385
386    fn linecodes(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
387        // Linecodes the reader materialized from inline line impedance
388        // re-inline on the line; they are skipped here unless a line
389        // without the marker also references them.
390        let inlined = inlined_codes(net);
391        let mut codes = Map::new();
392        for c in &net.linecodes {
393            if inlined.contains(&c.name.to_lowercase()) {
394                continue;
395            }
396            let mut o = Map::new();
397            insert_impedance_matrices(&mut o, c, net.base_frequency);
398            if let Some(i_max) = &c.i_max {
399                o.insert("cm_ub".into(), json!(i_max));
400            }
401            if let Some(s_max) = &c.s_max {
402                o.insert("sm_ub".into(), json!(s_max));
403            }
404            if c.source.is_some() {
405                self.warn(format!(
406                    "linecode {}: matrix provenance `source` has no ENGINEERING field; dropped",
407                    c.name
408                ));
409            }
410            codes.insert(c.name.to_lowercase(), Value::Object(o));
411        }
412        if !codes.is_empty() {
413            doc.insert("linecode".into(), Value::Object(codes));
414        }
415    }
416
417    /// Line-level ratings (BMOPF `i_max`/`s_max`) map onto the ENGINEERING
418    /// line's own `cm_ub`/`sm_ub` slots.
419    fn line_ratings(o: &mut Map<String, Value>, l: &DistLine) {
420        if let Some(i_max) = &l.i_max {
421            o.insert("cm_ub".into(), json!(i_max));
422        }
423        if let Some(s_max) = &l.s_max {
424            o.insert("sm_ub".into(), json!(s_max));
425        }
426    }
427
428    fn branches(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
429        if !net.lines.is_empty() {
430            let mut lines = Map::new();
431            for l in &net.lines {
432                let mut o = Map::new();
433                o.insert("f_bus".into(), json!(l.bus_from.to_lowercase()));
434                o.insert("t_bus".into(), json!(l.bus_to.to_lowercase()));
435                let what = format!("line {}", l.name);
436                o.insert(
437                    "f_connections".into(),
438                    json!(self.conns(&l.terminal_map_from, &what)),
439                );
440                o.insert(
441                    "t_connections".into(),
442                    json!(self.conns(&l.terminal_map_to, &what)),
443                );
444                o.insert("length".into(), json!(l.length));
445                // A line the reader materialized a linecode for re-inlines
446                // its impedance, the dss2eng shape for rmatrix defined
447                // lines: matrices on the line, no linecode key.
448                let inline = l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true);
449                match net.linecode(&l.linecode) {
450                    Some(c) if inline => {
451                        insert_impedance_matrices(&mut o, c, net.base_frequency);
452                        if let Some(i_max) = &c.i_max {
453                            o.insert("cm_ub".into(), json!(i_max));
454                        }
455                        if let Some(s_max) = &c.s_max {
456                            o.insert("sm_ub".into(), json!(s_max));
457                        }
458                    }
459                    _ => {
460                        if inline {
461                            self.warn(format!(
462                                "{what}: linecode `{}` is missing; emitted the reference instead of inline impedance",
463                                l.linecode
464                            ));
465                        }
466                        o.insert("linecode".into(), json!(l.linecode.to_lowercase()));
467                    }
468                }
469                Self::line_ratings(&mut o, l);
470                o.insert("status".into(), Self::status(&l.extras));
471                o.insert(
472                    "source_id".into(),
473                    json!(format!("line.{}", l.name.to_lowercase())),
474                );
475                self.extras_dropped(&l.extras, &["units"], &what);
476                lines.insert(l.name.to_lowercase(), Value::Object(o));
477            }
478            doc.insert("line".into(), Value::Object(lines));
479        }
480
481        if !net.switches.is_empty() {
482            let mut switches = Map::new();
483            for s in &net.switches {
484                let mut o = Map::new();
485                let what = format!("switch {}", s.name);
486                let n = self.bounded_dim(s.terminal_map_from.len(), &what);
487                o.insert("f_bus".into(), json!(s.bus_from.to_lowercase()));
488                o.insert("t_bus".into(), json!(s.bus_to.to_lowercase()));
489                o.insert(
490                    "f_connections".into(),
491                    json!(self.conns(&s.terminal_map_from, &what)),
492                );
493                o.insert(
494                    "t_connections".into(),
495                    json!(self.conns(&s.terminal_map_to, &what)),
496                );
497                // The reader's stash carries the source's series matrices;
498                // otherwise PMD models a dss switch as a tiny series
499                // resistance, 1e-4 ohm/m over the forced 0.001 m length
500                // (the product form keeps the value bit identical).
501                let rs = s.extras.get("pmd_rs").cloned().unwrap_or_else(|| {
502                    let mut rs = zero_matrix(n);
503                    for (i, row) in rs.iter_mut().enumerate() {
504                        row[i] = 1e-4 * 0.001;
505                    }
506                    matrix(&rs)
507                });
508                o.insert("rs".into(), rs);
509                let xs = s
510                    .extras
511                    .get("pmd_xs")
512                    .cloned()
513                    .unwrap_or_else(|| matrix(&zero_matrix(n)));
514                o.insert("xs".into(), xs);
515                o.insert("g_fr".into(), matrix(&zero_matrix(n)));
516                o.insert("g_to".into(), matrix(&zero_matrix(n)));
517                o.insert("b_fr".into(), matrix(&zero_matrix(n)));
518                o.insert("b_to".into(), matrix(&zero_matrix(n)));
519                if let Some(i_max) = &s.i_max {
520                    o.insert("cm_ub".into(), json!(i_max));
521                }
522                o.insert(
523                    "state".into(),
524                    json!(if s.open { "OPEN" } else { "CLOSED" }),
525                );
526                o.insert("dispatchable".into(), json!("YES"));
527                o.insert("status".into(), Self::status(&s.extras));
528                o.insert(
529                    "source_id".into(),
530                    json!(format!("line.{}", s.name.to_lowercase())),
531                );
532                self.extras_dropped(&s.extras, &[], &what);
533                switches.insert(s.name.to_lowercase(), Value::Object(o));
534            }
535            doc.insert("switch".into(), Value::Object(switches));
536        }
537    }
538
539    fn loads(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
540        if !net.loads.is_empty() {
541            let mut loads = Map::new();
542            for l in &net.loads {
543                let mut o = Map::new();
544                let what = format!("load {}", l.name);
545                let connections = self.conns(&l.terminal_map, &what);
546                // PMD types a two terminal load WYE when the return is the
547                // bus's grounded neutral and DELTA otherwise.
548                let configuration = match l.configuration {
549                    Configuration::Delta => "DELTA",
550                    Configuration::Wye => "WYE",
551                    Configuration::SinglePhase => {
552                        let grounded_return = l
553                            .terminal_map
554                            .last()
555                            .zip(net.bus(&l.bus))
556                            .is_some_and(|(t, b)| b.grounded.contains(t));
557                        if grounded_return { "WYE" } else { "DELTA" }
558                    }
559                };
560                o.insert("configuration".into(), json!(configuration));
561                o.insert("connections".into(), json!(connections));
562                o.insert(
563                    "pd_nom".into(),
564                    json!(l.p_nom.iter().map(|p| p / 1e3).collect::<Vec<_>>()),
565                );
566                o.insert(
567                    "qd_nom".into(),
568                    json!(l.q_nom.iter().map(|q| q / 1e3).collect::<Vec<_>>()),
569                );
570                o.insert("bus".into(), json!(l.bus.to_lowercase()));
571                let mut insert_vm_nom = |v_nom: &[f64]| {
572                    if let Some(value) = source_vm_nom(&l.extras, v_nom) {
573                        o.insert("vm_nom".into(), value);
574                    } else if !v_nom.is_empty() {
575                        let value = if v_nom.len() == 1 {
576                            json!(v_nom[0] / 1e3)
577                        } else {
578                            json!(v_nom.iter().map(|v| v / 1e3).collect::<Vec<_>>())
579                        };
580                        o.insert("vm_nom".into(), value);
581                    } else if let Some(kv) = Self::extras_f64(&l.extras, "kv") {
582                        o.insert("vm_nom".into(), json!(kv));
583                    }
584                };
585                let model = match &l.voltage_model {
586                    DistLoadVoltageModel::ConstantImpedance { v_nom } => {
587                        insert_vm_nom(v_nom);
588                        "IMPEDANCE"
589                    }
590                    DistLoadVoltageModel::ConstantCurrent { v_nom } => {
591                        insert_vm_nom(v_nom);
592                        "CURRENT"
593                    }
594                    DistLoadVoltageModel::Zip { v_nom, .. } => {
595                        insert_vm_nom(v_nom);
596                        "ZIPV"
597                    }
598                    DistLoadVoltageModel::Exponential { v_nom, .. } => {
599                        insert_vm_nom(v_nom);
600                        self.warn(format!(
601                            "{what}: exponential load model has no ENGINEERING field; emitted POWER"
602                        ));
603                        "POWER"
604                    }
605                    DistLoadVoltageModel::ConstantPower { v_nom } => {
606                        insert_vm_nom(v_nom);
607                        "POWER"
608                    }
609                };
610                o.insert("model".into(), json!(model));
611                o.insert("dispatchable".into(), json!("NO"));
612                o.insert("status".into(), Self::status(&l.extras));
613                o.insert(
614                    "source_id".into(),
615                    json!(format!("load.{}", l.name.to_lowercase())),
616                );
617                self.extras_dropped(&l.extras, &["kv", "model", "pf"], &what);
618                loads.insert(l.name.to_lowercase(), Value::Object(o));
619            }
620            doc.insert("load".into(), Value::Object(loads));
621        }
622    }
623
624    fn generators(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
625        if !net.generators.is_empty() {
626            let mut gens = Map::new();
627            for g in &net.generators {
628                let mut o = Map::new();
629                let what = format!("generator {}", g.name);
630                o.insert("bus".into(), json!(g.bus.to_lowercase()));
631                o.insert(
632                    "connections".into(),
633                    json!(self.conns(&g.terminal_map, &what)),
634                );
635                o.insert(
636                    "configuration".into(),
637                    json!(match g.configuration {
638                        Configuration::Delta => "DELTA",
639                        _ => "WYE",
640                    }),
641                );
642                let kw = |w: &[f64]| w.iter().map(|v| v / 1e3).collect::<Vec<_>>();
643                o.insert("pg".into(), json!(kw(&g.p_nom)));
644                o.insert("qg".into(), json!(kw(&g.q_nom)));
645                if let Some(b) = &g.q_min {
646                    o.insert("qg_lb".into(), json!(kw(b)));
647                }
648                if let Some(b) = &g.q_max {
649                    o.insert("qg_ub".into(), json!(kw(b)));
650                }
651                if let Some(b) = &g.p_min {
652                    o.insert("pg_lb".into(), json!(kw(b)));
653                }
654                if let Some(b) = &g.p_max {
655                    o.insert("pg_ub".into(), json!(kw(b)));
656                }
657                if g.cost.is_some() {
658                    self.warn(format!(
659                        "{what}: generation cost has no ENGINEERING field; dropped"
660                    ));
661                }
662                // The ENGINEERING generator carries kVA-scale sm_ub/cm_ub;
663                // that mapping is a #266 decision, so the drop stays loud.
664                for (key, present) in [("s_max", g.s_max.is_some()), ("i_max", g.i_max.is_some())] {
665                    if present {
666                        self.warn(format!(
667                            "{what}: `{key}` has no ENGINEERING generator mapping yet; dropped"
668                        ));
669                    }
670                }
671                o.insert("control_mode".into(), json!("FREQUENCYDROOP"));
672                o.insert("status".into(), Self::status(&g.extras));
673                o.insert(
674                    "source_id".into(),
675                    json!(format!("generator.{}", g.name.to_lowercase())),
676                );
677                self.extras_dropped(&g.extras, &["kv"], &what);
678                gens.insert(g.name.to_lowercase(), Value::Object(o));
679            }
680            doc.insert("generator".into(), Value::Object(gens));
681        }
682    }
683
684    fn injections(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
685        self.loads(net, doc);
686        self.generators(net, doc);
687        // Typed BMOPF capacitor banks have no ENGINEERING conversion yet.
688        for c in &net.capacitors {
689            self.warn(format!(
690                "capacitor {}: rated capacitor banks are not converted to ENGINEERING JSON; dropped",
691                c.name
692            ));
693        }
694        if !net.shunts.is_empty() {
695            let mut shunts = Map::new();
696            for s in &net.shunts {
697                let mut o = Map::new();
698                let what = format!("shunt {}", s.name);
699                o.insert("bus".into(), json!(s.bus.to_lowercase()));
700                o.insert(
701                    "connections".into(),
702                    json!(self.conns(&s.terminal_map, &what)),
703                );
704                o.insert("gs".into(), matrix(&s.g));
705                o.insert("bs".into(), matrix(&s.b));
706                // A delta bank carries a `conn` marker and an off diagonal B
707                // matrix; emitting it as WYE would describe a line to line
708                // admittance as line to ground.
709                let configuration = if shunt_is_delta(&s.extras) {
710                    "DELTA"
711                } else {
712                    "WYE"
713                };
714                o.insert("configuration".into(), json!(configuration));
715                o.insert("model".into(), json!("CAPACITOR"));
716                o.insert("dispatchable".into(), json!("NO"));
717                o.insert("status".into(), Self::status(&s.extras));
718                o.insert(
719                    "source_id".into(),
720                    json!(format!("capacitor.{}", s.name.to_lowercase())),
721                );
722                self.extras_dropped(&s.extras, &["kv", "kvar", "conn"], &what);
723                shunts.insert(s.name.to_lowercase(), Value::Object(o));
724            }
725            doc.insert("shunt".into(), Value::Object(shunts));
726        }
727
728        let mut sources = Map::new();
729        for vs in &net.sources {
730            sources.insert(vs.name.to_lowercase(), self.voltage_source(vs));
731        }
732        doc.insert("voltage_source".into(), Value::Object(sources));
733    }
734
735    fn voltage_source(&mut self, vs: &VoltageSource) -> Value {
736        let mut o = Map::new();
737        let what = format!("voltage source {}", vs.name);
738        let connections = self.conns(&vs.terminal_map, &what);
739        let n = self.bounded_dim(connections.len(), &what);
740        o.insert("bus".into(), json!(vs.bus.to_lowercase()));
741        o.insert("connections".into(), json!(connections));
742        o.insert("configuration".into(), json!("WYE"));
743        o.insert(
744            "vm".into(),
745            json!(vs.v_magnitude.iter().map(|v| v / 1e3).collect::<Vec<_>>()),
746        );
747        o.insert(
748            "va".into(),
749            json!(
750                vs.v_angle
751                    .iter()
752                    .map(|a| a.to_degrees())
753                    .collect::<Vec<_>>()
754            ),
755        );
756        // The Thevenin matrices: verbatim when the source carried them
757        // (an ENGINEERING round trip), recomputed with the engine's
758        // formulas from short circuit data otherwise.
759        if let (Some(rs), Some(xs)) = (vs.extras.get("rs"), vs.extras.get("xs")) {
760            o.insert("rs".into(), rs.clone());
761            o.insert("xs".into(), xs.clone());
762        } else {
763            let (rs, xs) = thevenin(vs, n);
764            if rs.iter().flatten().all(|&v| v == 0.0) {
765                self.warn(format!(
766                    "{what}: no short circuit data; emitted an ideal source (zero rs/xs)"
767                ));
768            }
769            o.insert("rs".into(), matrix(&rs));
770            o.insert("xs".into(), matrix(&xs));
771        }
772        o.insert("status".into(), Self::status(&vs.extras));
773        o.insert(
774            "source_id".into(),
775            json!(format!("vsource.{}", vs.name.to_lowercase())),
776        );
777        // The short circuit form (basekv/pu/angle/MVAsc/X-R ratios) is
778        // represented by vm/va and the Thevenin matrices.
779        self.extras_dropped(
780            &vs.extras,
781            &[
782                "basekv",
783                "basemva",
784                "pu",
785                "angle",
786                "mvasc1",
787                "mvasc3",
788                "x1r1",
789                "x0r0",
790                "rs",
791                "xs",
792                "isc1",
793                "isc3",
794                "configuration",
795            ],
796            &what,
797        );
798        Value::Object(o)
799    }
800
801    fn transformers(&mut self, net: &MulticonductorNetwork, doc: &mut Map<String, Value>) {
802        if net.transformers.is_empty() {
803            return;
804        }
805        let mut out = Map::new();
806        for t in &net.transformers {
807            out.insert(t.name.to_lowercase(), self.transformer(t));
808        }
809        doc.insert("transformer".into(), Value::Object(out));
810    }
811
812    fn transformer(&mut self, t: &DistTransformer) -> Value {
813        let mut o = Map::new();
814        let what = format!("transformer {}", t.name);
815        let phases = t.phases;
816
817        // The reader's stash carries a source polarity/connections pair the
818        // lag convention does not reproduce (euro/lead, reversed windings);
819        // emit it verbatim. Otherwise apply the ANSI lag convention the
820        // reference dss2eng uses: barrel roll the wye phase conductors
821        // under a delta primary and reverse the winding polarity.
822        let stashed = t.extras.contains_key("pmd_polarity");
823        let mut buses = Vec::new();
824        let mut connections: Vec<Value> = Vec::new();
825        for (w_idx, w) in t.windings.iter().enumerate() {
826            buses.push(json!(w.bus.to_lowercase()));
827            let mut c = self.conns(&w.terminal_map, &what);
828            if !stashed
829                && w_idx > 0
830                && t.windings[0].conn == WindingConn::Delta
831                && w.conn == WindingConn::Wye
832                && c.len() > 1
833            {
834                let phases_part = c.len() - 1;
835                c[..phases_part].rotate_left(1);
836            }
837            connections.push(json!(c));
838        }
839        o.insert("bus".into(), Value::Array(buses));
840        o.insert(
841            "connections".into(),
842            t.extras
843                .get("pmd_connections")
844                .cloned()
845                .unwrap_or(Value::Array(connections)),
846        );
847        o.insert(
848            "polarity".into(),
849            t.extras
850                .get("pmd_polarity")
851                .cloned()
852                .unwrap_or_else(|| json!(lag_polarity(&t.windings))),
853        );
854        o.insert(
855            "configuration".into(),
856            Value::Array(
857                t.windings
858                    .iter()
859                    .map(|w| {
860                        json!(match w.conn {
861                            WindingConn::Wye => "WYE",
862                            WindingConn::Delta => "DELTA",
863                        })
864                    })
865                    .collect(),
866            ),
867        );
868        o.insert(
869            "rw".into(),
870            json!(
871                t.windings
872                    .iter()
873                    .map(|w| w.r_pct / 100.0)
874                    .collect::<Vec<_>>()
875            ),
876        );
877        o.insert(
878            "xsc".into(),
879            json!(t.xsc_pct.iter().map(|x| x / 100.0).collect::<Vec<_>>()),
880        );
881        o.insert(
882            "sm_nom".into(),
883            json!(
884                t.windings
885                    .iter()
886                    .map(|w| w.s_rating / 1e3)
887                    .collect::<Vec<_>>()
888            ),
889        );
890        o.insert(
891            "vm_nom".into(),
892            json!(t.windings.iter().map(|w| w.v_ref / 1e3).collect::<Vec<_>>()),
893        );
894        // A transformer with no windings is degenerate but reachable from
895        // untrusted input (a PMD or BMOPF document with the winding array
896        // absent), so derive the emergency rating default from the first
897        // winding only when one exists rather than indexing unconditionally.
898        let sm_ub = Self::extras_f64(&t.extras, "emerghkva")
899            .unwrap_or_else(|| t.windings.first().map_or(0.0, |w| w.s_rating / 1e3 * 1.5));
900        o.insert("sm_ub".into(), json!(sm_ub));
901        insert_tap_fields(&mut o, t, phases);
902        if let Some(controls) = t.extras.get("controls") {
903            o.insert("controls".into(), controls.clone());
904        }
905        let noloadloss = Self::extras_f64(&t.extras, "%noloadloss").unwrap_or(0.0) / 100.0;
906        let cmag = Self::extras_f64(&t.extras, "%imag").unwrap_or(0.0) / 100.0;
907        o.insert("noloadloss".into(), json!(noloadloss));
908        o.insert("cmag".into(), json!(cmag));
909        o.insert("status".into(), Self::status(&t.extras));
910        o.insert(
911            "source_id".into(),
912            json!(format!("transformer.{}", t.name.to_lowercase())),
913        );
914        self.extras_dropped(
915            &t.extras,
916            &["controls", "%loadloss", "%noloadloss", "%imag", "emerghkva"],
917            &what,
918        );
919        Value::Object(o)
920    }
921}
922
923/// The per winding per phase tap arrays. The reader's `pmd_tm_*` stashes
924/// win (per phase taps, custom bounds, regulator fix flags); the defaults
925/// for the rest are the engine's bounds (0.9..1.1) and step (1/32).
926fn insert_tap_fields(o: &mut Map<String, Value>, t: &DistTransformer, phases: usize) {
927    let nw = t.windings.len();
928    let mut insert = |key: &str, default: fn(&DistTransformer, usize, usize) -> Value| {
929        let v = t
930            .extras
931            .get(&format!("pmd_{key}"))
932            .cloned()
933            .unwrap_or_else(|| default(t, nw, phases));
934        o.insert(key.into(), v);
935    };
936    insert("tm_set", |t, _, phases| {
937        Value::Array(
938            t.windings
939                .iter()
940                .map(|w| json!(vec![w.tap; phases]))
941                .collect(),
942        )
943    });
944    insert("tm_fix", |_, nw, phases| {
945        Value::Array((0..nw).map(|_| json!(vec![true; phases])).collect())
946    });
947    insert("tm_lb", |_, nw, phases| {
948        Value::Array((0..nw).map(|_| json!(vec![0.9; phases])).collect())
949    });
950    insert("tm_ub", |_, nw, phases| {
951        Value::Array((0..nw).map(|_| json!(vec![1.1; phases])).collect())
952    });
953    insert("tm_step", |_, nw, phases| {
954        Value::Array((0..nw).map(|_| json!(vec![1.0 / 32.0; phases])).collect())
955    });
956}
957
958/// The ENGINEERING settings for a model without the reader's stash (dss or
959/// bmopf sourced), following the dss2eng conventions: the per bus vbase is
960/// the source's nominal line to neutral kV without the pu factor folded
961/// in, and sbase is basemva in kVA (default 100 MVA).
962fn synthesized_settings(net: &MulticonductorNetwork) -> Value {
963    let mut settings = Map::new();
964    settings.insert("base_frequency".into(), json!(net.base_frequency));
965    settings.insert("power_scale_factor".into(), json!(1000.0));
966    settings.insert("voltage_scale_factor".into(), json!(1000.0));
967    let sbase = net
968        .sources
969        .first()
970        .and_then(|vs| Writer::extras_f64(&vs.extras, "basemva"))
971        .map_or(100_000.0, |mva| mva * 1e3);
972    settings.insert("sbase_default".into(), json!(sbase));
973    let mut vbases = Map::new();
974    for vs in &net.sources {
975        let phases = count_phases(vs).max(1) as f64;
976        let vln_kv = Writer::extras_f64(&vs.extras, "basekv").map_or_else(
977            || {
978                let pu = Writer::extras_f64(&vs.extras, "pu").unwrap_or(1.0);
979                vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 / pu
980            },
981            |kv| kv / phases.sqrt(),
982        );
983        vbases.insert(vs.bus.to_lowercase(), json!(vln_kv));
984    }
985    settings.insert("vbases_default".into(), Value::Object(vbases));
986    Value::Object(settings)
987}
988
989/// The polarity vector the ANSI lag convention produces for these windings:
990/// -1 with a barrel roll on each wye winding under a delta primary, -1 on
991/// the reversed second half of a center tap secondary, 1 elsewhere. The
992/// reader compares the source against this to decide whether the file's
993/// polarity needs an extras stash.
994pub(super) fn lag_polarity(windings: &[Winding]) -> Vec<i64> {
995    let nw = windings.len();
996    let mut polarity = vec![1i64; nw];
997    for (w_idx, w) in windings.iter().enumerate().skip(1) {
998        if windings[0].conn == WindingConn::Delta
999            && w.conn == WindingConn::Wye
1000            && w.terminal_map.len() > 1
1001        {
1002            polarity[w_idx] = -1;
1003        }
1004        // Center tap: the second half winding is reversed.
1005        if w_idx == 2 && nw == 3 && windings[1].terminal_map.last() == w.terminal_map.first() {
1006            polarity[w_idx] = -1;
1007        }
1008    }
1009    polarity
1010}
1011
1012/// Names (lowercased) of linecodes that re-inline on their lines: every
1013/// referencing line carries the reader's `pmd_inline` marker.
1014fn inlined_codes(net: &MulticonductorNetwork) -> BTreeSet<String> {
1015    let mut inlined = BTreeSet::new();
1016    for c in &net.linecodes {
1017        let mut refs = net
1018            .lines
1019            .iter()
1020            .filter(|l| l.linecode.eq_ignore_ascii_case(&c.name))
1021            .peekable();
1022        if refs.peek().is_some()
1023            && refs.all(|l| l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true))
1024        {
1025            inlined.insert(c.name.to_lowercase());
1026        }
1027    }
1028    inlined
1029}
1030
1031/// The six ENGINEERING impedance matrices of a linecode, emitted onto a
1032/// `linecode` entry or re-inlined onto a line. The b_fr/b_to numbers are
1033/// the dss cmatrix halves in nanofarads per meter (the susceptance follows
1034/// as 2 pi f C); the model holds true siemens per meter, so divide the
1035/// omega back out — or emit the reader's raw stash, which is bit exact.
1036fn insert_impedance_matrices(o: &mut Map<String, Value>, c: &DistLineCode, base_frequency: f64) {
1037    o.insert("rs".into(), matrix(&c.r_series));
1038    o.insert("xs".into(), matrix(&c.x_series));
1039    o.insert("g_fr".into(), matrix(&c.g_from));
1040    o.insert("g_to".into(), matrix(&c.g_to));
1041    if let (Some(fr), Some(to)) = (c.extras.get("pmd_b_fr"), c.extras.get("pmd_b_to")) {
1042        o.insert("b_fr".into(), fr.clone());
1043        o.insert("b_to".into(), to.clone());
1044    } else {
1045        let to_nf = 1.0 / (std::f64::consts::TAU * base_frequency * 1e-9);
1046        o.insert("b_fr".into(), matrix(&scale(&c.b_from, to_nf)));
1047        o.insert("b_to".into(), matrix(&scale(&c.b_to, to_nf)));
1048    }
1049}
1050
1051/// The engine's Thevenin computation from MVAsc3/MVAsc1 and the X/R ratios
1052/// (the same math the reference dss2eng inherits): sequence impedances from
1053/// the short circuit levels, then self/mutual phase values filled over all
1054/// conductors including the neutral.
1055fn thevenin(vs: &VoltageSource, n_cond: usize) -> (Mat, Mat) {
1056    let get = |key: &str| Writer::extras_f64(&vs.extras, key);
1057    let basekv = get("basekv").unwrap_or_else(|| {
1058        // Reconstruct from the magnitude when basekv was defaulted.
1059        vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 * (count_phases(vs) as f64).sqrt()
1060    });
1061    let phases = count_phases(vs);
1062    if basekv <= 0.0 || phases == 0 {
1063        return (zero_matrix(n_cond), zero_matrix(n_cond));
1064    }
1065    let mvasc3 = get("mvasc3").unwrap_or(2000.0);
1066    let mvasc1 = get("mvasc1").unwrap_or(2100.0);
1067    let x1r1 = get("x1r1").unwrap_or(4.0);
1068    let x0r0 = get("x0r0").unwrap_or(3.0);
1069    let factor = if phases == 1 { 1.0 } else { 3f64.sqrt() };
1070
1071    let isc1 = mvasc1 * 1e3 / (basekv * factor);
1072    let x1 = basekv * basekv / mvasc3 / (1.0 + 1.0 / (x1r1 * x1r1)).sqrt();
1073    let r1 = x1 / x1r1;
1074    let a = 1.0 + x0r0 * x0r0;
1075    let b = 4.0 * (r1 + x1 * x0r0);
1076    let c = 4.0 * (r1 * r1 + x1 * x1) - (3.0 * basekv * 1000.0 / factor / isc1).powi(2);
1077    let disc = (b * b - 4.0 * a * c).max(0.0).sqrt();
1078    let r0 = ((-b + disc) / (2.0 * a)).max((-b - disc) / (2.0 * a));
1079    let x0 = r0 * x0r0;
1080
1081    let r_self = (2.0 * r1 + r0) / 3.0;
1082    let x_self = (2.0 * x1 + x0) / 3.0;
1083    let r_mutual = (r0 - r1) / 3.0;
1084    let x_mutual = (x0 - x1) / 3.0;
1085
1086    let mut r_mat = vec![vec![r_mutual; n_cond]; n_cond];
1087    let mut x_mat = vec![vec![x_mutual; n_cond]; n_cond];
1088    for i in 0..n_cond {
1089        r_mat[i][i] = r_self;
1090        x_mat[i][i] = x_self;
1091    }
1092    (r_mat, x_mat)
1093}
1094
1095fn count_phases(vs: &VoltageSource) -> usize {
1096    vs.v_magnitude.iter().filter(|&&v| v > 0.0).count()
1097}
1098
1099fn source_vm_nom(extras: &Extras, v_nom: &[f64]) -> Option<Value> {
1100    let raw = extras.get("kv")?;
1101    if v_nom.is_empty() {
1102        return Some(raw.clone());
1103    }
1104    if let Some(kv) = raw
1105        .as_f64()
1106        .or_else(|| raw.as_str().and_then(|s| s.parse().ok()))
1107    {
1108        if v_nom.iter().all(|v| same_voltage(*v, kv * 1e3)) {
1109            return Some(json!(kv));
1110        }
1111    }
1112    let vals: Vec<f64> = raw
1113        .as_array()?
1114        .iter()
1115        .filter_map(serde_json::Value::as_f64)
1116        .collect();
1117    if vals.len() == 1 && v_nom.iter().all(|v| same_voltage(*v, vals[0] * 1e3)) {
1118        return Some(raw.clone());
1119    }
1120    if vals.len() == v_nom.len()
1121        && vals
1122            .iter()
1123            .zip(v_nom)
1124            .all(|(a, b)| same_voltage(*b, *a * 1e3))
1125    {
1126        return Some(raw.clone());
1127    }
1128    None
1129}
1130
1131fn same_voltage(a: f64, b: f64) -> bool {
1132    (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
1133}