1use 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
21pub const GEO_LAYER_EXTENSION: &str = "geo.json";
23
24const FMT: &str = "geo layer";
25
26#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct GeoLayer {
31 pub space: CoordinateSpace,
33 #[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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub kind: Option<CoordsKind>,
56}
57
58#[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 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#[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#[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#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub struct GeoParsed {
113 pub layer: GeoLayer,
114 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
116}
117
118impl GeoParsed {
119 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#[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 pub unlocated_buses: usize,
141 pub unlocated_branches: usize,
143 pub notes: Vec<String>,
144}
145
146impl GeoApplyReport {
147 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 pub fn parse(text: &str, name_hint: Option<&str>) -> Result<GeoParsed> {
170 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 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 #[must_use]
252 #[allow(clippy::missing_panics_doc)] 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
282struct 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
343struct 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
390const 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
432struct 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
473fn 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 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 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
566fn 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
576fn branch_key(record: &Record) -> ElementKey {
579 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
641fn 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
675fn 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
723fn 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 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 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
826fn 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 let trimmed = cell.trim();
871 if trimmed.len() != cell.len() {
872 *cell = trimmed.to_owned();
873 }
874 }
875 }
876 rows
877}
878
879fn 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
896const 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
918impl BalancedNetwork {
923 #[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 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
1000pub(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
1018pub 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 fn substation_note(&self, count: usize) -> String;
1027 fn unlocated_counts(&self) -> (usize, usize);
1030}
1031
1032pub 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
1069struct 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
1110pub(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
1126struct 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 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
1179struct 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 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 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
1249fn 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 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}