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
27pub 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 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)] 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)] 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 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}