Skip to main content

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