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