Skip to main content

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