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