Skip to main content

powerio_tnd/
integrated.rs

1//! Composition of balanced transmission and conductor-resolved feeder modules.
2use num_complex::Complex64;
3use powerio_core::{Error, PioModule};
4use powerio_dist::MulticonductorNetwork;
5use powerio_tx::{BalancedNetwork, BusId, BusType};
6use std::collections::{BTreeMap, BTreeSet};
7use std::f64::consts::TAU;
8
9use crate::transform::UnionFind;
10
11/// How the feeder's computed demand enters the transmission balance.
12#[derive(Clone, Debug, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum TransmissionDemand {
15    /// Additional demand, with every existing transmission load retained.
16    Additional,
17    /// Substitute feeder demand for these transmission load UIDs in coupled equations.
18    /// The component modules retain the original load records.
19    ReplaceLoads(Vec<String>),
20}
21
22/// A three-phase interface from a balanced bus to an earth-referenced feeder source.
23///
24/// The consumer replaces the selected source's three phase voltage prescriptions
25/// with the coupled voltage equations. Extra source terminals remain fixed at zero.
26/// Source names and terminal names are exact identifiers within the named feeder.
27/// The source's stated phase voltages must agree with the interface: each within
28/// 0.5 to 2 times the bus voltage times `voltage_ratio`, in A, B, C rotation.
29#[derive(Clone, Debug, PartialEq)]
30#[non_exhaustive]
31pub struct Interface {
32    pub name: String,
33    pub transmission_bus: BusId,
34    pub feeder: String,
35    pub voltage_source: String,
36    /// Feeder terminals corresponding to positive-sequence A, B and C, in order.
37    pub phase_terminals: [String; 3],
38    /// Feeder voltage divided by transmission voltage, on the same phase-to-earth basis.
39    pub voltage_ratio: f64,
40    /// Feeder phase A angle minus transmission positive-sequence angle, radians.
41    pub angle_shift_radians: f64,
42    pub demand: TransmissionDemand,
43}
44impl Interface {
45    /// `voltage_ratio` is feeder phase-to-earth voltage divided by transmission
46    /// phase-to-earth voltage; there is no default, since equal bases are rare.
47    pub fn new(
48        name: impl Into<String>,
49        transmission_bus: BusId,
50        feeder: impl Into<String>,
51        voltage_source: impl Into<String>,
52        phase_terminals: [String; 3],
53        voltage_ratio: f64,
54        demand: TransmissionDemand,
55    ) -> Self {
56        Self {
57            name: name.into(),
58            transmission_bus,
59            feeder: feeder.into(),
60            voltage_source: voltage_source.into(),
61            phase_terminals,
62            voltage_ratio,
63            angle_shift_radians: 0.0,
64            demand,
65        }
66    }
67}
68
69/// Validated immutable composition. Component modules retain source bytes and records.
70///
71/// This is an input model for coupled calculations. Constructing it neither solves
72/// the networks nor changes their standalone equations. A consumer must apply each
73/// interface's source substitution and explicit transmission demand policy.
74/// Interfaces do not imply an automatic voltage-based partition of a source model.
75#[derive(Clone, Debug)]
76pub struct IntegratedNetwork {
77    transmission: PioModule<BalancedNetwork>,
78    feeders: BTreeMap<String, PioModule<MulticonductorNetwork>>,
79    interfaces: Vec<Interface>,
80}
81impl IntegratedNetwork {
82    pub fn new(
83        transmission: PioModule<BalancedNetwork>,
84        feeders: BTreeMap<String, PioModule<MulticonductorNetwork>>,
85        interfaces: Vec<Interface>,
86    ) -> Result<Self, Error> {
87        let value = Self {
88            transmission,
89            feeders,
90            interfaces,
91        };
92        value.validate()?;
93        Ok(value)
94    }
95    pub fn transmission(&self) -> &PioModule<BalancedNetwork> {
96        &self.transmission
97    }
98    pub fn feeders(&self) -> &BTreeMap<String, PioModule<MulticonductorNetwork>> {
99        &self.feeders
100    }
101    pub fn interfaces(&self) -> &[Interface] {
102        &self.interfaces
103    }
104
105    /// Convert positive-sequence transmission voltage (per unit) to feeder A/B/C
106    /// phase-to-earth phasors in volts. Uses the bus line-to-line kV base and the
107    /// interface ratio; component MVA bases do not enter this voltage conversion.
108    pub fn boundary_voltages(
109        &self,
110        interface: &str,
111        voltage_pu: Complex64,
112    ) -> Result<[Complex64; 3], Error> {
113        let link = self
114            .interfaces
115            .iter()
116            .find(|i| i.name == interface)
117            .ok_or_else(|| invalid("unknown interface"))?;
118        let bus = self
119            .transmission
120            .value()
121            .buses()
122            .iter()
123            .find(|b| b.id == link.transmission_bus)
124            .ok_or_else(|| invalid("interface transmission bus is missing"))?;
125        let a = voltage_pu
126            * (bus.base_kv * 1000.0 / 3.0_f64.sqrt() * link.voltage_ratio)
127            * Complex64::from_polar(1.0, link.angle_shift_radians);
128        let phases = [
129            a,
130            a * Complex64::from_polar(1.0, -TAU / 3.0),
131            a * Complex64::from_polar(1.0, TAU / 3.0),
132        ];
133        if phases.iter().any(|v| !finite(*v)) {
134            return Err(invalid("boundary voltage is nonfinite or overflows"));
135        }
136        Ok(phases)
137    }
138
139    #[allow(clippy::too_many_lines)]
140    fn validate(&self) -> Result<(), Error> {
141        let tx = self.transmission.value();
142        tx.validate().map_err(invalid)?;
143        tx.check_base_mva().map_err(invalid)?;
144        if !positive(tx.base_frequency()) || self.feeders.is_empty() || self.interfaces.is_empty() {
145            return Err(invalid(
146                "composition needs a positive frequency, feeders, and interfaces",
147            ));
148        }
149        let mut classes = BTreeMap::new();
150        for (id, module) in &self.feeders {
151            identifier(id)?;
152            let net = module.value();
153            powerio_dist::require_electrical_readiness(net)?;
154            let refs = powerio_dist::find_unresolved_references(net);
155            if !refs.is_empty() {
156                return Err(invalid(format!("feeder {id}: {refs:?}")));
157            }
158            if !positive(net.base_frequency())
159                || (net.base_frequency() - tx.base_frequency()).abs() > 1e-9
160            {
161                return Err(invalid(format!(
162                    "feeder {id} frequency differs from transmission"
163                )));
164            }
165            classes.insert(id, terminal_classes(net)?);
166        }
167        let mut names = BTreeSet::new();
168        let mut sources = BTreeSet::new();
169        let mut terminals = BTreeSet::new();
170        let mut replaced = BTreeSet::new();
171        let mut connected = BTreeSet::new();
172        for link in &self.interfaces {
173            identifier(&link.name)?;
174            if !names.insert(&link.name) {
175                return Err(invalid("duplicate interface name"));
176            }
177            if !positive(link.voltage_ratio) || !link.angle_shift_radians.is_finite() {
178                return Err(invalid(
179                    "interface voltage ratio must be positive and angle finite",
180                ));
181            }
182            let bus = tx
183                .buses()
184                .iter()
185                .find(|b| b.id == link.transmission_bus)
186                .ok_or_else(|| invalid("unknown transmission bus"))?;
187            if bus.kind == BusType::Isolated || !positive(bus.base_kv) {
188                return Err(invalid(
189                    "interface requires an active bus with positive voltage base",
190                ));
191            }
192            let nominal = bus.base_kv * 1000.0 / 3.0_f64.sqrt() * link.voltage_ratio;
193            let net = self
194                .feeders
195                .get(&link.feeder)
196                .ok_or_else(|| invalid("unknown feeder"))?
197                .value();
198            connected.insert(&link.feeder);
199            let matches: Vec<_> = net
200                .sources()
201                .iter()
202                .filter(|s| s.name == link.voltage_source)
203                .collect();
204            if matches.len() != 1 || !sources.insert((&link.feeder, &link.voltage_source)) {
205                return Err(invalid(
206                    "interface source must exist exactly once and belong to one interface",
207                ));
208            }
209            let source = matches[0];
210            let bus = net
211                .bus(&source.bus)
212                .ok_or_else(|| invalid("unknown source bus"))?;
213            if source.reference_terminal.is_some() {
214                return Err(invalid(
215                    "this interface requires an earth-referenced source",
216                ));
217            }
218            let count = source.terminal_map.len();
219            if count != source.v_magnitude.len()
220                || count != source.v_angle.len()
221                || source.terminal_map.iter().collect::<BTreeSet<_>>().len() != count
222            {
223                return Err(invalid(
224                    "source phasor vectors or terminal identities are inconsistent",
225                ));
226            }
227            if link.phase_terminals.iter().collect::<BTreeSet<_>>().len() != 3 {
228                return Err(invalid("phase map must name three distinct terminals"));
229            }
230            for terminal in &link.phase_terminals {
231                if !bus.terminals.contains(terminal)
232                    || !source.terminal_map.contains(terminal)
233                    || bus.grounded.contains(terminal)
234                {
235                    return Err(invalid(
236                        "phase terminal must be declared, sourced, and ungrounded",
237                    ));
238                }
239            }
240            // Terminal identity follows closed ideal switches by terminal map,
241            // so a voltage fixed elsewhere can still reach an interface phase.
242            let classes = &classes[&link.feeder];
243            let class = |bus: &str, terminal: &str| {
244                classes
245                    .get(&(bus.to_ascii_lowercase(), terminal.to_owned()))
246                    .copied()
247            };
248            let phases: BTreeSet<_> = link
249                .phase_terminals
250                .iter()
251                .filter_map(|terminal| class(&bus.id, terminal))
252                .collect();
253            if phases.len() != 3 {
254                return Err(invalid(
255                    "closed switches join interface phase terminals together",
256                ));
257            }
258            for phase in &phases {
259                if !terminals.insert((&link.feeder, *phase)) {
260                    return Err(invalid(
261                        "interfaces overlap at a feeder terminal, directly or through closed switches",
262                    ));
263                }
264            }
265            let mut fixed = net
266                .sources()
267                .iter()
268                .flat_map(|s| s.terminal_map.iter().map(move |t| (s, t)))
269                .filter(|(s, t)| s.name != source.name || !link.phase_terminals.contains(t))
270                .filter_map(|(s, t)| class(&s.bus, t))
271                .chain(
272                    net.buses()
273                        .iter()
274                        .flat_map(|b| b.grounded.iter().filter_map(|t| class(&b.id, t))),
275                );
276            if fixed.any(|c| phases.contains(&c)) {
277                return Err(invalid(
278                    "another source terminal or a ground fixes an interface phase, directly or through closed switches",
279                ));
280            }
281            // The stated phase voltages must describe the voltage the coupled
282            // equations will impose: a wrong ratio or phase map fails here.
283            let phasor = |terminal: &String| {
284                let index = source
285                    .terminal_map
286                    .iter()
287                    .position(|t| t == terminal)
288                    .expect("phase terminals were checked to be sourced");
289                (source.v_magnitude[index], source.v_angle[index])
290            };
291            let [a, b, c] = link.phase_terminals.each_ref().map(phasor);
292            if [a, b, c]
293                .iter()
294                .any(|(magnitude, _)| !(0.5 * nominal..=2.0 * nominal).contains(magnitude))
295            {
296                return Err(invalid(
297                    "stated source voltages disagree with the interface voltage ratio",
298                ));
299            }
300            if !near_angle(b.1 - a.1, -TAU / 3.0) || !near_angle(c.1 - a.1, TAU / 3.0) {
301                return Err(invalid(
302                    "stated source rotation disagrees with the A, B, C phase map",
303                ));
304            }
305            for (index, terminal) in source.terminal_map.iter().enumerate() {
306                if !bus.terminals.contains(terminal)
307                    || !source.v_magnitude[index].is_finite()
308                    || source.v_magnitude[index] < 0.0
309                    || !source.v_angle[index].is_finite()
310                {
311                    return Err(invalid("source has invalid terminal or phasor data"));
312                }
313                if !link.phase_terminals.contains(terminal) && source.v_magnitude[index] != 0.0 {
314                    return Err(invalid(
315                        "unbound source terminals must remain zero-volt earth clamps",
316                    ));
317                }
318            }
319            if let TransmissionDemand::ReplaceLoads(loads) = &link.demand {
320                if loads.is_empty() {
321                    return Err(invalid("replacement policy must name at least one load"));
322                }
323                for uid in loads {
324                    let matches: Vec<_> = tx
325                        .loads()
326                        .iter()
327                        .filter(|l| l.uid.as_ref() == Some(uid))
328                        .collect();
329                    if matches.len() != 1
330                        || !matches[0].in_service
331                        || matches[0].bus != link.transmission_bus
332                        || !replaced.insert(uid)
333                    {
334                        return Err(invalid(
335                            "replacement must name a unique active load at the interface bus, once",
336                        ));
337                    }
338                }
339            }
340        }
341        if connected.len() != self.feeders.len() {
342            return Err(invalid("every feeder needs an interface"));
343        }
344        Ok(())
345    }
346}
347
348/// Sum V times conjugate(I) in MW + j MVAr. Voltages are phase-to-earth volts,
349/// currents are amperes positive into the feeder, in the same A/B/C order.
350/// Export from the feeder produces negative active power. Lossless interface
351/// power is invariant to the voltage ratio and does not use an MVA base.
352pub fn boundary_power(
353    voltage: [Complex64; 3],
354    current: [Complex64; 3],
355) -> Result<Complex64, Error> {
356    if voltage.iter().chain(&current).any(|v| !finite(*v)) {
357        return Err(invalid("boundary phasors must be finite"));
358    }
359    let power: Complex64 = voltage
360        .into_iter()
361        .zip(current)
362        .map(|(v, i)| v * i.conj() / 1e6)
363        .sum();
364    if !finite(power) {
365        return Err(invalid("boundary power overflows"));
366    }
367    Ok(power)
368}
369/// Class of every `(bus, terminal)` node joined by closed ideal switches,
370/// pairing conductors by terminal map. Bus names compare case-insensitively,
371/// as `MulticonductorNetwork::bus` does; terminal names are exact.
372fn terminal_classes(
373    net: &MulticonductorNetwork,
374) -> Result<BTreeMap<(String, String), usize>, Error> {
375    let key = |bus: &str, terminal: &str| (bus.to_ascii_lowercase(), terminal.to_owned());
376    let closed: Vec<_> = net.switches().iter().filter(|s| !s.open).collect();
377    let mut index = BTreeMap::new();
378    let nodes = net
379        .buses()
380        .iter()
381        .flat_map(|b| b.terminals.iter().chain(&b.grounded).map(|t| key(&b.id, t)))
382        .chain(
383            net.sources()
384                .iter()
385                .flat_map(|s| s.terminal_map.iter().map(|t| key(&s.bus, t))),
386        )
387        .chain(closed.iter().flat_map(|s| {
388            let from = s.terminal_map_from.iter().map(|t| key(&s.bus_from, t));
389            from.chain(s.terminal_map_to.iter().map(|t| key(&s.bus_to, t)))
390        }));
391    for node in nodes {
392        let next = index.len();
393        index.entry(node).or_insert(next);
394    }
395    let mut union = UnionFind::new(index.len());
396    for switch in closed {
397        if switch.terminal_map_from.is_empty()
398            || switch.terminal_map_from.len() != switch.terminal_map_to.len()
399        {
400            return Err(invalid(format!(
401                "closed switch {} must pair its terminals one to one",
402                switch.name
403            )));
404        }
405        for (a, b) in switch.terminal_map_from.iter().zip(&switch.terminal_map_to) {
406            union.join(
407                index[&key(&switch.bus_from, a)],
408                index[&key(&switch.bus_to, b)],
409            );
410        }
411    }
412    Ok(index
413        .into_iter()
414        .map(|(node, row)| (node, union.root(row)))
415        .collect())
416}
417/// Within 30 degrees of `expected`, modulo a full turn. Opposite rotation is
418/// 120 degrees away, so a swapped phase map cannot pass.
419fn near_angle(actual: f64, expected: f64) -> bool {
420    (actual - expected)
421        .rem_euclid(TAU)
422        .min((expected - actual).rem_euclid(TAU))
423        <= TAU / 12.0
424}
425fn positive(value: f64) -> bool {
426    value.is_finite() && value > 0.0
427}
428fn finite(value: Complex64) -> bool {
429    value.re.is_finite() && value.im.is_finite()
430}
431fn identifier(value: &str) -> Result<(), Error> {
432    if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
433        Err(invalid(
434            "identifier must be nonempty, bounded, and free of control characters",
435        ))
436    } else {
437        Ok(())
438    }
439}
440fn invalid(message: impl std::fmt::Display) -> Error {
441    Error::new(
442        &crate::diagnostics::VALIDATE_TND_INTERFACE,
443        message.to_string(),
444    )
445}