Skip to main content

powerio_dist/glm/
write.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::fmt::Write as _;
3
4use num_complex::Complex64;
5use powerio_core::{ArtifactPath, Destination, EmitResult, Error, MemoryArtifact};
6
7use crate::convert::TextEmission;
8use crate::diagnostics::{Diagnostics, codes};
9use crate::{
10    Configuration, DistBus, DistLine, DistLineCode, DistLoadVoltageModel, MulticonductorNetwork,
11};
12
13use super::read::{nominal_phasor, phase_name};
14
15const METERS_PER_MILE: f64 = 1609.344;
16
17mod coil_banks;
18mod current_form;
19mod ideal;
20mod injections;
21mod loads;
22mod regulators;
23mod services;
24mod shunts;
25mod transformers;
26
27/// Emit fresh GLM from the current typed network, without retained-source echo.
28///
29/// The initial profile supports earth/phase-pair P/I/Z loads, ideal fixed sources,
30/// explicit phase line matrices, ordinary grounded-wye transformers and fixed
31/// contacts. Grounded center-tapped services with private impedance core loads
32/// emit native transformers; triplex P/I/Z terms and qualified full-neutral or
33/// direct hot-conductor line matrices emit from current typed coefficients.
34/// Fixed generator and explicitly fixed inverter injections emit signed
35/// constant-power loads; component identity and metadata losses are diagnosed.
36/// Coherent three-coil phase-pair banks emit native delta-grounded-wye
37/// configurations using current effective winding voltages and impedances.
38/// Fixed resistive regulator equivalents emit manual Type B settings.
39/// Ideal closed contacts and grounded ideal phase ratios select exact FBS
40/// output on a checked forest with one source bus per component. Independent
41/// phase ratios use native series stages; private service core circuits are
42/// contracted for the forest check. Redundant fully open ties use unloaded
43/// parented endpoint aliases to preserve their voltages and zero port currents.
44/// FBS uses a 1,000-iteration budget with unchanged node stopping precision.
45/// NR uses a nominal-voltage-scaled convergence limit of 1e-9 per unit;
46/// the electrical circuit and source constraints remain unchanged.
47/// Symmetric line end shunts use exact admittance-load equivalents when native
48/// line parameters cannot preserve their port equations. Returned diagnostics
49/// identify the generated loads that contribute to physical line port flows.
50/// Unsupported electrical behavior fails
51/// before the destination is written; omitted non-electrical metadata is
52/// reported. This entry point deliberately takes a value, not a source module.
53pub fn emit_canonical(
54    network: &MulticonductorNetwork,
55    destination: Destination,
56) -> Result<EmitResult, Error> {
57    let emission = emit_text(network)?;
58    destination.__commit_artifacts(
59        false,
60        emission.fidelity,
61        vec![MemoryArtifact::new(
62            ArtifactPath::new("case.glm")?,
63            emission.text.into_bytes(),
64        )],
65        emission.diagnostics,
66    )
67}
68
69pub(crate) fn emit_text(network: &MulticonductorNetwork) -> Result<TextEmission, Error> {
70    crate::require_electrical_readiness(network)?;
71    network.validate_component_locations()?;
72    let mut writer = Writer {
73        net: network,
74        out: String::new(),
75        diagnostics: Diagnostics::new(),
76        identities: BTreeSet::new(),
77        phases: BTreeMap::new(),
78        source_voltages: BTreeMap::new(),
79        nominal: BTreeMap::new(),
80        locations: network
81            .component_locations()
82            .iter()
83            .map(|p| ((p.target, p.name.as_str()), p))
84            .collect(),
85        written_locations: BTreeSet::new(),
86        ideal_plan: None,
87        services: services::Services::discover(network)?,
88        coil_banks: coil_banks::CoilBanks::discover(network)?,
89        regulators: regulators::Regulators::discover(network)?,
90    };
91    writer.inventory()?;
92    writer.sources()?;
93    writer.prepare_ideal_contacts()?;
94    writer.validate_transformers()?;
95    writer.nominal_voltages()?;
96    let solver = if writer.ideal_plan.is_some() {
97        "FBS"
98    } else {
99        "NR"
100    };
101    let precision = if writer.ideal_plan.is_some() {
102        ""
103    } else {
104        " default_maximum_voltage_error 1e-9;"
105    };
106    let _ = writeln!(
107        writer.out,
108        "// Canonical PowerIO static network.\nmodule powerflow {{ solver_method {solver}; line_capacitance TRUE; nominal_frequency {};{precision} }}\n",
109        network.base_frequency()
110    );
111    if writer.ideal_plan.is_some() {
112        writer.out.push_str("#set iteration_limit=1000;\n");
113    }
114    if writer
115        .identities
116        .iter()
117        .any(|name| !name.as_bytes()[0].is_ascii_alphabetic() && name.as_bytes()[0] != b'_')
118    {
119        writer.out.push_str("#set relax_naming_rules=1;\n");
120    }
121    writer.buses()?;
122    writer.lines()?;
123    writer.switches()?;
124    writer.transformers()?;
125    writer.loads()?;
126    writer.static_injections()?;
127    writer.capacitor_shunts()?;
128    writer.remaining_locations();
129    if *network.source_format() == Some(crate::DistSourceFormat::Sincal) {
130        writer.diagnostics.push(
131            &codes::EMIT_SINCAL_MULTICONDUCTOR_RETAINED_SOURCE_OMITTED,
132            "Native SINCAL source-only data are omitted from the typed electrical conversion.",
133        );
134    }
135    Ok(TextEmission::new(
136        writer.out,
137        Vec::new(),
138        writer.diagnostics,
139    ))
140}
141
142struct Writer<'a> {
143    net: &'a MulticonductorNetwork,
144    out: String,
145    diagnostics: Diagnostics,
146    identities: BTreeSet<String>,
147    phases: BTreeMap<&'a str, Vec<usize>>,
148    source_voltages: BTreeMap<&'a str, BTreeMap<usize, Complex64>>,
149    nominal: BTreeMap<&'a str, f64>,
150    locations: BTreeMap<(crate::DistLocationTarget, &'a str), &'a crate::DistComponentLocation>,
151    written_locations: BTreeSet<(crate::DistLocationTarget, &'a str)>,
152    ideal_plan: Option<ideal::IdealPlan<'a>>,
153    services: services::Services<'a>,
154    coil_banks: coil_banks::CoilBanks<'a>,
155    regulators: regulators::Regulators<'a>,
156}
157
158fn unsupported(message: impl Into<String>) -> Error {
159    Error::new(&codes::EMIT_GLM_UNSUPPORTED, message)
160}
161
162impl Writer<'_> {
163    // Native NR derives this limit from nominal_voltage times the module's
164    // fractional criterion. A fixed nanovolt threshold can prevent convergence
165    // on a large feeder. Keep the qualified FBS absolute setting unchanged.
166    fn voltage_error(&self) -> &'static str {
167        if self.ideal_plan.is_some() {
168            "1e-9"
169        } else {
170            "0"
171        }
172    }
173
174    fn coordinates(&mut self, target: crate::DistLocationTarget, name: &str) {
175        let Some(location) = self.locations.get(&(target, name)).copied() else {
176            return;
177        };
178        if let Some(latitude) = location.point.latitude {
179            let _ = writeln!(self.out, "    latitude {latitude};");
180        }
181        if let Some(longitude) = location.point.longitude {
182            let _ = writeln!(self.out, "    longitude {longitude};");
183        }
184        self.written_locations
185            .insert((target, location.name.as_str()));
186        if location.point.datum.is_some()
187            || location
188                .kind
189                .is_some_and(|k| k != crate::DistCoordsKind::Source)
190        {
191            self.dropped(format!("{target:?} {name}: GLM has no declared coordinate datum or point-origin field; numeric angles are unchanged"));
192        }
193    }
194
195    fn remaining_locations(&mut self) {
196        for &key in self.locations.keys() {
197            if !self.written_locations.contains(&key) {
198                self.diagnostics.push(
199                    &codes::EMIT_DIST_COMPONENT_COORDINATES_DROPPED,
200                    format!(
201                        "{:?} {} has no point attachment in this GLM output profile",
202                        key.0, key.1
203                    ),
204                );
205            }
206        }
207    }
208
209    fn inventory(&mut self) -> Result<(), Error> {
210        if self.net.buses().is_empty() {
211            return Err(unsupported("GLM output needs at least one electrical bus"));
212        }
213        if ![50.0, 60.0].contains(&self.net.base_frequency()) {
214            return Err(unsupported(
215                "nominal frequencies other than 50 or 60 Hz are not qualified",
216            ));
217        }
218        if !self.net.capacitors().is_empty()
219            || !self.net.control_profiles().is_empty()
220            || !self.net.untyped_objects().is_empty()
221        {
222            return Err(unsupported(
223                "additional device tables need a qualified GLM mapping",
224            ));
225        }
226        if !self.net.extras().is_empty()
227            || !self.net.options().is_empty()
228            || !self.net.commands().is_empty()
229        {
230            return Err(unsupported(
231                "uninterpreted network extras, options or commands may affect physics",
232            ));
233        }
234        if self.net.name().is_some() || self.net.geo().is_some() {
235            self.dropped("network name/coordinate metadata is not emitted by this GLM profile");
236        }
237        for bus in self.net.buses() {
238            self.reserve(&bus.id)?;
239            if !bus.extras.is_empty() {
240                return Err(unsupported(format!(
241                    "bus {} has uninterpreted extras",
242                    bus.id
243                )));
244            }
245            if bus.location.is_some() || has_bus_bounds(bus) {
246                self.dropped(format!(
247                    "bus {}: coordinates/optimization voltage bounds are not emitted",
248                    bus.id
249                ));
250            }
251            if bus.grounded.as_slice() != ["n"]
252                || bus.terminals.iter().filter(|t| t.as_str() == "n").count() != 1
253            {
254                return Err(unsupported(format!(
255                    "bus {} requires explicit conductor/ground interpretation",
256                    bus.id
257                )));
258            }
259            if self.services.buses.contains_key(bus.id.as_str()) && bus.terminals != ["1", "2", "n"]
260            {
261                return Err(unsupported(
262                    "triplex buses need exactly two hot terminals and a grounded neutral",
263                ));
264            }
265            let phases = phases(
266                &bus.terminals
267                    .iter()
268                    .filter(|t| *t != "n")
269                    .cloned()
270                    .collect::<Vec<_>>(),
271            )?;
272            self.phases.insert(&bus.id, phases);
273        }
274        for line in self.net.lines() {
275            self.reserve(&line.name)?;
276        }
277        for t in self.net.transformers() {
278            self.reserve(&t.name)?;
279        }
280        for load in self.net.loads() {
281            self.reserve(&load.name)?;
282        }
283        for generator in self.net.generators() {
284            self.reserve(&generator.name)?;
285        }
286        for inverter in self.net.ibrs() {
287            self.reserve(&inverter.name)?;
288        }
289        for shunt in self.net.shunts() {
290            self.reserve(&shunt.name)?;
291        }
292        for switch in self.net.switches() {
293            self.reserve(&switch.name)?;
294        }
295        Ok(())
296    }
297
298    fn sources(&mut self) -> Result<(), Error> {
299        let buses: BTreeMap<_, _> = self
300            .net
301            .buses()
302            .iter()
303            .map(|b| (b.id.as_str(), b))
304            .collect();
305        for source in self.net.sources() {
306            let bus = buses.get(source.bus.as_str()).ok_or_else(|| {
307                unsupported(format!("source {} has no exact bus identity", source.name))
308            })?;
309            if !source.extras.is_empty() {
310                return Err(unsupported(format!(
311                    "source {} has uninterpreted electrical extras",
312                    source.name
313                )));
314            }
315            if source
316                .reference_terminal
317                .as_ref()
318                .is_some_and(|t| !bus.grounded.contains(t))
319            {
320                return Err(unsupported(format!(
321                    "source {} has a floating reference terminal",
322                    source.name
323                )));
324            }
325            if source.name != source.bus || source.energy_cost_rate.is_some() {
326                self.dropped(format!("source {} becomes the SWING state of bus {}; its independent name/cost is not emitted", source.name, source.bus));
327            }
328            let source_phases = phases(&source.terminal_map)?;
329            if source_phases.len() != source.v_magnitude.len()
330                || source_phases.len() != source.v_angle.len()
331            {
332                return Err(unsupported(format!(
333                    "source {} has incomplete phasors",
334                    source.name
335                )));
336            }
337            for (i, phase) in source_phases.into_iter().enumerate() {
338                let magnitude = source.v_magnitude[i];
339                let angle = source.v_angle[i];
340                if !magnitude.is_finite() || magnitude <= 0.0 || !angle.is_finite() {
341                    return Err(unsupported(
342                        "GLM SWING zero voltages select nominal defaults; nonpositive/nonfinite source phasors cannot be emitted",
343                    ));
344                }
345                if !self.phases[bus.id.as_str()].contains(&phase) {
346                    return Err(unsupported("source phase is absent from its bus"));
347                }
348                let value = Complex64::from_polar(magnitude, angle);
349                let voltages = self.source_voltages.entry(&bus.id).or_default();
350                if voltages.insert(phase, value).is_some() {
351                    return Err(unsupported(
352                        "multiple source constraints on one phase require explicit equivalence checking",
353                    ));
354                }
355            }
356        }
357        for (&bus, voltages) in &self.source_voltages {
358            if voltages.len() != self.phases[bus].len() {
359                return Err(unsupported(format!(
360                    "bus {bus}: partial SWING constraints would energize additional phases"
361                )));
362            }
363        }
364        Ok(())
365    }
366
367    #[allow(clippy::too_many_lines)] // Connected propagation includes distinct native winding bases.
368    fn nominal_voltages(&mut self) -> Result<(), Error> {
369        let mut neighbors: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
370        for line in self.net.lines() {
371            if self.services.buses.contains_key(line.bus_from.as_str()) {
372                neighbors
373                    .entry(&line.bus_from)
374                    .or_default()
375                    .push(&line.bus_to);
376                neighbors
377                    .entry(&line.bus_to)
378                    .or_default()
379                    .push(&line.bus_from);
380                continue;
381            }
382            self.link_phases(
383                &line.name,
384                &line.bus_from,
385                &line.bus_to,
386                &line.terminal_map_from,
387                &line.terminal_map_to,
388            )?;
389            neighbors
390                .entry(&line.bus_from)
391                .or_default()
392                .push(&line.bus_to);
393            neighbors
394                .entry(&line.bus_to)
395                .or_default()
396                .push(&line.bus_from);
397        }
398        if let Some(plan) = &self.ideal_plan {
399            for group in &plan.groups {
400                if plan.open_aliases.contains(&group.name) {
401                    continue;
402                }
403                neighbors.entry(group.from).or_default().push(group.to);
404                neighbors.entry(group.to).or_default().push(group.from);
405            }
406        }
407        let mut queue = VecDeque::new();
408        for (&bus, voltages) in &self.source_voltages {
409            let nominal = voltages
410                .values()
411                .next()
412                .expect("nonempty qualified source")
413                .norm();
414            self.nominal.insert(bus, nominal);
415            queue.push_back(bus);
416        }
417        let mut ratios: BTreeMap<&str, Vec<(&str, f64)>> = BTreeMap::new();
418        for t in self.net.transformers() {
419            if let Some(leader) = self.coil_banks.leaders.get(t.name.as_str()) {
420                if *leader != t.name {
421                    continue;
422                }
423                let bank = &self.coil_banks.banks[leader];
424                let a = &t.windings[0];
425                let b = &t.windings[1];
426                ratios
427                    .entry(&a.bus)
428                    .or_default()
429                    .push((&b.bus, bank.secondary / bank.primary));
430                ratios
431                    .entry(&b.bus)
432                    .or_default()
433                    .push((&a.bus, bank.primary / bank.secondary));
434                continue;
435            }
436            let a = &t.windings[0];
437            let b = &t.windings[1];
438            ratios
439                .entry(&a.bus)
440                .or_default()
441                .push((&b.bus, (b.v_ref * b.tap) / (a.v_ref * a.tap)));
442            ratios
443                .entry(&b.bus)
444                .or_default()
445                .push((&a.bus, (a.v_ref * a.tap) / (b.v_ref * b.tap)));
446        }
447        while let Some(bus) = queue.pop_front() {
448            let nominal = self.nominal[bus];
449            for &(neighbor, ratio) in ratios.get(bus).into_iter().flatten() {
450                if let std::collections::btree_map::Entry::Vacant(entry) =
451                    self.nominal.entry(neighbor)
452                {
453                    entry.insert(nominal * ratio);
454                    queue.push_back(neighbor);
455                }
456            }
457            for &neighbor in neighbors.get(bus).into_iter().flatten() {
458                if let std::collections::btree_map::Entry::Vacant(entry) =
459                    self.nominal.entry(neighbor)
460                {
461                    entry.insert(nominal);
462                    queue.push_back(neighbor);
463                }
464            }
465        }
466        if self.nominal.len() + self.services.core_buses.len() != self.net.buses().len() {
467            return Err(unsupported(
468                "a GLM bus island has no fixed source from which to initialize voltage",
469            ));
470        }
471        self.diagnostics.push(&codes::EMIT_GLM_VALUE_REBASED,
472            "bus nominal voltages are initialized from connected source magnitudes; P/I/Z coefficients retain their original voltage law");
473        Ok(())
474    }
475
476    fn buses(&mut self) -> Result<(), Error> {
477        for bus in self.net.buses() {
478            if self.services.core_buses.contains(bus.id.as_str()) {
479                continue;
480            }
481            if let Some(&phase) = self.services.buses.get(bus.id.as_str()) {
482                self.service_bus(bus, phase)?;
483                continue;
484            }
485            let phase_flags = flags(&self.phases[bus.id.as_str()]);
486            let voltage_error = self.voltage_error();
487            let _ = writeln!(
488                self.out,
489                "object node {{ name \"{}\"; phases {phase_flags}N; nominal_voltage {}; maximum_voltage_error {voltage_error};",
490                bus.id,
491                self.nominal[bus.id.as_str()]
492            );
493            if let Some(voltages) = self.source_voltages.get(bus.id.as_str()) {
494                self.out.push_str("    bustype SWING;\n");
495                for (&phase, &voltage) in voltages {
496                    let literal = complex_literal(voltage)?;
497                    let _ = writeln!(self.out, "    voltage_{} {literal};", phase_name(phase));
498                }
499            }
500            self.coordinates(crate::DistLocationTarget::Bus, &bus.id);
501            self.out.push_str("}\n");
502        }
503        Ok(())
504    }
505
506    fn link_phases(
507        &self,
508        name: &str,
509        from: &str,
510        to: &str,
511        from_map: &[String],
512        to_map: &[String],
513    ) -> Result<Vec<usize>, Error> {
514        if from_map != to_map {
515            return Err(unsupported(format!(
516                "link {name} permutes endpoint phase identities"
517            )));
518        }
519        let terminals = phases(from_map)?;
520        for bus in [from, to] {
521            let available = self
522                .phases
523                .get(bus)
524                .ok_or_else(|| unsupported(format!("link {name} has no exact endpoint {bus}")))?;
525            if terminals.iter().any(|p| !available.contains(p)) {
526                return Err(unsupported(format!(
527                    "link {name} uses an absent endpoint phase"
528                )));
529            }
530        }
531        Ok(terminals)
532    }
533
534    fn link_endpoints<'b>(&self, name: &str, from: &'b str, to: &'b str) -> (&'b str, &'b str) {
535        if self
536            .ideal_plan
537            .as_ref()
538            .is_some_and(|p| p.reversed.contains(name))
539        {
540            (to, from)
541        } else {
542            (from, to)
543        }
544    }
545
546    fn switches(&mut self) -> Result<(), Error> {
547        if let Some(plan) = self.ideal_plan.take() {
548            self.ideal_contacts(&plan);
549            self.ideal_plan = Some(plan);
550            return Ok(());
551        }
552        for switch in self.net.switches() {
553            if !switch.extras.is_empty() {
554                return Err(unsupported(format!(
555                    "switch {} has uninterpreted electrical extras",
556                    switch.name
557                )));
558            }
559            let phase_order = self.link_phases(
560                &switch.name,
561                &switch.bus_from,
562                &switch.bus_to,
563                &switch.terminal_map_from,
564                &switch.terminal_map_to,
565            )?;
566            let phase_flags: String = phase_order
567                .iter()
568                .map(|&p| super::read::phase_name(p))
569                .collect();
570            let _ = writeln!(
571                self.out,
572                "object switch {{\n    name {};\n    phases {phase_flags}N;\n    from {};\n    to {};\n    operating_mode INDIVIDUAL;\n    status OPEN;",
573                switch.name, switch.bus_from, switch.bus_to
574            );
575            for phase in phase_flags.chars() {
576                let _ = writeln!(self.out, "    phase_{phase}_state OPEN;");
577            }
578            self.coordinates(crate::DistLocationTarget::Switch, &switch.name);
579            self.out.push_str("}\n\n");
580            if switch.i_max.is_some() {
581                self.dropped(format!(
582                    "switch {}: contact current ratings are not emitted",
583                    switch.name
584                ));
585            }
586        }
587        Ok(())
588    }
589
590    #[allow(clippy::too_many_lines)] // A line and its coefficient/endpoint expansions stay together.
591    fn lines(&mut self) -> Result<(), Error> {
592        let codes: BTreeMap<_, _> = self
593            .net
594            .line_codes()
595            .iter()
596            .map(|c| (c.name.as_str(), c))
597            .collect();
598        let mut used_codes = BTreeSet::new();
599        for line in self.net.lines() {
600            if self.services.buses.contains_key(line.bus_from.as_str()) {
601                let code = codes
602                    .get(line.linecode.as_str())
603                    .ok_or_else(|| unsupported("service line has no linecode"))?;
604                used_codes.insert(code.name.as_str());
605                self.service_line(line, code)?;
606                continue;
607            }
608            if self.current_form_line(line, &codes)? {
609                used_codes.insert(line.linecode.as_str());
610                continue;
611            }
612            let phase_order = self.link_phases(
613                &line.name,
614                &line.bus_from,
615                &line.bus_to,
616                &line.terminal_map_from,
617                &line.terminal_map_to,
618            )?;
619            let code = codes.get(line.linecode.as_str()).ok_or_else(|| {
620                unsupported(format!("line {} has no exact linecode identity", line.name))
621            })?;
622            used_codes.insert(code.name.as_str());
623            if !line.extras.is_empty() || !code.extras.is_empty() {
624                return Err(unsupported(format!(
625                    "line {} has uninterpreted electrical extras",
626                    line.name
627                )));
628            }
629            if line.route.is_some()
630                || line.i_max.is_some()
631                || line.s_max.is_some()
632                || code.i_max.is_some()
633                || code.s_max.is_some()
634                || code.source.is_some()
635            {
636                self.dropped(format!(
637                    "line {}: route, ratings and linecode provenance are not emitted",
638                    line.name
639                ));
640            }
641            validate_line_matrices(line, code, phase_order.len())?;
642            let expanded_shunts = Self::expand_line_shunts(code);
643            let config = self.generated_name(&format!("{}__glm_configuration", line.name));
644            let _ = writeln!(self.out, "object line_configuration {{ name \"{config}\";");
645            self.coordinates(crate::DistLocationTarget::LineCode, &code.name);
646            for (row, &a) in phase_order.iter().enumerate() {
647                for (col, &b) in phase_order.iter().enumerate() {
648                    let z = Complex64::new(code.r_series[row][col], code.x_series[row][col])
649                        * METERS_PER_MILE;
650                    let literal = complex_literal(z)?;
651                    let capacitance = if expanded_shunts {
652                        0.0
653                    } else {
654                        code.b_from[row][col]
655                    } * METERS_PER_MILE
656                        / (std::f64::consts::PI * self.net.base_frequency() * 1e-9);
657                    if !capacitance.is_finite() {
658                        return Err(unsupported("line capacitance conversion overflows"));
659                    }
660                    let _ = writeln!(
661                        self.out,
662                        "    z{}{} {literal}; c{}{} {capacitance};",
663                        a + 1,
664                        b + 1,
665                        a + 1,
666                        b + 1
667                    );
668                }
669            }
670            self.out.push_str("}\n");
671            let (from, to) = self.link_endpoints(&line.name, &line.bus_from, &line.bus_to);
672            let _ = writeln!(
673                self.out,
674                "object overhead_line {{ name \"{}\"; phases {}N; from \"{}\"; to \"{}\"; length {} m; configuration \"{config}\";",
675                line.name,
676                flags(&phase_order),
677                from,
678                to,
679                line.length
680            );
681            self.coordinates(crate::DistLocationTarget::Line, &line.name);
682            self.out.push_str("}\n");
683            if expanded_shunts {
684                self.line_shunts(line, code, &phase_order)?;
685            }
686            self.diagnostics.push(
687                &codes::EMIT_GLM_VALUE_REBASED,
688                format!(
689                    "line {}: linecode {} is materialized as phase-indexed configuration {config}",
690                    line.name, code.name
691                ),
692            );
693        }
694        for code in self.net.line_codes() {
695            if !used_codes.contains(code.name.as_str()) {
696                self.dropped(format!("unused linecode {} is not emitted", code.name));
697            }
698        }
699        Ok(())
700    }
701
702    fn loads(&mut self) -> Result<(), Error> {
703        for load in self.net.loads() {
704            if self.services.core_loads.contains(load.name.as_str()) {
705                continue;
706            }
707            if self.services.buses.contains_key(load.bus.as_str()) {
708                self.service_load(load)?;
709                continue;
710            }
711            if load.configuration != Configuration::Wye {
712                self.pair_load(load)?;
713                continue;
714            }
715            if load.configuration != Configuration::Wye
716                || load.terminal_map.last().map(String::as_str) != Some("n")
717            {
718                return Err(unsupported(format!(
719                    "load {} requires an additional connection model",
720                    load.name
721                )));
722            }
723            if !load.extras.is_empty() {
724                return Err(unsupported(format!(
725                    "load {} has uninterpreted electrical extras",
726                    load.name
727                )));
728            }
729            let phase_order = phases(&load.terminal_map[..load.terminal_map.len() - 1])?;
730            let available = self.phases.get(load.bus.as_str()).ok_or_else(|| {
731                unsupported(format!("load {} has no exact bus identity", load.name))
732            })?;
733            if phase_order.iter().any(|p| !available.contains(p))
734                || load.p_nom.len() != phase_order.len()
735                || load.q_nom.len() != phase_order.len()
736            {
737                return Err(unsupported(format!(
738                    "load {} has incomplete terminal/power vectors",
739                    load.name
740                )));
741            }
742            let (law, v_nom) = match &load.voltage_model {
743                DistLoadVoltageModel::ConstantPower { v_nom } => ("power", v_nom),
744                DistLoadVoltageModel::ConstantCurrent { v_nom } => ("current", v_nom),
745                DistLoadVoltageModel::ConstantImpedance { v_nom } => ("impedance", v_nom),
746                _ => {
747                    return Err(unsupported(format!(
748                        "load {} requires another voltage-law mapping",
749                        load.name
750                    )));
751                }
752            };
753            if law != "power"
754                && (v_nom.len() != phase_order.len()
755                    || v_nom.iter().any(|v| !v.is_finite() || *v <= 0.0))
756            {
757                return Err(unsupported(format!(
758                    "load {} needs positive finite per-phase nominal voltages",
759                    load.name
760                )));
761            }
762            let _ = writeln!(
763                self.out,
764                "object load {{ name \"{}\"; parent \"{}\"; phases {}N; nominal_voltage {};",
765                load.name,
766                load.bus,
767                flags(&phase_order),
768                self.nominal[load.bus.as_str()]
769            );
770            self.coordinates(crate::DistLocationTarget::Load, &load.name);
771            for (i, &phase) in phase_order.iter().enumerate() {
772                let power = Complex64::new(load.p_nom[i], load.q_nom[i]);
773                let value = match law {
774                    "power" => power,
775                    "current" => nominal_phasor(1.0, phase) * power.conj() / v_nom[i],
776                    _ if power == Complex64::default() => Complex64::default(),
777                    _ => v_nom[i] * v_nom[i] / power.conj(),
778                };
779                if power != Complex64::default() && value == Complex64::default() {
780                    return Err(unsupported(
781                        "load coefficient conversion underflows to zero",
782                    ));
783                }
784                let literal = complex_literal(value)?;
785                let _ = writeln!(
786                    self.out,
787                    "    constant_{law}_{} {literal};",
788                    phase_name(phase)
789                );
790            }
791            self.out.push_str("}\n");
792        }
793        Ok(())
794    }
795
796    fn reserve(&mut self, name: &str) -> Result<(), Error> {
797        if name.is_empty()
798            || !name
799                .bytes()
800                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
801        {
802            return Err(unsupported(format!(
803                "object name {name:?} needs qualified GLM escaping"
804            )));
805        }
806        if !self.identities.insert(name.to_ascii_lowercase()) {
807            return Err(unsupported(format!(
808                "object name {name:?} collides in the GLM namespace"
809            )));
810        }
811        Ok(())
812    }
813
814    fn generated_name(&mut self, base: &str) -> String {
815        if self.identities.insert(base.to_ascii_lowercase()) {
816            return base.to_owned();
817        }
818        for i in 1usize.. {
819            let name = format!("{base}_{i}");
820            if self.identities.insert(name.to_ascii_lowercase()) {
821                return name;
822            }
823        }
824        unreachable!("bounded network cannot exhaust usize names")
825    }
826
827    fn dropped(&mut self, message: impl Into<String>) {
828        self.diagnostics
829            .push(&codes::EMIT_GLM_FIELD_DROPPED, message);
830    }
831}
832
833fn validate_line_matrices(line: &DistLine, code: &DistLineCode, n: usize) -> Result<(), Error> {
834    if code.n_conductors != n
835        || !line.length.is_finite()
836        || line.length <= 0.0
837        || !(line.length / 0.3048).is_finite()
838    {
839        return Err(unsupported(format!(
840            "line {} has invalid conductor count or length",
841            line.name
842        )));
843    }
844    for matrix in [
845        &code.r_series,
846        &code.x_series,
847        &code.g_from,
848        &code.b_from,
849        &code.g_to,
850        &code.b_to,
851    ] {
852        if matrix.len() != n
853            || matrix
854                .iter()
855                .any(|r| r.len() != n || r.iter().any(|v| !v.is_finite()))
856        {
857            return Err(unsupported(format!(
858                "line {} has an invalid conductor matrix",
859                line.name
860            )));
861        }
862    }
863    if (0..n).all(|i| code.r_series[i][i] == 0.0 && code.x_series[i][i] == 0.0) {
864        return Err(unsupported(format!(
865            "line {} has no nonzero diagonal to select native explicit-matrix mode",
866            line.name
867        )));
868    }
869    Ok(())
870}
871
872fn phases(terminals: &[String]) -> Result<Vec<usize>, Error> {
873    let mut seen = BTreeSet::new();
874    let mut result = Vec::new();
875    for terminal in terminals {
876        let phase = match terminal.as_str() {
877            "1" => 0,
878            "2" => 1,
879            "3" => 2,
880            _ => {
881                return Err(unsupported(format!(
882                    "terminal {terminal:?} needs additional phase/neutral interpretation"
883                )));
884            }
885        };
886        if !seen.insert(phase) {
887            return Err(unsupported("repeated phase terminal"));
888        }
889        result.push(phase);
890    }
891    if result.is_empty() {
892        return Err(unsupported("empty phase set"));
893    }
894    Ok(result)
895}
896
897fn flags(phases: &[usize]) -> String {
898    // Native sets have fixed A/B/C order; matrices and properties are emitted
899    // with explicit phase indices, independently of canonical terminal order.
900    let set: BTreeSet<_> = phases.iter().copied().collect();
901    set.into_iter().map(phase_name).collect()
902}
903
904fn complex_literal(value: Complex64) -> Result<String, Error> {
905    if !value.re.is_finite() || !value.im.is_finite() {
906        return Err(unsupported(
907            "GLM electrical literal is nonfinite or its unit conversion overflowed",
908        ));
909    }
910    Ok(format!("{}{:+}j", value.re, value.im))
911}
912
913fn has_bus_bounds(bus: &DistBus) -> bool {
914    bus.v_min.is_some()
915        || bus.v_max.is_some()
916        || bus.v_min_phase.is_some()
917        || bus.v_max_phase.is_some()
918        || bus.vpn_min.is_some()
919        || bus.vpn_max.is_some()
920        || bus.vpp_min.is_some()
921        || bus.vpp_max.is_some()
922        || bus.vpos_min.is_some()
923        || bus.vpos_max.is_some()
924        || bus.vneg_max.is_some()
925        || bus.vzero_max.is_some()
926        || bus.vn_max.is_some()
927}