1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::DistLocationTarget;
13use crate::geo::ComponentLocationEdits;
14use crate::{
15 Configuration, DistCapacitor, DistGenerator, DistIbr, DistLine, DistLineCode,
16 DistLoadVoltageModel, DistShunt, Error, IbrPrimeMover, IbrTopology, MulticonductorNetwork,
17 NeutralKronOptions, NeutralKronReport, Result, neutral_kron_reduce,
18};
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[serde(rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum LinDist3FlowPreparationPolicy {
27 #[default]
29 Reject,
30 Lower,
32 Approximate,
34 Permissive,
36}
37
38impl LinDist3FlowPreparationPolicy {
39 const fn level(self) -> u8 {
40 match self {
41 Self::Reject => 0,
42 Self::Lower => 1,
43 Self::Approximate => 2,
44 Self::Permissive => 3,
45 }
46 }
47
48 const fn at_least(self, required: Self) -> bool {
49 self.level() >= required.level()
50 }
51
52 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Reject => "reject",
56 Self::Lower => "lower",
57 Self::Approximate => "approximate",
58 Self::Permissive => "permissive",
59 }
60 }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "snake_case")]
67#[non_exhaustive]
68pub enum LinDist3FlowPreparationActionKind {
69 NeutralKronReduced,
70 OpenSwitchOmitted,
71 ClosedSwitchLowered,
72 LineShuntLowered,
73 CapacitorLowered,
74 LoadApproximated,
75 IbrApproximated,
76 StaticControlFrozen,
77 UntypedObjectOmitted,
78 InitialPointOmitted,
79}
80
81impl LinDist3FlowPreparationActionKind {
82 #[must_use]
83 pub const fn as_str(self) -> &'static str {
84 match self {
85 Self::NeutralKronReduced => "neutral_kron_reduced",
86 Self::OpenSwitchOmitted => "open_switch_omitted",
87 Self::ClosedSwitchLowered => "closed_switch_lowered",
88 Self::LineShuntLowered => "line_shunt_lowered",
89 Self::CapacitorLowered => "capacitor_lowered",
90 Self::LoadApproximated => "load_approximated",
91 Self::IbrApproximated => "ibr_approximated",
92 Self::StaticControlFrozen => "static_control_frozen",
93 Self::UntypedObjectOmitted => "untyped_object_omitted",
94 Self::InitialPointOmitted => "initial_point_omitted",
95 }
96 }
97
98 #[must_use]
101 pub const fn is_approximation(self) -> bool {
102 matches!(
103 self,
104 Self::LoadApproximated
105 | Self::IbrApproximated
106 | Self::StaticControlFrozen
107 | Self::UntypedObjectOmitted
108 | Self::InitialPointOmitted
109 )
110 }
111
112 #[must_use]
115 pub const fn is_omission(self) -> bool {
116 matches!(
117 self,
118 Self::OpenSwitchOmitted
119 | Self::StaticControlFrozen
120 | Self::UntypedObjectOmitted
121 | Self::InitialPointOmitted
122 )
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129#[non_exhaustive]
130pub struct LinDist3FlowPreparationAction {
131 pub kind: LinDist3FlowPreparationActionKind,
132 pub source: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub target: Option<String>,
135 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
136 pub details: BTreeMap<String, Value>,
137}
138
139impl LinDist3FlowPreparationAction {
140 #[must_use]
143 pub fn new(
144 kind: LinDist3FlowPreparationActionKind,
145 source: impl Into<String>,
146 target: Option<String>,
147 ) -> Self {
148 Self {
149 kind,
150 source: source.into(),
151 target,
152 details: BTreeMap::new(),
153 }
154 }
155}
156
157#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
159#[non_exhaustive]
160pub struct LinDist3FlowPreparationReport {
161 pub policy: LinDist3FlowPreparationPolicy,
162 pub actions: Vec<LinDist3FlowPreparationAction>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub neutral_kron: Option<NeutralKronReport>,
165}
166
167#[derive(Clone, Debug)]
169pub struct LinDist3FlowPreparedNetwork {
170 network: MulticonductorNetwork,
171 report: LinDist3FlowPreparationReport,
172}
173
174impl LinDist3FlowPreparedNetwork {
175 #[must_use]
176 pub fn network(&self) -> &MulticonductorNetwork {
177 &self.network
178 }
179
180 #[must_use]
181 pub const fn report(&self) -> &LinDist3FlowPreparationReport {
182 &self.report
183 }
184
185 #[must_use]
186 pub fn into_parts(self) -> (MulticonductorNetwork, LinDist3FlowPreparationReport) {
187 (self.network, self.report)
188 }
189}
190
191fn fail(message: impl Into<String>) -> Error {
192 Error::LinDist3FlowPreparation {
193 message: message.into(),
194 }
195}
196
197fn unique_name(prefix: &str, names: &mut BTreeSet<String>) -> String {
198 let mut candidate = prefix.to_owned();
199 let mut suffix = 2usize;
200 while !names.insert(candidate.to_ascii_lowercase()) {
201 candidate = format!("{prefix}-{suffix}");
202 suffix += 1;
203 }
204 candidate
205}
206
207fn zero_matrix(n: usize) -> Vec<Vec<f64>> {
208 vec![vec![0.0; n]; n]
209}
210
211fn scaled(matrix: &[Vec<f64>], scale: f64) -> Vec<Vec<f64>> {
212 matrix
213 .iter()
214 .map(|row| row.iter().map(|value| value * scale).collect())
215 .collect()
216}
217
218fn nonzero(matrix: &[Vec<f64>]) -> bool {
219 matrix.iter().flatten().any(|value| *value != 0.0)
220}
221
222fn lower_line_shunts(
223 network: &mut MulticonductorNetwork,
224 report: &mut LinDist3FlowPreparationReport,
225) -> Result<()> {
226 let lines = network.lines().clone();
227 let mut names = network
228 .shunts()
229 .iter()
230 .map(|shunt| shunt.name.to_ascii_lowercase())
231 .collect::<BTreeSet<_>>();
232 let mut additions = Vec::new();
233 for line in &lines {
234 let code = network.linecode(&line.linecode).ok_or_else(|| {
235 fail(format!(
236 "line `{}` names missing linecode `{}`",
237 line.name, line.linecode
238 ))
239 })?;
240 let n = line.terminal_map_from.len();
241 if line.terminal_map_to.len() != n
242 || [&code.g_from, &code.b_from, &code.g_to, &code.b_to]
243 .into_iter()
244 .any(|matrix| matrix.len() != n || matrix.iter().any(|row| row.len() != n))
245 {
246 return Err(fail(format!(
247 "line `{}` shunt matrices do not align with its terminal maps",
248 line.name
249 )));
250 }
251 for (side, bus, terminals, g, b) in [
252 (
253 "from",
254 &line.bus_from,
255 &line.terminal_map_from,
256 &code.g_from,
257 &code.b_from,
258 ),
259 (
260 "to",
261 &line.bus_to,
262 &line.terminal_map_to,
263 &code.g_to,
264 &code.b_to,
265 ),
266 ] {
267 if !nonzero(g) && !nonzero(b) {
268 continue;
269 }
270 let name = unique_name(
271 &format!("__l3f-line-{}-{side}-shunt", line.name),
272 &mut names,
273 );
274 additions.push(DistShunt::new(
275 &name,
276 bus,
277 terminals.clone(),
278 scaled(g, line.length),
279 scaled(b, line.length),
280 ));
281 report.actions.push(LinDist3FlowPreparationAction::new(
282 LinDist3FlowPreparationActionKind::LineShuntLowered,
283 format!("line.{}", line.name),
284 Some(format!("shunt.{name}")),
285 ));
286 }
287 }
288 network.shunts_mut().extend(additions);
289 for code in network.line_codes_mut() {
290 code.g_from = zero_matrix(code.n_conductors);
291 code.b_from = zero_matrix(code.n_conductors);
292 code.g_to = zero_matrix(code.n_conductors);
293 code.b_to = zero_matrix(code.n_conductors);
294 }
295 Ok(())
296}
297
298fn lower_switches(
299 network: &mut MulticonductorNetwork,
300 report: &mut LinDist3FlowPreparationReport,
301) -> Result<()> {
302 let mut locations = ComponentLocationEdits::new(network);
303 let switches = std::mem::take(network.switches_mut());
304 let mut line_names = network
305 .lines()
306 .iter()
307 .map(|line| line.name.to_ascii_lowercase())
308 .collect::<BTreeSet<_>>();
309 let mut code_names = network
310 .line_codes()
311 .iter()
312 .map(|code| code.name.to_ascii_lowercase())
313 .collect::<BTreeSet<_>>();
314 for switch in switches {
315 let source = format!("switch.{}", switch.name);
316 if switch.open {
317 let mut action = LinDist3FlowPreparationAction::new(
318 LinDist3FlowPreparationActionKind::OpenSwitchOmitted,
319 source,
320 None,
321 );
322 locations.rewrite(
323 DistLocationTarget::Switch,
324 &switch.name,
325 None,
326 &mut action.details,
327 );
328 report.actions.push(action);
329 continue;
330 }
331 let n = switch.terminal_map_from.len();
332 if n == 0 || switch.terminal_map_to.len() != n {
333 return Err(fail(format!(
334 "closed switch `{}` has empty or unequal terminal maps",
335 switch.name
336 )));
337 }
338 if switch
339 .i_max
340 .as_ref()
341 .is_some_and(|limits| limits.len() != n)
342 {
343 return Err(fail(format!(
344 "closed switch `{}` current limits do not align with its terminal maps",
345 switch.name
346 )));
347 }
348 let line_name = unique_name(&format!("__l3f-switch-{}", switch.name), &mut line_names);
349 let code_name = unique_name(
350 &format!("__l3f-switch-{}-ideal", switch.name),
351 &mut code_names,
352 );
353 network.line_codes_mut().push(DistLineCode::new(
354 &code_name,
355 zero_matrix(n),
356 zero_matrix(n),
357 ));
358 let mut line = DistLine::new(
359 &line_name,
360 switch.bus_from,
361 switch.bus_to,
362 switch.terminal_map_from,
363 switch.terminal_map_to,
364 code_name,
365 1.0,
366 );
367 line.i_max = switch.i_max;
368 network.lines_mut().push(line);
369 let mut action = LinDist3FlowPreparationAction::new(
370 LinDist3FlowPreparationActionKind::ClosedSwitchLowered,
371 source,
372 Some(format!("line.{line_name}")),
373 );
374 action.details.insert(
375 "flow_identity".to_owned(),
376 json!("independent_contact_flow"),
377 );
378 locations.rewrite(
379 DistLocationTarget::Switch,
380 &switch.name,
381 Some((DistLocationTarget::Line, &line_name)),
382 &mut action.details,
383 );
384 report.actions.push(action);
385 }
386 locations.apply(network);
387 Ok(())
388}
389
390fn capacitor_matrix(capacitor: &DistCapacitor) -> Result<Vec<Vec<f64>>> {
391 if !capacitor.q_rated.is_finite()
392 || !capacitor.v_nom.is_finite()
393 || capacitor.v_nom <= 0.0
394 || capacitor.terminal_map.is_empty()
395 {
396 return Err(fail(format!(
397 "capacitor `{}` has invalid rating, nominal voltage, or terminal map",
398 capacitor.name
399 )));
400 }
401 let n = capacitor.terminal_map.len();
402 let mut matrix = zero_matrix(n);
403 let mut couple = |a: usize, b: usize, value: f64| {
404 matrix[a][a] += value;
405 matrix[b][b] += value;
406 matrix[a][b] -= value;
407 matrix[b][a] -= value;
408 };
409 match capacitor.configuration {
410 Configuration::Wye if n == 1 => {
412 matrix[0][0] = capacitor.q_rated / capacitor.v_nom.powi(2);
413 }
414 Configuration::Wye => {
417 let phases = n - 1;
418 let v_ln = capacitor.v_nom / 3f64.sqrt();
419 #[allow(clippy::cast_precision_loss)]
420 let value = capacitor.q_rated / (phases as f64) / v_ln.powi(2);
421 for phase in 0..phases {
422 couple(phase, phases, value);
423 }
424 }
425 Configuration::SinglePhase if n == 2 => {
426 couple(0, 1, capacitor.q_rated / capacitor.v_nom.powi(2));
427 }
428 Configuration::Delta if n == 3 => {
429 let value = capacitor.q_rated / (3.0 * capacitor.v_nom.powi(2));
430 for pair in 0..3 {
431 couple(pair, (pair + 1) % 3, value);
432 }
433 }
434 _ => {
435 return Err(fail(format!(
436 "capacitor `{}` {:?} connection with {n} terminals is not supported",
437 capacitor.name, capacitor.configuration
438 )));
439 }
440 }
441 Ok(matrix)
442}
443
444fn lower_capacitors(
445 network: &mut MulticonductorNetwork,
446 report: &mut LinDist3FlowPreparationReport,
447) -> Result<()> {
448 let mut locations = ComponentLocationEdits::new(network);
449 let capacitors = std::mem::take(network.capacitors_mut());
450 let mut names = network
451 .shunts()
452 .iter()
453 .map(|shunt| shunt.name.to_ascii_lowercase())
454 .collect::<BTreeSet<_>>();
455 for capacitor in capacitors {
456 let matrix = capacitor_matrix(&capacitor)?;
457 let name = unique_name(&format!("__l3f-capacitor-{}", capacitor.name), &mut names);
458 network.shunts_mut().push(DistShunt::new(
459 &name,
460 &capacitor.bus,
461 capacitor.terminal_map.clone(),
462 zero_matrix(matrix.len()),
463 matrix,
464 ));
465 let mut action = LinDist3FlowPreparationAction::new(
466 LinDist3FlowPreparationActionKind::CapacitorLowered,
467 format!("capacitor.{}", capacitor.name),
468 Some(format!("shunt.{name}")),
469 );
470 locations.rewrite(
471 DistLocationTarget::Capacitor,
472 &capacitor.name,
473 Some((DistLocationTarget::Shunt, &name)),
474 &mut action.details,
475 );
476 report.actions.push(action);
477 }
478 locations.apply(network);
479 Ok(())
480}
481
482fn nominal_bounded_load(load: &crate::DistLoad) -> Result<Option<DistLoadVoltageModel>> {
485 let model = &load.voltage_model;
486 let (low, min, max) = match model {
487 DistLoadVoltageModel::BoundedZip {
488 v_low_pu,
489 v_min_pu,
490 v_max_pu,
491 cutoff_pu,
492 ..
493 } if cutoff_pu.is_finite() && (0.0..1.0).contains(cutoff_pu) => {
494 (*v_low_pu, *v_min_pu, *v_max_pu)
495 }
496 DistLoadVoltageModel::BoundedExponential {
497 v_low_pu,
498 v_min_pu,
499 v_max_pu,
500 ..
501 } => (*v_low_pu, *v_min_pu, *v_max_pu),
502 DistLoadVoltageModel::BoundedConstantPower {
503 v_min_pu,
504 v_max_pu,
505 current_limit_pu,
506 ..
507 } if current_limit_pu.is_none_or(|limit| limit.is_finite() && limit > 1.0) => {
508 (0.0, *v_min_pu, *v_max_pu)
509 }
510 _ => return Ok(None),
511 };
512 if ![low, min, max].iter().all(|value| value.is_finite())
513 || !(0.0..1.0).contains(&low)
514 || min >= 1.0
515 || max <= 1.0
516 {
517 return Ok(None);
518 }
519 let n = load.p_nom.len();
520 if n == 0 || load.q_nom.len() != n || model.v_nom().len() != n {
521 return Err(fail(format!(
522 "load `{}` bounded law has invalid channel dimensions",
523 load.name
524 )));
525 }
526 for (channel, &voltage) in model.v_nom().iter().enumerate() {
527 model
528 .power_scale(channel, voltage)
529 .map_err(|error| fail(format!("load `{}`: {error}", load.name)))?;
530 }
531 Ok(Some(model.without_voltage_bounds()))
532}
533
534fn approximate_loads(
535 network: &mut MulticonductorNetwork,
536 report: &mut LinDist3FlowPreparationReport,
537) -> Result<()> {
538 let expand = |values: &[f64], channels: usize, label: &str| -> Result<Vec<f64>> {
539 match values.len() {
540 1 => Ok(vec![values[0]; channels]),
541 length if length == channels => Ok(values.to_vec()),
542 length => Err(fail(format!(
543 "{label} has length {length}, expected 1 or {channels}"
544 ))),
545 }
546 };
547 for load in network.loads_mut() {
548 let nominal = nominal_bounded_load(load)?;
549 let model = nominal.as_ref().unwrap_or(&load.voltage_model);
550 let approximation = match model {
551 DistLoadVoltageModel::ConstantCurrent { v_nom } => Some(DistLoadVoltageModel::Zip {
552 v_nom: v_nom.clone(),
553 alpha_z: vec![0.5; load.p_nom.len()],
554 alpha_i: vec![0.0; load.p_nom.len()],
555 alpha_p: vec![0.5; load.p_nom.len()],
556 beta_z: vec![0.5; load.q_nom.len()],
557 beta_i: vec![0.0; load.q_nom.len()],
558 beta_p: vec![0.5; load.q_nom.len()],
559 }),
560 DistLoadVoltageModel::Zip {
561 v_nom,
562 alpha_z,
563 alpha_i,
564 alpha_p,
565 beta_z,
566 beta_i,
567 beta_p,
568 } if alpha_i.iter().any(|value| value.abs() > f64::EPSILON)
569 || beta_i.iter().any(|value| value.abs() > f64::EPSILON) =>
570 {
571 let combine = |z: &[f64],
572 i: &[f64],
573 p: &[f64],
574 channels: usize|
575 -> Result<(Vec<f64>, Vec<f64>)> {
576 let z = expand(z, channels, "ZIP impedance fraction")?;
577 let i = expand(i, channels, "ZIP current fraction")?;
578 let p = expand(p, channels, "ZIP power fraction")?;
579 Ok((
580 z.iter().zip(&i).map(|(z, i)| z + 0.5 * i).collect(),
581 p.iter().zip(&i).map(|(p, i)| p + 0.5 * i).collect(),
582 ))
583 };
584 let (alpha_z, alpha_p) = combine(alpha_z, alpha_i, alpha_p, load.p_nom.len())?;
585 let (beta_z, beta_p) = combine(beta_z, beta_i, beta_p, load.q_nom.len())?;
586 Some(DistLoadVoltageModel::Zip {
587 v_nom: v_nom.clone(),
588 alpha_z,
589 alpha_i: vec![0.0; load.p_nom.len()],
590 alpha_p,
591 beta_z,
592 beta_i: vec![0.0; load.q_nom.len()],
593 beta_p,
594 })
595 }
596 DistLoadVoltageModel::Exponential {
597 v_nom,
598 gamma_p,
599 gamma_q,
600 } => {
601 let gamma_p = expand(gamma_p, load.p_nom.len(), "active exponent")?;
602 let gamma_q = expand(gamma_q, load.q_nom.len(), "reactive exponent")?;
603 Some(DistLoadVoltageModel::Zip {
604 v_nom: v_nom.clone(),
605 alpha_z: gamma_p.iter().map(|gamma| 0.5 * gamma).collect(),
606 alpha_i: vec![0.0; load.p_nom.len()],
607 alpha_p: gamma_p.iter().map(|gamma| 1.0 - 0.5 * gamma).collect(),
608 beta_z: gamma_q.iter().map(|gamma| 0.5 * gamma).collect(),
609 beta_i: vec![0.0; load.q_nom.len()],
610 beta_p: gamma_q.iter().map(|gamma| 1.0 - 0.5 * gamma).collect(),
611 })
612 }
613 _ => None,
614 }
615 .or(nominal);
616 if let Some(approximation) = approximation {
617 let source_model = json!(&load.voltage_model);
618 load.voltage_model = approximation;
619 let mut action = LinDist3FlowPreparationAction::new(
620 LinDist3FlowPreparationActionKind::LoadApproximated,
621 format!("load.{}", load.name),
622 Some(format!("load.{}", load.name)),
623 );
624 action.details.insert(
625 "method".to_owned(),
626 json!("first_order_squared_voltage_at_nominal"),
627 );
628 action
629 .details
630 .insert("source_voltage_model".into(), source_model);
631 report.actions.push(action);
632 }
633 }
634 Ok(())
635}
636
637fn ibr_configuration(ibr: &DistIbr) -> Result<Configuration> {
638 match (ibr.topology, ibr.terminal_map.len(), ibr.s_max.len()) {
639 (IbrTopology::SinglePhase, 1, 1) => Ok(Configuration::Wye),
640 (IbrTopology::SinglePhase, 2, 1) => Ok(Configuration::SinglePhase),
641 (IbrTopology::ThreeLeg | IbrTopology::FourLeg, terminals, phases)
642 if terminals == phases =>
643 {
644 Ok(Configuration::Wye)
645 }
646 _ => Err(fail(format!(
647 "IBR `{}` topology and terminal/rating dimensions cannot be mapped to a static generator",
648 ibr.name
649 ))),
650 }
651}
652
653fn approximate_ibrs(
654 network: &mut MulticonductorNetwork,
655 report: &mut LinDist3FlowPreparationReport,
656) -> Result<()> {
657 let mut locations = ComponentLocationEdits::new(network);
658 let ibrs = std::mem::take(network.ibrs_mut());
659 let mut names = network
660 .generators()
661 .iter()
662 .map(|generator| generator.name.to_ascii_lowercase())
663 .collect::<BTreeSet<_>>();
664 for ibr in ibrs {
665 let channels = ibr.s_max.len();
666 if channels == 0
667 || ibr
668 .s_max
669 .iter()
670 .any(|rating| !rating.is_finite() || *rating <= 0.0)
671 {
672 return Err(fail(format!(
673 "IBR `{}` has invalid phase ratings",
674 ibr.name
675 )));
676 }
677 let configuration = ibr_configuration(&ibr)?;
678 let name = unique_name(&format!("__l3f-ibr-{}", ibr.name), &mut names);
679 let total_rating = ibr.s_max.iter().sum::<f64>();
680 let p_nom = ibr.p_avail.map_or_else(
681 || vec![0.0; channels],
682 |available| {
683 ibr.s_max
684 .iter()
685 .map(|rating| available * rating / total_rating)
686 .collect()
687 },
688 );
689 let mut generator = DistGenerator::new(
690 &name,
691 &ibr.bus,
692 ibr.terminal_map.clone(),
693 configuration,
694 p_nom.clone(),
695 vec![0.0; channels],
696 );
697 generator.p_min = ibr.p_min.clone().or_else(|| {
698 (ibr.p_avail.is_some()
699 && matches!(ibr.prime_mover, IbrPrimeMover::Pv | IbrPrimeMover::Generic))
700 .then(|| vec![0.0; channels])
701 });
702 generator.p_max = ibr.p_max.clone().or_else(|| ibr.p_avail.map(|_| p_nom));
703 if generator.p_min.is_some() != generator.p_max.is_some() {
704 return Err(fail(format!(
705 "IBR `{}` supplies only one side of its active-power bounds",
706 ibr.name
707 )));
708 }
709 generator.q_min.clone_from(&ibr.q_min);
710 generator.q_max.clone_from(&ibr.q_max);
711 if generator.q_min.is_some() != generator.q_max.is_some() {
712 return Err(fail(format!(
713 "IBR `{}` supplies only one side of its reactive-power bounds",
714 ibr.name
715 )));
716 }
717 generator.s_max = Some(ibr.s_max.clone());
718 generator.i_max.clone_from(&ibr.i_max);
719 network.generators_mut().push(generator);
720 let mut action = LinDist3FlowPreparationAction::new(
721 LinDist3FlowPreparationActionKind::IbrApproximated,
722 format!("ibr.{}", ibr.name),
723 Some(format!("generator.{name}")),
724 );
725 action
726 .details
727 .insert("method".to_owned(), json!("static_dispatchable_generator"));
728 action
729 .details
730 .insert("control_profile".to_owned(), json!(&ibr.control_profile));
731 locations.rewrite(
732 DistLocationTarget::Ibr,
733 &ibr.name,
734 Some((DistLocationTarget::Generator, &name)),
735 &mut action.details,
736 );
737 report.actions.push(action);
738 if let Some(profile) = &ibr.control_profile {
739 report.actions.push(LinDist3FlowPreparationAction::new(
740 LinDist3FlowPreparationActionKind::StaticControlFrozen,
741 format!("control_profile.{profile}"),
742 Some(format!("generator.{name}")),
743 ));
744 }
745 }
746 locations.apply(network);
747 Ok(())
748}
749
750fn omit_controls_and_untyped(
751 network: &mut MulticonductorNetwork,
752 policy: LinDist3FlowPreparationPolicy,
753 report: &mut LinDist3FlowPreparationReport,
754) {
755 let objects = std::mem::take(network.untyped_objects_mut());
756 let mut retained = Vec::new();
757 for object in objects {
758 let control = matches!(
759 object.class.to_ascii_lowercase().as_str(),
760 "regcontrol" | "capcontrol" | "invcontrol" | "swtcontrol"
761 );
762 if control && policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
763 report.actions.push(LinDist3FlowPreparationAction::new(
764 LinDist3FlowPreparationActionKind::StaticControlFrozen,
765 format!("{}.{}", object.class, object.name),
766 None,
767 ));
768 } else if policy.at_least(LinDist3FlowPreparationPolicy::Permissive) {
769 report.actions.push(LinDist3FlowPreparationAction::new(
770 LinDist3FlowPreparationActionKind::UntypedObjectOmitted,
771 format!("{}.{}", object.class, object.name),
772 None,
773 ));
774 } else {
775 retained.push(object);
776 }
777 }
778 *network.untyped_objects_mut() = retained;
779 if policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
780 for profile in network.control_profiles() {
781 let source = format!("control_profile.{}", profile.name);
782 if !report.actions.iter().any(|action| action.source == source) {
783 report.actions.push(LinDist3FlowPreparationAction::new(
784 LinDist3FlowPreparationActionKind::StaticControlFrozen,
785 source,
786 None,
787 ));
788 }
789 }
790 network.control_profiles_mut().clear();
791 }
792}
793
794pub fn prepare_lindist3flow_network(
811 network: &MulticonductorNetwork,
812 policy: LinDist3FlowPreparationPolicy,
813) -> Result<LinDist3FlowPreparedNetwork> {
814 network
815 .validate_component_locations()
816 .map_err(|error| fail(error.to_string()))?;
817 let mut prepared = network.clone();
818 let mut report = LinDist3FlowPreparationReport {
819 policy,
820 ..LinDist3FlowPreparationReport::default()
821 };
822 if policy.at_least(LinDist3FlowPreparationPolicy::Lower) {
823 lower_capacitors(&mut prepared, &mut report)?;
826 let reduction = neutral_kron_reduce(&prepared, &NeutralKronOptions::default())?;
827 let (network, neutral_report) = reduction.into_parts();
828 prepared = network;
829 if !neutral_report.buses.is_empty() {
830 let mut action = LinDist3FlowPreparationAction::new(
831 LinDist3FlowPreparationActionKind::NeutralKronReduced,
832 "network",
833 Some("network".to_owned()),
834 );
835 action
836 .details
837 .insert("bus_count".to_owned(), json!(neutral_report.buses.len()));
838 report.actions.push(action);
839 report.neutral_kron = Some(neutral_report);
840 }
841 lower_switches(&mut prepared, &mut report)?;
842 lower_line_shunts(&mut prepared, &mut report)?;
843 }
844 if policy.at_least(LinDist3FlowPreparationPolicy::Approximate) {
845 approximate_loads(&mut prepared, &mut report)?;
846 approximate_ibrs(&mut prepared, &mut report)?;
847 }
848 omit_controls_and_untyped(&mut prepared, policy, &mut report);
849 prepared
850 .validate_component_locations()
851 .map_err(|error| fail(error.to_string()))?;
852 crate::transform_audit::store(
853 &mut prepared,
854 "powerio_lindist3flow_preparation",
855 json!({
856 "policy": report.policy,
857 "actions": report.actions,
858 }),
859 )
860 .map_err(fail)?;
861 Ok(LinDist3FlowPreparedNetwork {
862 network: prepared,
863 report,
864 })
865}