1use 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#[derive(Clone, Debug, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum TransmissionDemand {
15 Additional,
17 ReplaceLoads(Vec<String>),
20}
21
22#[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 pub phase_terminals: [String; 3],
38 pub voltage_ratio: f64,
40 pub angle_shift_radians: f64,
42 pub demand: TransmissionDemand,
43}
44impl Interface {
45 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#[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 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 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 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
348pub fn boundary_power(
353 voltage: [Complex64; 3],
354 current: [Complex64; 3],
355) -> Result<Complex64, Error> {
356 if voltage.iter().chain(¤t).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}
369fn 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}
417fn 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}