Skip to main content

powerio_dist/pmd/
write.rs

1//! [`DistNetwork`] 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::BTreeSet;
15
16use serde_json::{Map, Value, json};
17
18use crate::convert::Conversion;
19use crate::model::{
20    Configuration, DistLineCode, DistLoadVoltageModel, DistNetwork, DistTransformer, Extras, Mat,
21    VoltageSource, Winding, WindingConn,
22};
23
24/// Writes the ENGINEERING document.
25///
26/// # Panics
27///
28/// Never in practice: the document is maps, strings, finite numbers, and
29/// nulls, which always serialize.
30pub fn write_pmd_json(net: &DistNetwork) -> Conversion {
31    let mut w = Writer {
32        warnings: Vec::new(),
33    };
34    let doc = w.document(net);
35    Conversion {
36        text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n",
37        warnings: w.warnings,
38    }
39}
40
41struct Writer {
42    warnings: Vec<String>,
43}
44
45/// Terminal names as PMD integer connections; non numeric names count from
46/// 90 upward (PMD requires ints; the warning names the rename).
47fn conns(map: &[String], warnings: &mut Vec<String>, what: &str) -> Vec<i64> {
48    map.iter()
49        .enumerate()
50        .map(|(k, t)| {
51            t.parse::<i64>().unwrap_or_else(|_| {
52                let fallback = 90 + i64::try_from(k).unwrap_or(0);
53                warnings.push(format!(
54                    "{what}: terminal `{t}` is not numeric; emitted as {fallback}"
55                ));
56                fallback
57            })
58        })
59        .collect()
60}
61
62/// A matrix as PMD serializes it: array of columns (`hcat` rebuilds it).
63fn matrix(m: &Mat) -> Value {
64    let n = m.len();
65    let cols: Vec<Value> = (0..n)
66        .map(|j| Value::Array((0..n).map(|i| json!(m[i][j])).collect()))
67        .collect();
68    Value::Array(cols)
69}
70
71fn zero_matrix(n: usize) -> Mat {
72    vec![vec![0.0; n]; n]
73}
74
75/// A shunt whose stashed `conn` marks it a delta (line to line) bank.
76fn shunt_is_delta(extras: &Extras) -> bool {
77    extras
78        .get("conn")
79        .and_then(|v| v.as_str())
80        .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"))
81}
82
83fn scale(m: &Mat, k: f64) -> Mat {
84    m.iter()
85        .map(|row| row.iter().map(|v| v * k).collect())
86        .collect()
87}
88
89impl Writer {
90    fn warn(&mut self, msg: impl Into<String>) {
91        self.warnings.push(msg.into());
92    }
93
94    /// Reports extras the ENGINEERING model has no field for. `consumed`
95    /// names keys a field already represents; `pmd_*` bookkeeping and the
96    /// BMOPF subtype marker pass silently.
97    fn extras_dropped(&mut self, extras: &crate::model::Extras, consumed: &[&str], what: &str) {
98        for key in extras.keys() {
99            if consumed.contains(&key.as_str()) || key.starts_with("pmd_") || key == "bmopf_subtype"
100            {
101                continue;
102            }
103            self.warn(format!(
104                "{what}: `{key}` has no ENGINEERING field; dropped from the output"
105            ));
106        }
107    }
108
109    fn extras_f64(extras: &Extras, key: &str) -> Option<f64> {
110        extras.get(key).and_then(|v| {
111            v.as_f64()
112                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
113        })
114    }
115
116    /// The element status: the reader's stash when the source carried a non
117    /// ENABLED status, `ENABLED` otherwise.
118    fn status(extras: &Extras) -> Value {
119        extras
120            .get("pmd_status")
121            .cloned()
122            .unwrap_or_else(|| json!("ENABLED"))
123    }
124
125    fn document(&mut self, net: &DistNetwork) -> Value {
126        let mut doc = Map::new();
127        doc.insert("data_model".into(), json!("ENGINEERING"));
128        doc.insert(
129            "name".into(),
130            json!(net.name.clone().unwrap_or_default().to_lowercase()),
131        );
132        doc.insert(
133            "files".into(),
134            net.extras
135                .get("pmd_files")
136                .cloned()
137                .unwrap_or_else(|| json!([])),
138        );
139
140        // The reader's stash wins; synthesis covers dss/bmopf sourced
141        // models.
142        let settings = net
143            .extras
144            .get("pmd_settings")
145            .cloned()
146            .unwrap_or_else(|| synthesized_settings(net));
147        doc.insert("settings".into(), settings);
148
149        let max_conductor = net
150            .buses
151            .iter()
152            .flat_map(|b| &b.terminals)
153            .filter_map(|t| t.parse::<i64>().ok())
154            .max()
155            .unwrap_or(4)
156            .max(4);
157        doc.insert(
158            "conductor_ids".into(),
159            Value::Array((1..=max_conductor).map(|i| json!(i)).collect()),
160        );
161
162        let mut buses = Map::new();
163        for b in &net.buses {
164            let mut o = Map::new();
165            o.insert(
166                "terminals".into(),
167                json!(conns(
168                    &b.terminals,
169                    &mut self.warnings,
170                    &format!("bus {}", b.id)
171                )),
172            );
173            let grounded = conns(&b.grounded, &mut self.warnings, &format!("bus {}", b.id));
174            // Nonzero grounding impedance rides in extras (the reader's
175            // stash); zero vectors are the materialized default.
176            for key in ["rg", "xg"] {
177                let v = b
178                    .extras
179                    .get(key)
180                    .cloned()
181                    .unwrap_or_else(|| json!(vec![0.0; grounded.len()]));
182                o.insert(key.into(), v);
183            }
184            o.insert("grounded".into(), json!(grounded));
185            o.insert("status".into(), Self::status(&b.extras));
186            if let Some(x) = Self::extras_f64(&b.extras, "x") {
187                o.insert("lon".into(), json!(x));
188            }
189            if let Some(y) = Self::extras_f64(&b.extras, "y") {
190                o.insert("lat".into(), json!(y));
191            }
192            // Voltage bound families have no ENGINEERING fields in volts;
193            // they drop loudly (PMD bounds are per unit).
194            for (key, present) in [
195                ("v_min", b.v_min.is_some()),
196                ("v_max", b.v_max.is_some()),
197                ("vpn_min", b.vpn_min.is_some()),
198                ("vpn_max", b.vpn_max.is_some()),
199                ("vpp_min", b.vpp_min.is_some()),
200                ("vpp_max", b.vpp_max.is_some()),
201                ("vsym_min", b.vsym_min.is_some()),
202                ("vsym_max", b.vsym_max.is_some()),
203            ] {
204                if present {
205                    self.warn(format!(
206                        "bus {}: `{key}` volt bounds have no ENGINEERING field; dropped",
207                        b.id
208                    ));
209                }
210            }
211            buses.insert(b.id.to_lowercase(), Value::Object(o));
212        }
213        doc.insert("bus".into(), Value::Object(buses));
214
215        Self::linecodes(net, &mut doc);
216        self.branches(net, &mut doc);
217        self.injections(net, &mut doc);
218        self.transformers(net, &mut doc);
219
220        for u in &net.untyped {
221            self.warn(format!(
222                "{} {}: class is not converted to ENGINEERING; dropped from the output",
223                u.class, u.name
224            ));
225        }
226        Value::Object(doc)
227    }
228
229    fn linecodes(net: &DistNetwork, doc: &mut Map<String, Value>) {
230        // Linecodes the reader materialized from inline line impedance
231        // re-inline on the line; they are skipped here unless a line
232        // without the marker also references them.
233        let inlined = inlined_codes(net);
234        let mut codes = Map::new();
235        for c in &net.linecodes {
236            if inlined.contains(&c.name.to_lowercase()) {
237                continue;
238            }
239            let mut o = Map::new();
240            insert_impedance_matrices(&mut o, c, net.base_frequency);
241            if let Some(i_max) = &c.i_max {
242                o.insert("cm_ub".into(), json!(i_max));
243            }
244            if let Some(s_max) = &c.s_max {
245                o.insert("sm_ub".into(), json!(s_max));
246            }
247            codes.insert(c.name.to_lowercase(), Value::Object(o));
248        }
249        if !codes.is_empty() {
250            doc.insert("linecode".into(), Value::Object(codes));
251        }
252    }
253
254    fn branches(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
255        if !net.lines.is_empty() {
256            let mut lines = Map::new();
257            for l in &net.lines {
258                let mut o = Map::new();
259                o.insert("f_bus".into(), json!(l.bus_from.to_lowercase()));
260                o.insert("t_bus".into(), json!(l.bus_to.to_lowercase()));
261                let what = format!("line {}", l.name);
262                o.insert(
263                    "f_connections".into(),
264                    json!(conns(&l.terminal_map_from, &mut self.warnings, &what)),
265                );
266                o.insert(
267                    "t_connections".into(),
268                    json!(conns(&l.terminal_map_to, &mut self.warnings, &what)),
269                );
270                o.insert("length".into(), json!(l.length));
271                // A line the reader materialized a linecode for re-inlines
272                // its impedance, the dss2eng shape for rmatrix defined
273                // lines: matrices on the line, no linecode key.
274                let inline = l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true);
275                match net.linecode(&l.linecode) {
276                    Some(c) if inline => {
277                        insert_impedance_matrices(&mut o, c, net.base_frequency);
278                        if let Some(i_max) = &c.i_max {
279                            o.insert("cm_ub".into(), json!(i_max));
280                        }
281                    }
282                    _ => {
283                        if inline {
284                            self.warn(format!(
285                                "{what}: linecode `{}` is missing; emitted the reference instead of inline impedance",
286                                l.linecode
287                            ));
288                        }
289                        o.insert("linecode".into(), json!(l.linecode.to_lowercase()));
290                    }
291                }
292                o.insert("status".into(), Self::status(&l.extras));
293                o.insert(
294                    "source_id".into(),
295                    json!(format!("line.{}", l.name.to_lowercase())),
296                );
297                self.extras_dropped(&l.extras, &["units"], &what);
298                lines.insert(l.name.to_lowercase(), Value::Object(o));
299            }
300            doc.insert("line".into(), Value::Object(lines));
301        }
302
303        if !net.switches.is_empty() {
304            let mut switches = Map::new();
305            for s in &net.switches {
306                let mut o = Map::new();
307                let n = s.terminal_map_from.len();
308                let what = format!("switch {}", s.name);
309                o.insert("f_bus".into(), json!(s.bus_from.to_lowercase()));
310                o.insert("t_bus".into(), json!(s.bus_to.to_lowercase()));
311                o.insert(
312                    "f_connections".into(),
313                    json!(conns(&s.terminal_map_from, &mut self.warnings, &what)),
314                );
315                o.insert(
316                    "t_connections".into(),
317                    json!(conns(&s.terminal_map_to, &mut self.warnings, &what)),
318                );
319                // The reader's stash carries the source's series matrices;
320                // otherwise PMD models a dss switch as a tiny series
321                // resistance, 1e-4 ohm/m over the forced 0.001 m length
322                // (the product form keeps the value bit identical).
323                let rs = s.extras.get("pmd_rs").cloned().unwrap_or_else(|| {
324                    let mut rs = zero_matrix(n);
325                    for (i, row) in rs.iter_mut().enumerate() {
326                        row[i] = 1e-4 * 0.001;
327                    }
328                    matrix(&rs)
329                });
330                o.insert("rs".into(), rs);
331                let xs = s
332                    .extras
333                    .get("pmd_xs")
334                    .cloned()
335                    .unwrap_or_else(|| matrix(&zero_matrix(n)));
336                o.insert("xs".into(), xs);
337                o.insert("g_fr".into(), matrix(&zero_matrix(n)));
338                o.insert("g_to".into(), matrix(&zero_matrix(n)));
339                o.insert("b_fr".into(), matrix(&zero_matrix(n)));
340                o.insert("b_to".into(), matrix(&zero_matrix(n)));
341                if let Some(i_max) = &s.i_max {
342                    o.insert("cm_ub".into(), json!(i_max));
343                }
344                o.insert(
345                    "state".into(),
346                    json!(if s.open { "OPEN" } else { "CLOSED" }),
347                );
348                o.insert("dispatchable".into(), json!("YES"));
349                o.insert("status".into(), Self::status(&s.extras));
350                o.insert(
351                    "source_id".into(),
352                    json!(format!("line.{}", s.name.to_lowercase())),
353                );
354                self.extras_dropped(&s.extras, &[], &what);
355                switches.insert(s.name.to_lowercase(), Value::Object(o));
356            }
357            doc.insert("switch".into(), Value::Object(switches));
358        }
359    }
360
361    fn loads(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
362        if !net.loads.is_empty() {
363            let mut loads = Map::new();
364            for l in &net.loads {
365                let mut o = Map::new();
366                let what = format!("load {}", l.name);
367                let connections = conns(&l.terminal_map, &mut self.warnings, &what);
368                // PMD types a two terminal load WYE when the return is the
369                // bus's grounded neutral and DELTA otherwise.
370                let configuration = match l.configuration {
371                    Configuration::Delta => "DELTA",
372                    Configuration::Wye => "WYE",
373                    Configuration::SinglePhase => {
374                        let grounded_return = l
375                            .terminal_map
376                            .last()
377                            .zip(net.bus(&l.bus))
378                            .is_some_and(|(t, b)| b.grounded.contains(t));
379                        if grounded_return { "WYE" } else { "DELTA" }
380                    }
381                };
382                o.insert("configuration".into(), json!(configuration));
383                o.insert("connections".into(), json!(connections));
384                o.insert(
385                    "pd_nom".into(),
386                    json!(l.p_nom.iter().map(|p| p / 1e3).collect::<Vec<_>>()),
387                );
388                o.insert(
389                    "qd_nom".into(),
390                    json!(l.q_nom.iter().map(|q| q / 1e3).collect::<Vec<_>>()),
391                );
392                o.insert("bus".into(), json!(l.bus.to_lowercase()));
393                let mut insert_vm_nom = |v_nom: &[f64]| {
394                    if let Some(value) = source_vm_nom(&l.extras, v_nom) {
395                        o.insert("vm_nom".into(), value);
396                    } else if !v_nom.is_empty() {
397                        let value = if v_nom.len() == 1 {
398                            json!(v_nom[0] / 1e3)
399                        } else {
400                            json!(v_nom.iter().map(|v| v / 1e3).collect::<Vec<_>>())
401                        };
402                        o.insert("vm_nom".into(), value);
403                    } else if let Some(kv) = Self::extras_f64(&l.extras, "kv") {
404                        o.insert("vm_nom".into(), json!(kv));
405                    }
406                };
407                let model = match &l.voltage_model {
408                    DistLoadVoltageModel::ConstantImpedance { v_nom } => {
409                        insert_vm_nom(v_nom);
410                        "IMPEDANCE"
411                    }
412                    DistLoadVoltageModel::ConstantCurrent { v_nom } => {
413                        insert_vm_nom(v_nom);
414                        "CURRENT"
415                    }
416                    DistLoadVoltageModel::Zip { v_nom, .. } => {
417                        insert_vm_nom(v_nom);
418                        "ZIPV"
419                    }
420                    DistLoadVoltageModel::Exponential { v_nom, .. } => {
421                        insert_vm_nom(v_nom);
422                        self.warn(format!(
423                            "{what}: exponential load model has no ENGINEERING field; emitted POWER"
424                        ));
425                        "POWER"
426                    }
427                    DistLoadVoltageModel::ConstantPower { v_nom } => {
428                        insert_vm_nom(v_nom);
429                        "POWER"
430                    }
431                };
432                o.insert("model".into(), json!(model));
433                o.insert("dispatchable".into(), json!("NO"));
434                o.insert("status".into(), Self::status(&l.extras));
435                o.insert(
436                    "source_id".into(),
437                    json!(format!("load.{}", l.name.to_lowercase())),
438                );
439                self.extras_dropped(&l.extras, &["kv", "model", "pf"], &what);
440                loads.insert(l.name.to_lowercase(), Value::Object(o));
441            }
442            doc.insert("load".into(), Value::Object(loads));
443        }
444    }
445
446    fn generators(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
447        if !net.generators.is_empty() {
448            let mut gens = Map::new();
449            for g in &net.generators {
450                let mut o = Map::new();
451                let what = format!("generator {}", g.name);
452                o.insert("bus".into(), json!(g.bus.to_lowercase()));
453                o.insert(
454                    "connections".into(),
455                    json!(conns(&g.terminal_map, &mut self.warnings, &what)),
456                );
457                o.insert(
458                    "configuration".into(),
459                    json!(match g.configuration {
460                        Configuration::Delta => "DELTA",
461                        _ => "WYE",
462                    }),
463                );
464                let kw = |w: &[f64]| w.iter().map(|v| v / 1e3).collect::<Vec<_>>();
465                o.insert("pg".into(), json!(kw(&g.p_nom)));
466                o.insert("qg".into(), json!(kw(&g.q_nom)));
467                if let Some(b) = &g.q_min {
468                    o.insert("qg_lb".into(), json!(kw(b)));
469                }
470                if let Some(b) = &g.q_max {
471                    o.insert("qg_ub".into(), json!(kw(b)));
472                }
473                if let Some(b) = &g.p_min {
474                    o.insert("pg_lb".into(), json!(kw(b)));
475                }
476                if let Some(b) = &g.p_max {
477                    o.insert("pg_ub".into(), json!(kw(b)));
478                }
479                if g.cost.is_some() {
480                    self.warn(format!(
481                        "{what}: generation cost has no ENGINEERING field; dropped"
482                    ));
483                }
484                o.insert("control_mode".into(), json!("FREQUENCYDROOP"));
485                o.insert("status".into(), Self::status(&g.extras));
486                o.insert(
487                    "source_id".into(),
488                    json!(format!("generator.{}", g.name.to_lowercase())),
489                );
490                self.extras_dropped(&g.extras, &["kv"], &what);
491                gens.insert(g.name.to_lowercase(), Value::Object(o));
492            }
493            doc.insert("generator".into(), Value::Object(gens));
494        }
495    }
496
497    fn injections(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
498        self.loads(net, doc);
499        self.generators(net, doc);
500        if !net.shunts.is_empty() {
501            let mut shunts = Map::new();
502            for s in &net.shunts {
503                let mut o = Map::new();
504                let what = format!("shunt {}", s.name);
505                o.insert("bus".into(), json!(s.bus.to_lowercase()));
506                o.insert(
507                    "connections".into(),
508                    json!(conns(&s.terminal_map, &mut self.warnings, &what)),
509                );
510                o.insert("gs".into(), matrix(&s.g));
511                o.insert("bs".into(), matrix(&s.b));
512                // A delta bank carries a `conn` marker and an off diagonal B
513                // matrix; emitting it as WYE would describe a line to line
514                // admittance as line to ground.
515                let configuration = if shunt_is_delta(&s.extras) {
516                    "DELTA"
517                } else {
518                    "WYE"
519                };
520                o.insert("configuration".into(), json!(configuration));
521                o.insert("model".into(), json!("CAPACITOR"));
522                o.insert("dispatchable".into(), json!("NO"));
523                o.insert("status".into(), Self::status(&s.extras));
524                o.insert(
525                    "source_id".into(),
526                    json!(format!("capacitor.{}", s.name.to_lowercase())),
527                );
528                self.extras_dropped(&s.extras, &["kv", "kvar", "conn"], &what);
529                shunts.insert(s.name.to_lowercase(), Value::Object(o));
530            }
531            doc.insert("shunt".into(), Value::Object(shunts));
532        }
533
534        let mut sources = Map::new();
535        for vs in &net.sources {
536            sources.insert(vs.name.to_lowercase(), self.voltage_source(vs));
537        }
538        doc.insert("voltage_source".into(), Value::Object(sources));
539    }
540
541    fn voltage_source(&mut self, vs: &VoltageSource) -> Value {
542        let mut o = Map::new();
543        let what = format!("voltage source {}", vs.name);
544        let connections = conns(&vs.terminal_map, &mut self.warnings, &what);
545        let n = connections.len();
546        o.insert("bus".into(), json!(vs.bus.to_lowercase()));
547        o.insert("connections".into(), json!(connections));
548        o.insert("configuration".into(), json!("WYE"));
549        o.insert(
550            "vm".into(),
551            json!(vs.v_magnitude.iter().map(|v| v / 1e3).collect::<Vec<_>>()),
552        );
553        o.insert(
554            "va".into(),
555            json!(
556                vs.v_angle
557                    .iter()
558                    .map(|a| a.to_degrees())
559                    .collect::<Vec<_>>()
560            ),
561        );
562        // The Thevenin matrices: verbatim when the source carried them
563        // (an ENGINEERING round trip), recomputed with the engine's
564        // formulas from short circuit data otherwise.
565        if let (Some(rs), Some(xs)) = (vs.extras.get("rs"), vs.extras.get("xs")) {
566            o.insert("rs".into(), rs.clone());
567            o.insert("xs".into(), xs.clone());
568        } else {
569            let (rs, xs) = thevenin(vs, n);
570            if rs.iter().flatten().all(|&v| v == 0.0) {
571                self.warn(format!(
572                    "{what}: no short circuit data; emitted an ideal source (zero rs/xs)"
573                ));
574            }
575            o.insert("rs".into(), matrix(&rs));
576            o.insert("xs".into(), matrix(&xs));
577        }
578        o.insert("status".into(), Self::status(&vs.extras));
579        o.insert(
580            "source_id".into(),
581            json!(format!("vsource.{}", vs.name.to_lowercase())),
582        );
583        // The short circuit form (basekv/pu/angle/MVAsc/X-R ratios) is
584        // represented by vm/va and the Thevenin matrices.
585        self.extras_dropped(
586            &vs.extras,
587            &[
588                "basekv",
589                "basemva",
590                "pu",
591                "angle",
592                "mvasc1",
593                "mvasc3",
594                "x1r1",
595                "x0r0",
596                "rs",
597                "xs",
598                "isc1",
599                "isc3",
600                "configuration",
601            ],
602            &what,
603        );
604        Value::Object(o)
605    }
606
607    fn transformers(&mut self, net: &DistNetwork, doc: &mut Map<String, Value>) {
608        if net.transformers.is_empty() {
609            return;
610        }
611        let mut out = Map::new();
612        for t in &net.transformers {
613            out.insert(t.name.to_lowercase(), self.transformer(t));
614        }
615        doc.insert("transformer".into(), Value::Object(out));
616    }
617
618    fn transformer(&mut self, t: &DistTransformer) -> Value {
619        let mut o = Map::new();
620        let what = format!("transformer {}", t.name);
621        let phases = t.phases;
622
623        // The reader's stash carries a source polarity/connections pair the
624        // lag convention does not reproduce (euro/lead, reversed windings);
625        // emit it verbatim. Otherwise apply the ANSI lag convention the
626        // reference dss2eng uses: barrel roll the wye phase conductors
627        // under a delta primary and reverse the winding polarity.
628        let stashed = t.extras.contains_key("pmd_polarity");
629        let mut buses = Vec::new();
630        let mut connections: Vec<Value> = Vec::new();
631        for (w_idx, w) in t.windings.iter().enumerate() {
632            buses.push(json!(w.bus.to_lowercase()));
633            let mut c = conns(&w.terminal_map, &mut self.warnings, &what);
634            if !stashed
635                && w_idx > 0
636                && t.windings[0].conn == WindingConn::Delta
637                && w.conn == WindingConn::Wye
638                && c.len() > 1
639            {
640                let phases_part = c.len() - 1;
641                c[..phases_part].rotate_left(1);
642            }
643            connections.push(json!(c));
644        }
645        o.insert("bus".into(), Value::Array(buses));
646        o.insert(
647            "connections".into(),
648            t.extras
649                .get("pmd_connections")
650                .cloned()
651                .unwrap_or(Value::Array(connections)),
652        );
653        o.insert(
654            "polarity".into(),
655            t.extras
656                .get("pmd_polarity")
657                .cloned()
658                .unwrap_or_else(|| json!(lag_polarity(&t.windings))),
659        );
660        o.insert(
661            "configuration".into(),
662            Value::Array(
663                t.windings
664                    .iter()
665                    .map(|w| {
666                        json!(match w.conn {
667                            WindingConn::Wye => "WYE",
668                            WindingConn::Delta => "DELTA",
669                        })
670                    })
671                    .collect(),
672            ),
673        );
674        o.insert(
675            "rw".into(),
676            json!(
677                t.windings
678                    .iter()
679                    .map(|w| w.r_pct / 100.0)
680                    .collect::<Vec<_>>()
681            ),
682        );
683        o.insert(
684            "xsc".into(),
685            json!(t.xsc_pct.iter().map(|x| x / 100.0).collect::<Vec<_>>()),
686        );
687        o.insert(
688            "sm_nom".into(),
689            json!(
690                t.windings
691                    .iter()
692                    .map(|w| w.s_rating / 1e3)
693                    .collect::<Vec<_>>()
694            ),
695        );
696        o.insert(
697            "vm_nom".into(),
698            json!(t.windings.iter().map(|w| w.v_ref / 1e3).collect::<Vec<_>>()),
699        );
700        let sm_ub =
701            Self::extras_f64(&t.extras, "emerghkva").unwrap_or(t.windings[0].s_rating / 1e3 * 1.5);
702        o.insert("sm_ub".into(), json!(sm_ub));
703        insert_tap_fields(&mut o, t, phases);
704        if let Some(controls) = t.extras.get("controls") {
705            o.insert("controls".into(), controls.clone());
706        }
707        let noloadloss = Self::extras_f64(&t.extras, "%noloadloss").unwrap_or(0.0) / 100.0;
708        let cmag = Self::extras_f64(&t.extras, "%imag").unwrap_or(0.0) / 100.0;
709        o.insert("noloadloss".into(), json!(noloadloss));
710        o.insert("cmag".into(), json!(cmag));
711        o.insert("status".into(), Self::status(&t.extras));
712        o.insert(
713            "source_id".into(),
714            json!(format!("transformer.{}", t.name.to_lowercase())),
715        );
716        self.extras_dropped(
717            &t.extras,
718            &["controls", "%loadloss", "%noloadloss", "%imag", "emerghkva"],
719            &what,
720        );
721        Value::Object(o)
722    }
723}
724
725/// The per winding per phase tap arrays. The reader's `pmd_tm_*` stashes
726/// win (per phase taps, custom bounds, regulator fix flags); the defaults
727/// for the rest are the engine's bounds (0.9..1.1) and step (1/32).
728fn insert_tap_fields(o: &mut Map<String, Value>, t: &DistTransformer, phases: usize) {
729    let nw = t.windings.len();
730    let mut insert = |key: &str, default: fn(&DistTransformer, usize, usize) -> Value| {
731        let v = t
732            .extras
733            .get(&format!("pmd_{key}"))
734            .cloned()
735            .unwrap_or_else(|| default(t, nw, phases));
736        o.insert(key.into(), v);
737    };
738    insert("tm_set", |t, _, phases| {
739        Value::Array(
740            t.windings
741                .iter()
742                .map(|w| json!(vec![w.tap; phases]))
743                .collect(),
744        )
745    });
746    insert("tm_fix", |_, nw, phases| {
747        Value::Array((0..nw).map(|_| json!(vec![true; phases])).collect())
748    });
749    insert("tm_lb", |_, nw, phases| {
750        Value::Array((0..nw).map(|_| json!(vec![0.9; phases])).collect())
751    });
752    insert("tm_ub", |_, nw, phases| {
753        Value::Array((0..nw).map(|_| json!(vec![1.1; phases])).collect())
754    });
755    insert("tm_step", |_, nw, phases| {
756        Value::Array((0..nw).map(|_| json!(vec![1.0 / 32.0; phases])).collect())
757    });
758}
759
760/// The ENGINEERING settings for a model without the reader's stash (dss or
761/// bmopf sourced), following the dss2eng conventions: the per bus vbase is
762/// the source's nominal line to neutral kV without the pu factor folded
763/// in, and sbase is basemva in kVA (default 100 MVA).
764fn synthesized_settings(net: &DistNetwork) -> Value {
765    let mut settings = Map::new();
766    settings.insert("base_frequency".into(), json!(net.base_frequency));
767    settings.insert("power_scale_factor".into(), json!(1000.0));
768    settings.insert("voltage_scale_factor".into(), json!(1000.0));
769    let sbase = net
770        .sources
771        .first()
772        .and_then(|vs| Writer::extras_f64(&vs.extras, "basemva"))
773        .map_or(100_000.0, |mva| mva * 1e3);
774    settings.insert("sbase_default".into(), json!(sbase));
775    let mut vbases = Map::new();
776    for vs in &net.sources {
777        let phases = count_phases(vs).max(1) as f64;
778        let vln_kv = Writer::extras_f64(&vs.extras, "basekv").map_or_else(
779            || {
780                let pu = Writer::extras_f64(&vs.extras, "pu").unwrap_or(1.0);
781                vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 / pu
782            },
783            |kv| kv / phases.sqrt(),
784        );
785        vbases.insert(vs.bus.to_lowercase(), json!(vln_kv));
786    }
787    settings.insert("vbases_default".into(), Value::Object(vbases));
788    Value::Object(settings)
789}
790
791/// The polarity vector the ANSI lag convention produces for these windings:
792/// -1 with a barrel roll on each wye winding under a delta primary, -1 on
793/// the reversed second half of a center tap secondary, 1 elsewhere. The
794/// reader compares the source against this to decide whether the file's
795/// polarity needs an extras stash.
796pub(super) fn lag_polarity(windings: &[Winding]) -> Vec<i64> {
797    let nw = windings.len();
798    let mut polarity = vec![1i64; nw];
799    for (w_idx, w) in windings.iter().enumerate().skip(1) {
800        if windings[0].conn == WindingConn::Delta
801            && w.conn == WindingConn::Wye
802            && w.terminal_map.len() > 1
803        {
804            polarity[w_idx] = -1;
805        }
806        // Center tap: the second half winding is reversed.
807        if w_idx == 2 && nw == 3 && windings[1].terminal_map.last() == w.terminal_map.first() {
808            polarity[w_idx] = -1;
809        }
810    }
811    polarity
812}
813
814/// Names (lowercased) of linecodes that re-inline on their lines: every
815/// referencing line carries the reader's `pmd_inline` marker.
816fn inlined_codes(net: &DistNetwork) -> BTreeSet<String> {
817    let mut inlined = BTreeSet::new();
818    for c in &net.linecodes {
819        let mut refs = net
820            .lines
821            .iter()
822            .filter(|l| l.linecode.eq_ignore_ascii_case(&c.name))
823            .peekable();
824        if refs.peek().is_some()
825            && refs.all(|l| l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true))
826        {
827            inlined.insert(c.name.to_lowercase());
828        }
829    }
830    inlined
831}
832
833/// The six ENGINEERING impedance matrices of a linecode, emitted onto a
834/// `linecode` entry or re-inlined onto a line. The b_fr/b_to numbers are
835/// the dss cmatrix halves in nanofarads per meter (the susceptance follows
836/// as 2 pi f C); the model holds true siemens per meter, so divide the
837/// omega back out — or emit the reader's raw stash, which is bit exact.
838fn insert_impedance_matrices(o: &mut Map<String, Value>, c: &DistLineCode, base_frequency: f64) {
839    o.insert("rs".into(), matrix(&c.r_series));
840    o.insert("xs".into(), matrix(&c.x_series));
841    o.insert("g_fr".into(), matrix(&c.g_from));
842    o.insert("g_to".into(), matrix(&c.g_to));
843    if let (Some(fr), Some(to)) = (c.extras.get("pmd_b_fr"), c.extras.get("pmd_b_to")) {
844        o.insert("b_fr".into(), fr.clone());
845        o.insert("b_to".into(), to.clone());
846    } else {
847        let to_nf = 1.0 / (std::f64::consts::TAU * base_frequency * 1e-9);
848        o.insert("b_fr".into(), matrix(&scale(&c.b_from, to_nf)));
849        o.insert("b_to".into(), matrix(&scale(&c.b_to, to_nf)));
850    }
851}
852
853/// The engine's Thevenin computation from MVAsc3/MVAsc1 and the X/R ratios
854/// (the same math the reference dss2eng inherits): sequence impedances from
855/// the short circuit levels, then self/mutual phase values filled over all
856/// conductors including the neutral.
857fn thevenin(vs: &VoltageSource, n_cond: usize) -> (Mat, Mat) {
858    let get = |key: &str| Writer::extras_f64(&vs.extras, key);
859    let basekv = get("basekv").unwrap_or_else(|| {
860        // Reconstruct from the magnitude when basekv was defaulted.
861        vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 * (count_phases(vs) as f64).sqrt()
862    });
863    let phases = count_phases(vs);
864    if basekv <= 0.0 || phases == 0 {
865        return (zero_matrix(n_cond), zero_matrix(n_cond));
866    }
867    let mvasc3 = get("mvasc3").unwrap_or(2000.0);
868    let mvasc1 = get("mvasc1").unwrap_or(2100.0);
869    let x1r1 = get("x1r1").unwrap_or(4.0);
870    let x0r0 = get("x0r0").unwrap_or(3.0);
871    let factor = if phases == 1 { 1.0 } else { 3f64.sqrt() };
872
873    let isc1 = mvasc1 * 1e3 / (basekv * factor);
874    let x1 = basekv * basekv / mvasc3 / (1.0 + 1.0 / (x1r1 * x1r1)).sqrt();
875    let r1 = x1 / x1r1;
876    let a = 1.0 + x0r0 * x0r0;
877    let b = 4.0 * (r1 + x1 * x0r0);
878    let c = 4.0 * (r1 * r1 + x1 * x1) - (3.0 * basekv * 1000.0 / factor / isc1).powi(2);
879    let disc = (b * b - 4.0 * a * c).max(0.0).sqrt();
880    let r0 = ((-b + disc) / (2.0 * a)).max((-b - disc) / (2.0 * a));
881    let x0 = r0 * x0r0;
882
883    let r_self = (2.0 * r1 + r0) / 3.0;
884    let x_self = (2.0 * x1 + x0) / 3.0;
885    let r_mutual = (r0 - r1) / 3.0;
886    let x_mutual = (x0 - x1) / 3.0;
887
888    let mut r_mat = vec![vec![r_mutual; n_cond]; n_cond];
889    let mut x_mat = vec![vec![x_mutual; n_cond]; n_cond];
890    for i in 0..n_cond {
891        r_mat[i][i] = r_self;
892        x_mat[i][i] = x_self;
893    }
894    (r_mat, x_mat)
895}
896
897fn count_phases(vs: &VoltageSource) -> usize {
898    vs.v_magnitude.iter().filter(|&&v| v > 0.0).count()
899}
900
901fn source_vm_nom(extras: &Extras, v_nom: &[f64]) -> Option<Value> {
902    let raw = extras.get("kv")?;
903    if v_nom.is_empty() {
904        return Some(raw.clone());
905    }
906    if let Some(kv) = raw
907        .as_f64()
908        .or_else(|| raw.as_str().and_then(|s| s.parse().ok()))
909    {
910        if v_nom.iter().all(|v| same_voltage(*v, kv * 1e3)) {
911            return Some(json!(kv));
912        }
913    }
914    let vals: Vec<f64> = raw
915        .as_array()?
916        .iter()
917        .filter_map(serde_json::Value::as_f64)
918        .collect();
919    if vals.len() == 1 && v_nom.iter().all(|v| same_voltage(*v, vals[0] * 1e3)) {
920        return Some(raw.clone());
921    }
922    if vals.len() == v_nom.len()
923        && vals
924            .iter()
925            .zip(v_nom)
926            .all(|(a, b)| same_voltage(*b, *a * 1e3))
927    {
928        return Some(raw.clone());
929    }
930    None
931}
932
933fn same_voltage(a: f64, b: f64) -> bool {
934    (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
935}