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