Skip to main content

powerio/geo/
pwd.rs

1//! PowerWorld substation promotion into the geo model.
2//!
3//! Two PowerWorld files carry substation coordinates. The `.pwd` display
4//! holds symbols in diagram coordinates, which [`geo_layer_from_pwd`] lifts
5//! into a diagram space [`GeoLayer`]; [`pwd_mercator_to_lonlat`] is the
6//! documented, approximate inverse of the projection PowerWorld's auto
7//! generated layouts use, for consumers that want to place a diagram on a
8//! map. The `.aux` `Substation` table holds latitude and longitude, which
9//! [`geo_layer_from_aux_substations`] lifts into a geographic layer.
10//! [`apply_substation_points`] joins either layer onto buses through the
11//! `SubNum` extras key.
12
13use std::collections::HashMap;
14
15use serde_json::Value;
16
17use super::layer::{ElementKey, GeoApplyReport, GeoFeature, GeoGeometry, GeoLayer, GeoTarget};
18use super::{Canvas, CoordinateSpace, GeoMeta, Location};
19use crate::format::PwdDisplay;
20use crate::format::powerworld::AuxFile;
21use crate::network::BalancedNetwork;
22
23/// Scale of PowerWorld's auto generated layouts: `x = K·lon` and
24/// `y = K·mercdeg(lat)`, with the Mercator ordinate expressed in degrees.
25pub const PWD_MERCATOR_K: f64 = 535.816_08;
26
27/// Approximate inverse of the projection PowerWorld's auto generated layouts
28/// use (verified against ACTIVSg200/2000 to within ~0.02 degrees): longitude
29/// is `x / K`, latitude the inverse Gudermannian of `y / K`. Hand edited
30/// diagrams drift from this, so treat the result as approximate.
31#[must_use]
32pub fn pwd_mercator_to_lonlat(x: f64, y: f64) -> (f64, f64) {
33    let lon = x / PWD_MERCATOR_K;
34    let lat = ((y / PWD_MERCATOR_K).to_radians().sinh())
35        .atan()
36        .to_degrees();
37    (lon, lat)
38}
39
40/// Lift decoded `.pwd` substation symbols into a diagram space [`GeoLayer`]
41/// with substation targets keyed by substation number.
42#[must_use]
43pub fn geo_layer_from_pwd(display: &PwdDisplay) -> GeoLayer {
44    GeoLayer {
45        space: CoordinateSpace::Diagram {
46            canvas: Some(Canvas {
47                width: Some(f64::from(display.canvas_width)),
48                height: Some(f64::from(display.canvas_height)),
49                units: None,
50            }),
51        },
52        kind: None,
53        features: display
54            .substations
55            .iter()
56            .map(|substation| {
57                substation_feature(
58                    substation_key(&substation.number.to_string()),
59                    (!substation.name.is_empty()).then(|| substation.name.clone()),
60                    [substation.x, substation.y],
61                )
62            })
63            .collect(),
64    }
65}
66
67/// Lift the aux `Substation` table into a geographic [`GeoLayer`] with
68/// substation targets keyed by substation number. The number comes from
69/// `SubNum` or `Number` and the point from `Latitude` and `Longitude`, the
70/// column names PowerWorld writes itself, so they take no aliases. A row
71/// whose number or coordinate is absent or is not a finite number is
72/// skipped. Rows stay in file order, so a repeated substation number keeps
73/// the last point once [`apply_substation_points`] runs.
74#[must_use]
75pub fn geo_layer_from_aux_substations(aux: &AuxFile) -> GeoLayer {
76    let mut features = Vec::new();
77    for object in aux.data_of("Substation") {
78        let (Some(number), Some(latitude), Some(longitude)) = (
79            object
80                .field_index("SubNum")
81                .or_else(|| object.field_index("Number")),
82            object.field_index("Latitude"),
83            object.field_index("Longitude"),
84        ) else {
85            continue;
86        };
87        for row in &object.rows {
88            let field = |column: usize| -> Option<(&str, f64)> {
89                let text = row.values.get(column)?.trim();
90                let value = text.parse::<f64>().ok().filter(|value| value.is_finite())?;
91                Some((text, value))
92            };
93            let (Some((number, _)), Some((_, lat)), Some((_, lon))) =
94                (field(number), field(latitude), field(longitude))
95            else {
96                continue;
97            };
98            features.push(substation_feature(substation_key(number), None, [lon, lat]));
99        }
100    }
101    GeoLayer {
102        space: CoordinateSpace::Geographic { crs: None },
103        kind: None,
104        features,
105    }
106}
107
108/// Join a layer's substation points onto buses through the `SubNum` (or
109/// `SubNumber`) extras key: every bus in a matched substation takes the
110/// substation's point, and the layer's space becomes the network's
111/// [`GeoMeta`] when anything matched. Replaced locations and a coordinate
112/// space change are reported in the notes rather than happening silently.
113pub fn apply_substation_points(net: &mut BalancedNetwork, layer: &GeoLayer) -> GeoApplyReport {
114    let mut report = GeoApplyReport::default();
115    // Substation number -> bus rows, built once for the whole pass.
116    let mut rows_by_substation: HashMap<String, Vec<usize>> = HashMap::new();
117    for (row, bus) in net.buses.iter().enumerate() {
118        if let Some(substation) = bus_substation(bus) {
119            rows_by_substation.entry(substation).or_default().push(row);
120        }
121    }
122    let mut replaced = 0usize;
123    for feature in &layer.features {
124        let (GeoTarget::Substation, GeoGeometry::Point(point)) =
125            (&feature.target, &feature.geometry)
126        else {
127            continue;
128        };
129        let rows = feature
130            .key
131            .id
132            .as_deref()
133            .and_then(|number| rows_by_substation.get(number));
134        let Some(rows) = rows else {
135            report.unmatched_features += 1;
136            continue;
137        };
138        for &row in rows {
139            let bus = &mut net.buses[row];
140            if bus.location.is_some() {
141                replaced += 1;
142            }
143            bus.location = Some(Location {
144                x: point[0],
145                y: point[1],
146                kind: feature.kind,
147            });
148            report.matched_buses += 1;
149        }
150    }
151    if report.matched_buses > 0 {
152        if replaced > 0 {
153            report
154                .notes
155                .push(format!("replaced {replaced} existing bus location(s)"));
156        }
157        super::layer::note_space_change(&mut report, net.geo.as_ref(), &layer.space);
158        net.geo = Some(GeoMeta {
159            space: layer.space.clone(),
160            kind: layer.kind,
161        });
162    }
163    (report.unlocated_buses, report.unlocated_branches) = super::layer::unlocated_counts(net);
164    report
165}
166
167/// The bus's substation number from extras, normalized to a string
168/// (PowerWorld exports carry it as a number or a numeric string).
169fn bus_substation(bus: &crate::network::Bus) -> Option<String> {
170    let value = bus
171        .extras
172        .get("SubNum")
173        .or_else(|| bus.extras.get("SubNumber"))?;
174    match value {
175        Value::Number(number) => Some(substation_key(&number.to_string())),
176        Value::String(text) => {
177            let trimmed = text.trim();
178            (!trimmed.is_empty()).then(|| substation_key(trimmed))
179        }
180        _ => None,
181    }
182}
183
184/// One substation point, keyed for [`apply_substation_points`]. Every
185/// substation source builds its features through this.
186fn substation_feature(id: String, name: Option<String>, point: [f64; 2]) -> GeoFeature {
187    GeoFeature {
188        target: GeoTarget::Substation,
189        key: ElementKey {
190            uid: None,
191            id: Some(id),
192            name,
193            index: None,
194        },
195        geometry: GeoGeometry::Point(point),
196        from: None,
197        to: None,
198        kind: None,
199    }
200}
201
202/// The join key for one substation number. Every source of a substation
203/// number goes through this: "12.0" and "12" name the same substation, and
204/// the two sides of the join must spell it the same way.
205fn substation_key(number: &str) -> String {
206    number
207        .parse::<f64>()
208        .ok()
209        .filter(|v| v.fract() == 0.0 && v.abs() < 1e15)
210        .map_or_else(|| number.to_owned(), |v| format!("{v:.0}"))
211}