Skip to main content

powerio/geo/
layer.rs

1//! The standalone geographic document.
2//!
3//! Coordinates arrive and leave as files of their own: a `Buscoords` CSV next
4//! to a DSS master, a GeoJSON export from a GIS tool, a layout computed by a
5//! renderer. [`GeoLayer`] is the container. Reading is tolerant (headerless
6//! buscoords CSV, aliased CSV/JSON records, GeoJSON Point/LineString); writing
7//! is canonical (a GeoJSON FeatureCollection with the `powerio_geo` foreign
8//! member). The reader takes bytes plus a name hint and touches no filesystem,
9//! so wasm consumers parse untrusted browser input through it directly.
10
11use std::collections::HashMap;
12
13use serde_json::{Map, Value, json};
14
15use super::{Canvas, CoordinateSpace, CoordsKind, GeoMeta, Location};
16use crate::network::{BalancedNetwork, BusId};
17use crate::{Error, Result};
18
19/// Suggested extension for the canonical document.
20pub const GEO_LAYER_EXTENSION: &str = "geo.json";
21
22const FMT: &str = "geo layer";
23
24/// A standalone geographic document: element points and routes in one
25/// coordinate space, keyed by element identity rather than embedded in a case.
26#[derive(Debug, Clone, PartialEq)]
27pub struct GeoLayer {
28    /// Coordinate space of every feature.
29    pub space: CoordinateSpace,
30    /// Default provenance, stamped into the `powerio_geo` member on write.
31    pub kind: Option<CoordsKind>,
32    pub features: Vec<GeoFeature>,
33}
34
35/// One point or route in a [`GeoLayer`].
36#[derive(Debug, Clone, PartialEq)]
37pub struct GeoFeature {
38    pub target: GeoTarget,
39    pub key: ElementKey,
40    pub geometry: GeoGeometry,
41    /// Endpoint bus references for a branch, the unordered fallback identity.
42    pub from: Option<String>,
43    pub to: Option<String>,
44    /// Per feature provenance when it differs from the layer default.
45    pub kind: Option<CoordsKind>,
46}
47
48/// The element family a feature places.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum GeoTarget {
52    Bus,
53    Branch,
54    /// PowerWorld substations, joined onto buses through the `SubNum` extras
55    /// key by [`super::apply_substation_points`].
56    Substation,
57}
58
59impl GeoTarget {
60    fn token(self) -> &'static str {
61        match self {
62            GeoTarget::Bus => "bus",
63            GeoTarget::Branch => "branch",
64            GeoTarget::Substation => "substation",
65        }
66    }
67}
68
69/// Feature geometry: a point or a polyline route.
70#[derive(Debug, Clone, PartialEq)]
71pub enum GeoGeometry {
72    Point([f64; 2]),
73    LineString(Vec<[f64; 2]>),
74}
75
76/// Element identity for one feature. Matching tries `uid`, then `id`, then
77/// case insensitive `name`; branches additionally fall back to the unordered
78/// `(from, to)` bus pair. `index` is a positional row alias (1-based, the
79/// MATPOWER row convention) accepted on read and never written; the durable
80/// identity is the payload `uid` (`buses:3`, `branches:7`).
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct ElementKey {
83    pub uid: Option<String>,
84    pub id: Option<String>,
85    pub name: Option<String>,
86    pub index: Option<usize>,
87}
88
89/// Output of a tolerant geo read: the layer plus the reader's notes on
90/// records it could not use.
91#[derive(Debug, Clone)]
92#[non_exhaustive]
93pub struct GeoParsed {
94    pub layer: GeoLayer,
95    pub warnings: Vec<String>,
96}
97
98/// Result of applying a [`GeoLayer`] to a network.
99#[derive(Debug, Clone, Default, PartialEq)]
100#[non_exhaustive]
101pub struct GeoApplyReport {
102    pub matched_buses: usize,
103    pub matched_branches: usize,
104    pub unmatched_features: usize,
105    /// Buses that carry no location when the pass ends, counted over the
106    /// whole model. Read with `matched_buses`, it tells a layer that matched
107    /// nothing from a model that needed nothing.
108    pub unlocated_buses: usize,
109    /// Branches that carry no route when the pass ends.
110    pub unlocated_branches: usize,
111    pub notes: Vec<String>,
112}
113
114impl GeoApplyReport {
115    /// Refuse a partial placement: every bus needs a location and every
116    /// branch a route.
117    ///
118    /// # Errors
119    /// [`Error::UnlocatedElements`] when either count is nonzero.
120    pub fn require_located(&self) -> Result<()> {
121        if self.unlocated_buses > 0 || self.unlocated_branches > 0 {
122            return Err(Error::UnlocatedElements {
123                buses: self.unlocated_buses,
124                branches: self.unlocated_branches,
125            });
126        }
127        Ok(())
128    }
129}
130
131impl GeoLayer {
132    /// Tolerant read of a geographic sidecar from bytes. `name_hint` (a file
133    /// name) picks CSV against JSON when present; otherwise the content is
134    /// sniffed. Accepts headerless buscoords CSV (`bus,x,y`), CSV and JSON
135    /// records with aliased field names, and GeoJSON Point/LineString
136    /// features. Rejects input carrying no usable coordinates.
137    pub fn parse_bytes(bytes: &[u8], name_hint: Option<&str>) -> Result<GeoParsed> {
138        // Windows exports lead with a UTF-8 BOM; serde_json rejects it.
139        let bytes = bytes
140            .strip_prefix(b"\xef\xbb\xbf".as_slice())
141            .unwrap_or(bytes);
142        let mut parsed = GeoParsed {
143            layer: GeoLayer {
144                space: CoordinateSpace::Unknown,
145                kind: None,
146                features: Vec::new(),
147            },
148            warnings: Vec::new(),
149        };
150        let mut declared_space = false;
151        let hint_ext = name_hint
152            .and_then(|name| name.rsplit('.').next())
153            .map(str::to_ascii_lowercase);
154        let looks_json = match hint_ext.as_deref() {
155            Some("csv") => false,
156            Some("json" | "geojson") => true,
157            _ => sniff_json(bytes),
158        };
159        if looks_json {
160            let value: Value = serde_json::from_slice(bytes)
161                .map_err(|error| bad(format!("invalid JSON: {error}")))?;
162            if let Some(features) = feature_collection(&value) {
163                declared_space = read_powerio_geo_member(&value, &mut parsed.layer);
164                for feature in features {
165                    read_geojson_feature(feature, &mut parsed);
166                }
167            } else {
168                let mut records = Vec::new();
169                collect_records(&value, &mut records);
170                for record in records {
171                    read_record(&record, &mut parsed);
172                }
173            }
174        } else {
175            let text = String::from_utf8_lossy(bytes);
176            read_csv(&text, &mut parsed);
177        }
178        if parsed.layer.features.is_empty() {
179            return Err(bad("no bus coordinates or branch routes found"));
180        }
181        if !declared_space {
182            parsed.layer.space = inferred_space(&parsed.layer);
183        }
184        Ok(parsed)
185    }
186
187    /// [`to_geojson`](Self::to_geojson) behind the extraction surfaces' shared
188    /// guard: an empty layer is refused because the written document would not
189    /// read back ([`parse_bytes`](Self::parse_bytes) rejects a document with
190    /// no features).
191    pub fn extracted_geojson(&self) -> Result<String> {
192        if self.features.is_empty() {
193            return Err(bad("the network carries no coordinates to extract"));
194        }
195        Ok(self.to_geojson())
196    }
197
198    /// Serialize the canonical form: a GeoJSON FeatureCollection with the
199    /// `powerio_geo` foreign member. Valid RFC 7946 GeoJSON when the space is
200    /// geographic, so GIS tools open it directly.
201    #[must_use]
202    pub fn to_geojson(&self) -> String {
203        let mut member = Map::new();
204        member.insert(
205            crate::version::VERSION_KEY.to_owned(),
206            json!(crate::VERSION),
207        );
208        let detail = match &self.space {
209            CoordinateSpace::Geographic { crs } | CoordinateSpace::Projected { crs } => {
210                crs.as_ref().map(crs_entry)
211            }
212            CoordinateSpace::Diagram { canvas } => canvas.as_ref().map(canvas_entry),
213            _ => None,
214        };
215        member.insert("space".to_owned(), json!(self.space.token()));
216        if let Some((key, value)) = detail {
217            member.insert(key.to_owned(), value);
218        }
219        if let Some(kind) = self.kind {
220            member.insert("kind".to_owned(), kind_value(kind));
221        }
222        let features: Vec<Value> = self.features.iter().map(feature_value).collect();
223        let document = json!({
224            "type": "FeatureCollection",
225            "powerio_geo": Value::Object(member),
226            "features": features,
227        });
228        // Serializing an in-memory `Value` does not fail; `Display` is the
229        // infallible (compact) fallback.
230        serde_json::to_string_pretty(&document).unwrap_or_else(|_| document.to_string())
231    }
232}
233
234fn crs_entry(crs: &String) -> (&'static str, Value) {
235    ("crs", json!(crs))
236}
237
238fn canvas_entry(canvas: &Canvas) -> (&'static str, Value) {
239    (
240        "canvas",
241        serde_json::to_value(canvas).unwrap_or(Value::Null),
242    )
243}
244
245fn kind_value(kind: CoordsKind) -> Value {
246    serde_json::to_value(kind).unwrap_or(Value::Null)
247}
248
249fn feature_value(feature: &GeoFeature) -> Value {
250    let mut properties = Map::new();
251    properties.insert("target".to_owned(), json!(feature.target.token()));
252    if let Some(uid) = &feature.key.uid {
253        properties.insert("uid".to_owned(), json!(uid));
254    }
255    if let Some(id) = &feature.key.id {
256        properties.insert("id".to_owned(), json!(id));
257    }
258    if let Some(name) = &feature.key.name {
259        properties.insert("name".to_owned(), json!(name));
260    }
261    if let Some(from) = &feature.from {
262        properties.insert("from".to_owned(), json!(from));
263    }
264    if let Some(to) = &feature.to {
265        properties.insert("to".to_owned(), json!(to));
266    }
267    if let Some(kind) = feature.kind {
268        properties.insert("kind".to_owned(), kind_value(kind));
269    }
270    let geometry = match &feature.geometry {
271        GeoGeometry::Point(point) => json!({"type": "Point", "coordinates": point}),
272        GeoGeometry::LineString(path) => json!({"type": "LineString", "coordinates": path}),
273    };
274    json!({"type": "Feature", "geometry": geometry, "properties": Value::Object(properties)})
275}
276
277// ---------------------------------------------------------------------------
278// Tolerant reading
279// ---------------------------------------------------------------------------
280
281/// Alias tables, matched on keys normalized to lowercase alphanumeric. These
282/// port the sidecar vocabulary tellegen's renderer accepted, so a file that
283/// loaded there loads here.
284const BUS_ID_ALIASES: &[&str] = &["busi", "bus", "busid", "busnumber", "number", "id"];
285const LAT_ALIASES: &[&str] = &["lat", "latitude", "y"];
286const LON_ALIASES: &[&str] = &["lon", "lng", "longitude", "x"];
287const FROM_ALIASES: &[&str] = &["fbus", "from", "frombus"];
288const TO_ALIASES: &[&str] = &["tbus", "to", "tobus"];
289const BRANCH_ID_ALIASES: &[&str] = &["branch", "branchid", "branchnumber", "catsid"];
290const PATH_ALIASES: &[&str] = &["path", "geometry", "coordinates"];
291const FROM_LAT_ALIASES: &[&str] = &["lat1", "fromlat"];
292const FROM_LON_ALIASES: &[&str] = &["lon1", "lng1", "fromlon", "fromlng"];
293const TO_LAT_ALIASES: &[&str] = &["lat2", "tolat"];
294const TO_LON_ALIASES: &[&str] = &["lon2", "lng2", "tolon", "tolng"];
295const NAME_ALIASES: &[&str] = &["name", "busname"];
296
297fn bad(message: impl Into<String>) -> Error {
298    Error::FormatRead {
299        format: FMT,
300        message: message.into(),
301    }
302}
303
304fn sniff_json(bytes: &[u8]) -> bool {
305    bytes
306        .iter()
307        .copied()
308        .find(|byte| !byte.is_ascii_whitespace() && *byte != 0xEF && *byte != 0xBB && *byte != 0xBF)
309        .is_some_and(|byte| byte == b'{' || byte == b'[')
310}
311
312fn normalize_key(key: &str) -> String {
313    key.chars()
314        .filter(char::is_ascii_alphanumeric)
315        .map(|c| c.to_ascii_lowercase())
316        .collect()
317}
318
319/// A record read from CSV or JSON, with normalized keys.
320struct Record {
321    fields: HashMap<String, Value>,
322}
323
324impl Record {
325    fn value(&self, aliases: &[&str]) -> Option<&Value> {
326        aliases.iter().find_map(|alias| self.fields.get(*alias))
327    }
328
329    fn number(&self, aliases: &[&str]) -> Option<f64> {
330        value_number(self.value(aliases)?)
331    }
332
333    fn string(&self, aliases: &[&str]) -> Option<String> {
334        match self.value(aliases)? {
335            Value::String(text) => {
336                let trimmed = text.trim();
337                (!trimmed.is_empty()).then(|| trimmed.to_owned())
338            }
339            Value::Number(number) => Some(number.to_string()),
340            _ => None,
341        }
342    }
343}
344
345fn value_number(value: &Value) -> Option<f64> {
346    match value {
347        Value::Number(number) => number.as_f64().filter(|v| v.is_finite()),
348        Value::String(text) => {
349            let trimmed = text.trim().trim_matches(|c| c == '\'' || c == '"');
350            trimmed.parse::<f64>().ok().filter(|v| v.is_finite())
351        }
352        _ => None,
353    }
354}
355
356fn feature_collection(value: &Value) -> Option<&Vec<Value>> {
357    value.get("features")?.as_array()
358}
359
360/// Read the `powerio_geo` foreign member into the layer; `true` when a space
361/// was declared.
362fn read_powerio_geo_member(value: &Value, layer: &mut GeoLayer) -> bool {
363    let Some(member) = value.get("powerio_geo").and_then(Value::as_object) else {
364        return false;
365    };
366    layer.kind = member.get("kind").and_then(read_kind);
367    let crs = member.get("crs").and_then(Value::as_str).map(str::to_owned);
368    let canvas = member
369        .get("canvas")
370        .and_then(|canvas| serde_json::from_value(canvas.clone()).ok());
371    match member.get("space").and_then(Value::as_str) {
372        Some("geographic") => layer.space = CoordinateSpace::Geographic { crs },
373        Some("projected") => layer.space = CoordinateSpace::Projected { crs },
374        Some("diagram") => layer.space = CoordinateSpace::Diagram { canvas },
375        Some(_) => layer.space = CoordinateSpace::Unknown,
376        None => return false,
377    }
378    true
379}
380
381fn read_kind(value: &Value) -> Option<CoordsKind> {
382    serde_json::from_value(value.clone()).ok()
383}
384
385fn read_geojson_feature(feature: &Value, parsed: &mut GeoParsed) {
386    let Some(geometry) = feature.get("geometry").and_then(Value::as_object) else {
387        return;
388    };
389    let properties = feature
390        .get("properties")
391        .and_then(Value::as_object)
392        .cloned()
393        .unwrap_or_default();
394    let record = Record {
395        fields: properties
396            .into_iter()
397            .map(|(key, value)| (normalize_key(&key), value))
398            .collect(),
399    };
400    let target = record.string(&["target"]);
401    let kind = record.value(&["kind"]).and_then(read_kind);
402    match geometry.get("type").and_then(Value::as_str) {
403        Some("Point") => {
404            let Some(point) = geometry.get("coordinates").and_then(coordinate) else {
405                parsed
406                    .warnings
407                    .push("skipped a Point feature with unusable coordinates".to_owned());
408                return;
409            };
410            // Property values keep their case; only keys are normalized.
411            let target = match target.as_deref() {
412                Some(token) if token.eq_ignore_ascii_case("substation") => GeoTarget::Substation,
413                _ => GeoTarget::Bus,
414            };
415            parsed.layer.features.push(GeoFeature {
416                target,
417                key: point_key(&record),
418                geometry: GeoGeometry::Point(point),
419                from: None,
420                to: None,
421                kind,
422            });
423        }
424        Some("LineString") => {
425            let path = geometry
426                .get("coordinates")
427                .and_then(Value::as_array)
428                .map(|raw| coordinate_path(raw))
429                .unwrap_or_default();
430            if path.len() < 2 {
431                parsed
432                    .warnings
433                    .push("skipped a LineString feature with fewer than 2 points".to_owned());
434                return;
435            }
436            push_branch_feature(&record, path, parsed);
437        }
438        Some(other) => {
439            // Truncate the echoed type name: it is attacker controlled, and
440            // unbounded distinct warnings would defeat the dedup below.
441            let shown: String = other.chars().take(32).collect();
442            push_once(
443                &mut parsed.warnings,
444                format!("skipped unsupported GeoJSON geometry `{shown}`"),
445            );
446        }
447        None => {}
448    }
449}
450
451/// Key for a point record: the payload `uid`, an aliased id, and a name.
452fn point_key(record: &Record) -> ElementKey {
453    ElementKey {
454        uid: record.string(&["uid"]),
455        id: record.string(BUS_ID_ALIASES),
456        name: record.string(NAME_ALIASES),
457        index: None,
458    }
459}
460
461/// Key for a branch record. A bare unsigned integer id is a positional row
462/// alias (read only); everything else matches by string id or name.
463fn branch_key(record: &Record) -> ElementKey {
464    // GIS exports and RFC 7946 tooling write a feature row counter under `id`,
465    // which would place the route on an unrelated branch. A named identifier
466    // there still matches a uid, so only the integer case is dropped.
467    let id = record.string(BRANCH_ID_ALIASES).or_else(|| {
468        record
469            .string(&["id"])
470            .filter(|raw| raw.parse::<usize>().is_err())
471    });
472    let index = id
473        .as_deref()
474        .and_then(|raw| raw.parse::<usize>().ok())
475        .filter(|_| record.string(&["uid"]).is_none());
476    ElementKey {
477        uid: record.string(&["uid"]),
478        id,
479        name: record.string(NAME_ALIASES),
480        index,
481    }
482}
483
484fn push_branch_feature(record: &Record, path: Vec<[f64; 2]>, parsed: &mut GeoParsed) {
485    let from = record.string(FROM_ALIASES);
486    let to = record.string(TO_ALIASES);
487    let key = branch_key(record);
488    if key.uid.is_none()
489        && key.id.is_none()
490        && key.name.is_none()
491        && (from.is_none() || to.is_none())
492    {
493        push_once(
494            &mut parsed.warnings,
495            "skipped a branch route with no id, uid, name, or endpoint pair".to_owned(),
496        );
497        return;
498    }
499    parsed.layer.features.push(GeoFeature {
500        target: GeoTarget::Branch,
501        key,
502        geometry: GeoGeometry::LineString(path),
503        from,
504        to,
505        kind: record.value(&["kind"]).and_then(read_kind),
506    });
507}
508
509fn coordinate(raw: &Value) -> Option<[f64; 2]> {
510    let items = raw.as_array()?;
511    let x = value_number(items.first()?)?;
512    let y = value_number(items.get(1)?)?;
513    Some([x, y])
514}
515
516fn coordinate_path(raw: &[Value]) -> Vec<[f64; 2]> {
517    raw.iter().filter_map(coordinate).collect()
518}
519
520/// Flatten arbitrary JSON into candidate records: arrays recurse, an object
521/// whose values contain arrays of objects yields those, and a plain object is
522/// itself one record. Depth is bounded by the parsed document.
523fn collect_records(value: &Value, out: &mut Vec<Record>) {
524    match value {
525        Value::Array(items) => {
526            for item in items {
527                collect_records(item, out);
528            }
529        }
530        Value::Object(object) => {
531            let before = out.len();
532            for nested in object.values() {
533                if let Value::Array(items) = nested {
534                    for item in items {
535                        if item.is_object() {
536                            collect_records(item, out);
537                        }
538                    }
539                }
540            }
541            if out.len() == before {
542                out.push(Record {
543                    fields: object
544                        .iter()
545                        .map(|(key, value)| (normalize_key(key), value.clone()))
546                        .collect(),
547                });
548            }
549        }
550        _ => {}
551    }
552}
553
554/// One aliased record can carry a bus point, a branch route, or both.
555fn read_record(record: &Record, parsed: &mut GeoParsed) {
556    read_point_record(record, parsed);
557    read_branch_record(record, parsed);
558}
559
560fn read_point_record(record: &Record, parsed: &mut GeoParsed) {
561    let key = point_key(record);
562    if key.uid.is_none() && key.id.is_none() && key.name.is_none() {
563        return;
564    }
565    let (Some(lon), Some(lat)) = (record.number(LON_ALIASES), record.number(LAT_ALIASES)) else {
566        return;
567    };
568    parsed.layer.features.push(GeoFeature {
569        target: GeoTarget::Bus,
570        key,
571        geometry: GeoGeometry::Point([lon, lat]),
572        from: None,
573        to: None,
574        kind: None,
575    });
576}
577
578fn read_branch_record(record: &Record, parsed: &mut GeoParsed) {
579    let path = record_path(record);
580    if path.len() < 2 {
581        return;
582    }
583    push_branch_feature(record, path, parsed);
584}
585
586fn record_path(record: &Record) -> Vec<[f64; 2]> {
587    if let Some(Value::Array(raw)) = record.value(PATH_ALIASES) {
588        return coordinate_path(raw);
589    }
590    let endpoints = (
591        record.number(FROM_LON_ALIASES),
592        record.number(FROM_LAT_ALIASES),
593        record.number(TO_LON_ALIASES),
594        record.number(TO_LAT_ALIASES),
595    );
596    if let (Some(lon1), Some(lat1), Some(lon2), Some(lat2)) = endpoints {
597        return vec![[lon1, lat1], [lon2, lat2]];
598    }
599    Vec::new()
600}
601
602// ---------------------------------------------------------------------------
603// CSV
604// ---------------------------------------------------------------------------
605
606fn read_csv(text: &str, parsed: &mut GeoParsed) {
607    let rows = csv_rows(text);
608    let Some(first) = rows.first() else { return };
609    let has_header = first
610        .iter()
611        .any(|cell| is_known_alias(&normalize_key(cell)));
612    if has_header {
613        let headers: Vec<String> = first.iter().map(|cell| normalize_key(cell)).collect();
614        for cells in &rows[1..] {
615            let record = Record {
616                fields: headers
617                    .iter()
618                    .zip(cells)
619                    .map(|(header, cell)| (header.clone(), Value::String(cell.clone())))
620                    .collect(),
621            };
622            read_record(&record, parsed);
623        }
624    } else {
625        // Headerless buscoords: `bus, x, y` (the OpenDSS sidecar layout).
626        for cells in &rows {
627            read_buscoords_row(cells, parsed);
628        }
629    }
630}
631
632fn is_known_alias(normalized: &str) -> bool {
633    [
634        BUS_ID_ALIASES,
635        LAT_ALIASES,
636        LON_ALIASES,
637        FROM_ALIASES,
638        TO_ALIASES,
639        BRANCH_ID_ALIASES,
640        PATH_ALIASES,
641        NAME_ALIASES,
642        FROM_LAT_ALIASES,
643        FROM_LON_ALIASES,
644        TO_LAT_ALIASES,
645        TO_LON_ALIASES,
646        &["uid", "target", "kind"],
647    ]
648    .iter()
649    .any(|aliases| aliases.contains(&normalized))
650}
651
652fn read_buscoords_row(cells: &[String], parsed: &mut GeoParsed) {
653    // Buscoords in the wild are comma or whitespace separated; a row that
654    // arrived as one comma-free cell splits on whitespace.
655    let split: Vec<String>;
656    let cells = if cells.len() == 1 && cells[0].split_whitespace().count() >= 3 {
657        split = cells[0].split_whitespace().map(str::to_owned).collect();
658        &split
659    } else {
660        cells
661    };
662    if cells.len() < 3 {
663        push_once(
664            &mut parsed.warnings,
665            "skipped a buscoords row with fewer than 3 columns".to_owned(),
666        );
667        return;
668    }
669    let bus = cells[0].trim();
670    let x = cells[1]
671        .trim()
672        .parse::<f64>()
673        .ok()
674        .filter(|v| v.is_finite());
675    let y = cells[2]
676        .trim()
677        .parse::<f64>()
678        .ok()
679        .filter(|v| v.is_finite());
680    let (Some(x), Some(y)) = (x, y) else {
681        push_once(
682            &mut parsed.warnings,
683            "skipped a buscoords row with unparseable coordinates".to_owned(),
684        );
685        return;
686    };
687    if bus.is_empty() {
688        return;
689    }
690    parsed.layer.features.push(GeoFeature {
691        target: GeoTarget::Bus,
692        key: ElementKey {
693            uid: None,
694            id: Some(bus.to_owned()),
695            name: Some(bus.to_owned()),
696            index: None,
697        },
698        geometry: GeoGeometry::Point([x, y]),
699        from: None,
700        to: None,
701        kind: None,
702    });
703}
704
705/// RFC-style quoted CSV split into trimmed cells; blank rows dropped.
706/// Deliberately separate from the strict case-file CSV reader in
707/// `format::pypsa`: this one parses untrusted sidecars, so malformed quoting
708/// degrades instead of erroring.
709fn csv_rows(text: &str) -> Vec<Vec<String>> {
710    let mut rows = Vec::new();
711    let mut row: Vec<String> = Vec::new();
712    let mut cell = String::new();
713    let mut quoted = false;
714    let mut chars = text.chars().peekable();
715    while let Some(c) = chars.next() {
716        if quoted {
717            if c == '"' && chars.peek() == Some(&'"') {
718                cell.push('"');
719                chars.next();
720            } else if c == '"' {
721                quoted = false;
722            } else {
723                cell.push(c);
724            }
725            continue;
726        }
727        match c {
728            '"' => quoted = true,
729            ',' => {
730                row.push(std::mem::take(&mut cell));
731                cell.clear();
732            }
733            '\n' => {
734                row.push(std::mem::take(&mut cell));
735                rows.push(std::mem::take(&mut row));
736            }
737            '\r' => {}
738            _ => cell.push(c),
739        }
740    }
741    if !cell.is_empty() || !row.is_empty() {
742        row.push(cell);
743        rows.push(row);
744    }
745    rows.retain(|row| row.iter().any(|cell| !cell.trim().is_empty()));
746    for row in &mut rows {
747        for cell in row.iter_mut() {
748            // Reallocate only when there is whitespace to strip.
749            let trimmed = cell.trim();
750            if trimmed.len() != cell.len() {
751                *cell = trimmed.to_owned();
752            }
753        }
754    }
755    rows
756}
757
758/// Without a declared space, coordinates that all fit longitude and latitude
759/// bounds read as geographic; anything else stays unknown.
760fn inferred_space(layer: &GeoLayer) -> CoordinateSpace {
761    let mut points = layer.features.iter().flat_map(|feature| {
762        let slice: &[[f64; 2]] = match &feature.geometry {
763            GeoGeometry::Point(point) => std::slice::from_ref(point),
764            GeoGeometry::LineString(path) => path,
765        };
766        slice.iter()
767    });
768    if points.all(|[x, y]| x.abs() <= 180.0 && y.abs() <= 90.0) {
769        CoordinateSpace::Geographic { crs: None }
770    } else {
771        CoordinateSpace::Unknown
772    }
773}
774
775/// Reader notes are bounded: the dedup scan is linear, so an unbounded
776/// number of distinct notes from adversarial input would go quadratic.
777const MAX_READER_NOTES: usize = 16;
778
779fn push_once(warnings: &mut Vec<String>, warning: String) {
780    if warnings.len() >= MAX_READER_NOTES {
781        return;
782    }
783    if !warnings.contains(&warning) {
784        warnings.push(warning);
785        if warnings.len() == MAX_READER_NOTES {
786            warnings.push("further reader notes suppressed".to_owned());
787        }
788    }
789}
790
791// ---------------------------------------------------------------------------
792// Extract and apply on the balanced network
793// ---------------------------------------------------------------------------
794
795impl BalancedNetwork {
796    /// Extract this network's coordinates as a standalone [`GeoLayer`]:
797    /// one point per located bus, one route per routed branch. The layer
798    /// carries the network's coordinate space and default provenance.
799    #[must_use]
800    pub fn geo_layer(&self) -> GeoLayer {
801        let mut features = Vec::new();
802        for (row, bus) in self.buses.iter().enumerate() {
803            let Some(location) = bus.location else {
804                continue;
805            };
806            features.push(GeoFeature {
807                target: GeoTarget::Bus,
808                key: ElementKey {
809                    uid: Some(payload_uid("buses", row, bus.uid.as_deref())),
810                    id: Some(bus.id.to_string()),
811                    name: bus.name.clone(),
812                    index: None,
813                },
814                geometry: GeoGeometry::Point([location.x, location.y]),
815                from: None,
816                to: None,
817                kind: location.kind,
818            });
819        }
820        for (row, branch) in self.branches.iter().enumerate() {
821            let Some(route) = &branch.route else {
822                continue;
823            };
824            features.push(GeoFeature {
825                target: GeoTarget::Branch,
826                key: ElementKey {
827                    uid: Some(payload_uid("branches", row, branch.uid.as_deref())),
828                    id: None,
829                    name: None,
830                    index: None,
831                },
832                geometry: GeoGeometry::LineString(
833                    route.iter().map(|point| [point.x, point.y]).collect(),
834                ),
835                from: Some(branch.from.to_string()),
836                to: Some(branch.to.to_string()),
837                kind: None,
838            });
839        }
840        GeoLayer {
841            space: self
842                .geo
843                .as_ref()
844                .map_or(CoordinateSpace::Unknown, |geo| geo.space.clone()),
845            kind: self.geo.as_ref().and_then(|geo| geo.kind),
846            features,
847        }
848    }
849
850    /// Apply a [`GeoLayer`] onto this network: matched bus points land in
851    /// `Bus.location`, matched branch routes in `Branch.route`, and the
852    /// layer's space becomes the network's [`GeoMeta`] when anything matched.
853    /// Matching follows [`ElementKey`]. Substation features are not applied
854    /// here; join them through [`super::apply_substation_points`].
855    pub fn apply_geo_layer(&mut self, layer: &GeoLayer) -> GeoApplyReport {
856        let mut target = BalancedApply {
857            buses: BalancedBusIndex::new(self),
858            branches: BalancedBranchIndex::new(self),
859            net: self,
860        };
861        let mut report = apply_geo_features(layer, &mut target);
862        if report.matched_buses > 0 || report.matched_branches > 0 {
863            note_space_change(&mut report, self.geo.as_ref(), &layer.space);
864            self.geo = Some(GeoMeta {
865                space: layer.space.clone(),
866                kind: layer.kind,
867            });
868        }
869        report
870    }
871}
872
873/// Note when an apply moves the network to a different coordinate space, so
874/// replacing (say) geographic locations with diagram points is never silent.
875pub(super) fn note_space_change(
876    report: &mut GeoApplyReport,
877    previous: Option<&GeoMeta>,
878    space: &CoordinateSpace,
879) {
880    if let Some(previous) = previous {
881        if previous.space != *space {
882            report.notes.push(format!(
883                "the network's coordinate space changed from {} to {}",
884                previous.space.token(),
885                space.token()
886            ));
887        }
888    }
889}
890
891/// The model half of one [`apply_geo_features`] pass: how a feature key
892/// resolves to a row, and how a matched point or route lands on the model.
893pub trait GeoApplyTarget {
894    fn bus_row(&self, key: &ElementKey) -> Option<usize>;
895    fn branch_row(&self, feature: &GeoFeature) -> Option<usize>;
896    fn place_bus(&mut self, row: usize, point: [f64; 2], kind: Option<CoordsKind>);
897    fn place_branch(&mut self, row: usize, path: &[[f64; 2]], kind: Option<CoordsKind>);
898    /// Report note for substation features this target cannot place.
899    fn substation_note(&self, count: usize) -> String;
900    /// Elements the model still has no geometry for, as (buses with no
901    /// location, branches with no route).
902    fn unlocated_counts(&self) -> (usize, usize);
903}
904
905/// One apply pass over a layer's features. The model-specific lookups and
906/// placements come from the [`GeoApplyTarget`]; the feature dispatch, match
907/// counting, and substation bookkeeping live here, so the balanced network
908/// and the multiconductor glue in `powerio-pkg` report identically.
909pub fn apply_geo_features(layer: &GeoLayer, target: &mut impl GeoApplyTarget) -> GeoApplyReport {
910    let mut report = GeoApplyReport::default();
911    let mut substations = 0usize;
912    for feature in &layer.features {
913        match (&feature.target, &feature.geometry) {
914            (GeoTarget::Bus, GeoGeometry::Point(point)) => {
915                if let Some(row) = target.bus_row(&feature.key) {
916                    target.place_bus(row, *point, feature.kind);
917                    report.matched_buses += 1;
918                } else {
919                    report.unmatched_features += 1;
920                }
921            }
922            (GeoTarget::Branch, GeoGeometry::LineString(path)) => {
923                if let Some(row) = target.branch_row(feature) {
924                    target.place_branch(row, path, feature.kind);
925                    report.matched_branches += 1;
926                } else {
927                    report.unmatched_features += 1;
928                }
929            }
930            (GeoTarget::Substation, _) => substations += 1,
931            _ => report.unmatched_features += 1,
932        }
933    }
934    if substations > 0 {
935        report.unmatched_features += substations;
936        report.notes.push(target.substation_note(substations));
937    }
938    (report.unlocated_buses, report.unlocated_branches) = target.unlocated_counts();
939    report
940}
941
942/// The balanced network as an apply target.
943struct BalancedApply<'a> {
944    net: &'a mut BalancedNetwork,
945    buses: BalancedBusIndex,
946    branches: BalancedBranchIndex,
947}
948
949impl GeoApplyTarget for BalancedApply<'_> {
950    fn bus_row(&self, key: &ElementKey) -> Option<usize> {
951        self.buses.row_for(key)
952    }
953
954    fn branch_row(&self, feature: &GeoFeature) -> Option<usize> {
955        self.branches.row_for(feature, self.net.branches.len())
956    }
957
958    fn place_bus(&mut self, row: usize, point: [f64; 2], kind: Option<CoordsKind>) {
959        self.net.buses[row].location = Some(Location {
960            x: point[0],
961            y: point[1],
962            kind,
963        });
964    }
965
966    fn place_branch(&mut self, row: usize, path: &[[f64; 2]], kind: Option<CoordsKind>) {
967        self.net.branches[row].route = Some(
968            path.iter()
969                .map(|[x, y]| Location { x: *x, y: *y, kind })
970                .collect(),
971        );
972    }
973
974    fn substation_note(&self, count: usize) -> String {
975        format!("{count} substation feature(s) not applied; join them with apply_substation_points")
976    }
977
978    fn unlocated_counts(&self) -> (usize, usize) {
979        unlocated_counts(self.net)
980    }
981}
982
983/// Buses with no location and branches with no route. Every apply pass over a
984/// balanced network reports through this one count, so the substation join
985/// and the feature join cannot disagree.
986pub(super) fn unlocated_counts(net: &BalancedNetwork) -> (usize, usize) {
987    (
988        net.buses
989            .iter()
990            .filter(|bus| bus.location.is_none())
991            .count(),
992        net.branches
993            .iter()
994            .filter(|branch| branch.route.is_none())
995            .count(),
996    )
997}
998
999/// Bus row lookups for one apply pass: by uid (element uid and payload row
1000/// uid), external id, and case insensitive name.
1001struct BalancedBusIndex {
1002    ids: HashMap<BusId, usize>,
1003    uids: HashMap<String, usize>,
1004    names: HashMap<String, usize>,
1005}
1006
1007impl BalancedBusIndex {
1008    fn new(net: &BalancedNetwork) -> Self {
1009        let mut index = Self {
1010            ids: HashMap::new(),
1011            uids: HashMap::new(),
1012            names: HashMap::new(),
1013        };
1014        for (row, bus) in net.buses.iter().enumerate() {
1015            index.ids.insert(bus.id, row);
1016            index
1017                .uids
1018                .insert(payload_uid("buses", row, bus.uid.as_deref()), row);
1019            if let Some(uid) = &bus.uid {
1020                index.uids.insert(uid.clone(), row);
1021            }
1022            if let Some(name) = &bus.name {
1023                index.names.entry(name.to_ascii_lowercase()).or_insert(row);
1024            }
1025        }
1026        index
1027    }
1028
1029    fn row_for(&self, key: &ElementKey) -> Option<usize> {
1030        key.uid
1031            .as_ref()
1032            .and_then(|uid| self.uids.get(uid))
1033            .or_else(|| {
1034                // A numeric id is the external BusId; a string id (one serialized
1035                // form serves the string-keyed multiconductor model too)
1036                // matches the bus name.
1037                let id = key.id.as_ref()?;
1038                match id.parse::<usize>() {
1039                    Ok(id) => self.ids.get(&BusId(id)),
1040                    Err(_) => self.names.get(&id.to_ascii_lowercase()),
1041                }
1042            })
1043            .or_else(|| {
1044                key.name
1045                    .as_ref()
1046                    .and_then(|name| self.names.get(&name.to_ascii_lowercase()))
1047            })
1048            .copied()
1049    }
1050}
1051
1052/// Branch row lookups for one apply pass: by uid, positional row alias, and
1053/// the unordered endpoint pair.
1054struct BalancedBranchIndex {
1055    uids: HashMap<String, usize>,
1056    pairs: HashMap<(BusId, BusId), usize>,
1057}
1058
1059impl BalancedBranchIndex {
1060    fn new(net: &BalancedNetwork) -> Self {
1061        let mut index = Self {
1062            uids: HashMap::new(),
1063            pairs: HashMap::new(),
1064        };
1065        for (row, branch) in net.branches.iter().enumerate() {
1066            index
1067                .uids
1068                .insert(payload_uid("branches", row, branch.uid.as_deref()), row);
1069            if let Some(uid) = &branch.uid {
1070                index.uids.insert(uid.clone(), row);
1071            }
1072            index
1073                .pairs
1074                .entry(ordered_pair(branch.from, branch.to))
1075                .or_insert(row);
1076        }
1077        index
1078    }
1079
1080    fn row_for(&self, feature: &GeoFeature, branches: usize) -> Option<usize> {
1081        feature
1082            .key
1083            .uid
1084            .as_ref()
1085            .and_then(|uid| self.uids.get(uid).copied())
1086            .or_else(|| {
1087                // Balanced branches have no external id or name of their own;
1088                // a foreign record's id/name still matches a source uid, the
1089                // documented uid -> id -> name order.
1090                feature
1091                    .key
1092                    .id
1093                    .as_ref()
1094                    .and_then(|id| self.uids.get(id))
1095                    .or_else(|| {
1096                        feature
1097                            .key
1098                            .name
1099                            .as_ref()
1100                            .and_then(|name| self.uids.get(name))
1101                    })
1102                    .copied()
1103            })
1104            .or_else(|| {
1105                // Positional row alias, 1-based (MATPOWER rows).
1106                feature
1107                    .key
1108                    .index
1109                    .and_then(|index| index.checked_sub(1))
1110                    .filter(|row| *row < branches)
1111            })
1112            .or_else(|| {
1113                let from = feature.from.as_ref()?.parse::<usize>().ok()?;
1114                let to = feature.to.as_ref()?.parse::<usize>().ok()?;
1115                self.pairs
1116                    .get(&ordered_pair(BusId(from), BusId(to)))
1117                    .copied()
1118            })
1119    }
1120}
1121
1122/// The payload row uid (`buses:3`), preferring the element's own uid. The same
1123/// identity `powerio-pkg` stamps on payload rows, so a layer written from a
1124/// package round-trips.
1125fn payload_uid(table: &str, row: usize, uid: Option<&str>) -> String {
1126    uid.map_or_else(|| format!("{table}:{row}"), str::to_owned)
1127}
1128
1129fn ordered_pair(a: BusId, b: BusId) -> (BusId, BusId) {
1130    if b.0 < a.0 { (b, a) } else { (a, b) }
1131}