Skip to main content

powerio/
dist_geo.rs

1//! Geographic layer glue for the multiconductor model.
2//!
3//! `powerio_dist` cannot depend on `powerio`, so the [`GeoLayer`] extract and
4//! apply for [`MulticonductorNetwork`] live here, where both model crates are
5//! visible. The apply pass itself (`apply_geo_features`) is shared with the
6//! balanced network; this module supplies the string keyed row lookups and
7//! the mirrored type conversions. The parity test in this crate keeps the
8//! mirrored geo types' JSON shapes identical, so the conversions are direct
9//! field maps with a serde fallback for variants added on one side first.
10
11use std::collections::HashMap;
12
13use crate::geo::{GeoApplyTarget, apply_geo_features};
14use crate::{CoordinateSpace, ElementKey, GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer};
15use powerio_dist::MulticonductorNetwork;
16
17/// Transform a multiconductor network's coordinates to a geographic layer.
18#[must_use]
19pub fn to_dist_geo_layer(net: &MulticonductorNetwork) -> GeoLayer {
20    let mut features = Vec::new();
21    for (row, bus) in net.buses().iter().enumerate() {
22        let Some(location) = bus.location else {
23            continue;
24        };
25        features.push(GeoFeature {
26            target: crate::GeoTarget::Bus,
27            key: ElementKey {
28                uid: Some(format!("buses:{row}")),
29                id: Some(bus.id.clone()),
30                name: Some(bus.id.clone()),
31                index: None,
32            },
33            geometry: GeoGeometry::Point([location.x, location.y]),
34            from: None,
35            to: None,
36            kind: location.kind.and_then(kind_to_balanced),
37        });
38    }
39    for (row, line) in net.lines().iter().enumerate() {
40        let Some(route) = &line.route else {
41            continue;
42        };
43        features.push(GeoFeature {
44            target: crate::GeoTarget::Branch,
45            key: ElementKey {
46                uid: Some(format!("lines:{row}")),
47                id: Some(line.name.clone()),
48                name: Some(line.name.clone()),
49                index: None,
50            },
51            geometry: GeoGeometry::LineString(
52                route.iter().map(|point| [point.x, point.y]).collect(),
53            ),
54            from: Some(line.bus_from.clone()),
55            to: Some(line.bus_to.clone()),
56            kind: None,
57        });
58    }
59    let meta = mirror::<_, crate::GeoMeta>(net.geo().as_ref());
60    GeoLayer {
61        space: meta
62            .as_ref()
63            .map_or(CoordinateSpace::Unknown, |geo| geo.space.clone()),
64        kind: meta.and_then(|geo| geo.kind),
65        features,
66    }
67}
68
69/// Apply a [`GeoLayer`] onto a multiconductor network: matched bus points
70/// land in `DistBus.location`, matched line routes in `DistLine.route`, and
71/// the layer's space becomes the network's `geo` when anything matched.
72/// Matching follows [`ElementKey`], with the string ids matched case
73/// insensitively (OpenDSS names are case insensitive).
74pub fn apply_dist_geo_layer(net: &mut MulticonductorNetwork, layer: &GeoLayer) -> GeoApplyReport {
75    let mut target = DistApply {
76        buses: DistBusIndex::new(net),
77        lines: DistLineIndex::new(net),
78        net,
79    };
80    let report = apply_geo_features(layer, &mut target);
81    if report.matched_buses > 0 || report.matched_branches > 0 {
82        *net.geo_mut() = mirror(Some(&crate::GeoMeta {
83            space: layer.space.clone(),
84            kind: layer.kind,
85        }));
86    }
87    report
88}
89
90/// The multiconductor network as an apply target.
91struct DistApply<'a> {
92    net: &'a mut MulticonductorNetwork,
93    buses: DistBusIndex,
94    lines: DistLineIndex,
95}
96
97impl GeoApplyTarget for DistApply<'_> {
98    fn bus_row(&self, key: &ElementKey) -> Option<usize> {
99        key.uid
100            .as_ref()
101            .and_then(|uid| self.buses.rows.get(uid))
102            .or_else(|| lookup_lower(&self.buses.rows, key.id.as_deref()))
103            .or_else(|| lookup_lower(&self.buses.rows, key.name.as_deref()))
104            .copied()
105    }
106
107    fn branch_row(&self, feature: &GeoFeature) -> Option<usize> {
108        feature
109            .key
110            .uid
111            .as_ref()
112            .and_then(|uid| self.lines.rows.get(uid).copied())
113            .or_else(|| {
114                lookup_lower(&self.lines.rows, feature.key.id.as_deref())
115                    .or_else(|| lookup_lower(&self.lines.rows, feature.key.name.as_deref()))
116                    .copied()
117            })
118            .or_else(|| {
119                // Positional row alias, 1-based.
120                feature
121                    .key
122                    .index
123                    .and_then(|index| index.checked_sub(1))
124                    .filter(|row| *row < self.net.lines().len())
125            })
126            .or_else(|| {
127                let from = feature.from.as_deref()?;
128                let to = feature.to.as_deref()?;
129                self.lines.pairs.get(&name_pair(from, to)).copied()
130            })
131    }
132
133    fn place_bus(&mut self, row: usize, point: [f64; 2], kind: Option<crate::CoordsKind>) {
134        self.net.buses_mut()[row].location = Some(powerio_dist::DistLocation {
135            x: point[0],
136            y: point[1],
137            kind: kind.and_then(kind_to_dist),
138        });
139    }
140
141    fn place_branch(&mut self, row: usize, path: &[[f64; 2]], kind: Option<crate::CoordsKind>) {
142        let kind = kind.and_then(kind_to_dist);
143        self.net.lines_mut()[row].route = Some(
144            path.iter()
145                .map(|[x, y]| powerio_dist::DistLocation { x: *x, y: *y, kind })
146                .collect(),
147        );
148    }
149
150    fn substation_note(&self, count: usize) -> String {
151        format!(
152            "{count} substation feature(s) not applied: the multiconductor model has no \
153             substation join"
154        )
155    }
156
157    fn unlocated_counts(&self) -> (usize, usize) {
158        (
159            self.net
160                .buses()
161                .iter()
162                .filter(|bus| bus.location.is_none())
163                .count(),
164            self.net
165                .lines()
166                .iter()
167                .filter(|line| line.route.is_none())
168                .count(),
169        )
170    }
171}
172
173/// Bus row lookups: payload row uid plus the lowercased string id.
174struct DistBusIndex {
175    rows: HashMap<String, usize>,
176}
177
178impl DistBusIndex {
179    fn new(net: &MulticonductorNetwork) -> Self {
180        let mut rows = HashMap::new();
181        for (row, bus) in net.buses().iter().enumerate() {
182            rows.insert(format!("buses:{row}"), row);
183            rows.entry(bus.id.to_ascii_lowercase()).or_insert(row);
184        }
185        Self { rows }
186    }
187}
188
189/// Line row lookups: payload row uid, lowercased name, and the unordered
190/// endpoint pair.
191struct DistLineIndex {
192    rows: HashMap<String, usize>,
193    pairs: HashMap<(String, String), usize>,
194}
195
196impl DistLineIndex {
197    fn new(net: &MulticonductorNetwork) -> Self {
198        let mut rows = HashMap::new();
199        let mut pairs = HashMap::new();
200        for (row, line) in net.lines().iter().enumerate() {
201            rows.insert(format!("lines:{row}"), row);
202            rows.entry(line.name.to_ascii_lowercase()).or_insert(row);
203            pairs
204                .entry(name_pair(&line.bus_from, &line.bus_to))
205                .or_insert(row);
206        }
207        Self { rows, pairs }
208    }
209}
210
211fn lookup_lower<'a>(rows: &'a HashMap<String, usize>, key: Option<&str>) -> Option<&'a usize> {
212    rows.get(&key?.to_ascii_lowercase())
213}
214
215fn name_pair(a: &str, b: &str) -> (String, String) {
216    let a = a.to_ascii_lowercase();
217    let b = b.to_ascii_lowercase();
218    if b < a { (b, a) } else { (a, b) }
219}
220
221/// Direct maps between the mirrored source relation enums; both are
222/// `#[non_exhaustive]`, so a variant added on one side first goes through the
223/// shared JSON shape instead of being dropped silently.
224fn kind_to_balanced(kind: powerio_dist::DistCoordsKind) -> Option<crate::CoordsKind> {
225    match kind {
226        powerio_dist::DistCoordsKind::Source => Some(crate::CoordsKind::Source),
227        powerio_dist::DistCoordsKind::Synthetic => Some(crate::CoordsKind::Synthetic),
228        powerio_dist::DistCoordsKind::Manual => Some(crate::CoordsKind::Manual),
229        powerio_dist::DistCoordsKind::Derived => Some(crate::CoordsKind::Derived),
230        _ => mirror(Some(&kind)),
231    }
232}
233
234fn kind_to_dist(kind: crate::CoordsKind) -> Option<powerio_dist::DistCoordsKind> {
235    match kind {
236        crate::CoordsKind::Source => Some(powerio_dist::DistCoordsKind::Source),
237        crate::CoordsKind::Synthetic => Some(powerio_dist::DistCoordsKind::Synthetic),
238        crate::CoordsKind::Manual => Some(powerio_dist::DistCoordsKind::Manual),
239        crate::CoordsKind::Derived => Some(powerio_dist::DistCoordsKind::Derived),
240        _ => mirror(Some(&kind)),
241    }
242}
243
244/// Convert between the mirrored geo types through their shared JSON shape.
245/// Reserved for the once-per-call `GeoMeta` conversion and the enum fallback
246/// arms; the per element paths use the direct maps above.
247fn mirror<S: serde::Serialize, T: serde::de::DeserializeOwned>(value: Option<&S>) -> Option<T> {
248    serde_json::to_value(value?)
249        .ok()
250        .and_then(|json| serde_json::from_value(json).ok())
251}