1use std::collections::HashMap;
13
14use serde_json::{Map, Value, json};
15
16use super::{Canvas, CoordinateSpace, CoordsKind, GeoMeta, Location};
17use crate::network::{BalancedNetwork, BusId};
18use crate::{Error, Result};
19
20pub const GEO_LAYER_EXTENSION: &str = "geo.json";
22
23const FMT: &str = "geo layer";
24
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub struct GeoLayer {
30 pub space: CoordinateSpace,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub kind: Option<CoordsKind>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub features: Vec<GeoFeature>,
37}
38
39#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct GeoFeature {
44 pub target: GeoTarget,
45 pub key: ElementKey,
46 pub geometry: GeoGeometry,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub from: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub to: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub kind: Option<CoordsKind>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum GeoTarget {
63 Bus,
64 Branch,
65 Substation,
68}
69
70impl GeoTarget {
71 fn token(self) -> &'static str {
72 match self {
73 GeoTarget::Bus => "bus",
74 GeoTarget::Branch => "branch",
75 GeoTarget::Substation => "substation",
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[serde(rename_all = "snake_case")]
84pub enum GeoGeometry {
85 Point([f64; 2]),
86 LineString(Vec<[f64; 2]>),
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96pub struct ElementKey {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub uid: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub id: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub name: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub index: Option<usize>,
105}
106
107#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub struct GeoParsed {
112 pub layer: GeoLayer,
113 pub diagnostics: Vec<crate::diagnostics::Diagnostic>,
115}
116
117impl GeoParsed {
118 fn note(
120 &mut self,
121 info: &'static crate::diagnostics::DiagnosticInfo,
122 message: impl Into<String>,
123 ) {
124 self.diagnostics
125 .push(crate::diagnostics::Diagnostic::of(info, message));
126 }
127}
128
129#[derive(Debug, Clone, Default, PartialEq)]
131#[non_exhaustive]
132pub struct GeoApplyReport {
133 pub matched_buses: usize,
134 pub matched_branches: usize,
135 pub unmatched_features: usize,
136 pub unlocated_buses: usize,
140 pub unlocated_branches: usize,
142 pub notes: Vec<String>,
143}
144
145impl GeoApplyReport {
146 pub fn require_located(&self) -> Result<()> {
152 if self.unlocated_buses > 0 || self.unlocated_branches > 0 {
153 return Err(Error::UnlocatedElements {
154 buses: self.unlocated_buses,
155 branches: self.unlocated_branches,
156 });
157 }
158 Ok(())
159 }
160}
161
162impl GeoLayer {
163 pub fn parse(text: &str, name_hint: Option<&str>) -> Result<GeoParsed> {
169 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
171 let mut parsed = GeoParsed {
172 layer: GeoLayer {
173 space: CoordinateSpace::Unknown,
174 kind: None,
175 features: Vec::new(),
176 },
177 diagnostics: Vec::new(),
178 };
179 let mut declared_space = false;
180 let hint_ext = name_hint
181 .and_then(|name| name.rsplit('.').next())
182 .map(str::to_ascii_lowercase);
183 let looks_json = match hint_ext.as_deref() {
184 Some("csv") => false,
185 Some("json" | "geojson") => true,
186 _ => sniff_json(text.as_bytes()),
187 };
188 if looks_json {
189 let value: Value = serde_json::from_str(text)
190 .map_err(|error| bad(format!("invalid JSON: {error}")))?;
191 if let Some(features) = feature_collection(&value) {
192 declared_space = read_powerio_geo_member(&value, &mut parsed.layer);
193 if !declared_space
194 && let Some(crs) = value.get("crs").and_then(|v| {
195 v.as_str()
196 .or_else(|| v.pointer("/properties/name").and_then(Value::as_str))
197 })
198 {
199 let geographic = matches!(
200 crs.to_ascii_uppercase().as_str(),
201 "EPSG:4326" | "OGC:CRS84" | "WGS84" | "URN:OGC:DEF:CRS:OGC:1.3:CRS84"
202 );
203 parsed.layer.space = if geographic {
204 CoordinateSpace::Geographic {
205 crs: Some(crs.into()),
206 }
207 } else {
208 CoordinateSpace::Projected {
209 crs: Some(crs.into()),
210 }
211 };
212 declared_space = true;
213 }
214 for feature in features {
215 read_geojson_feature(feature, &mut parsed);
216 }
217 } else {
218 let mut records = Vec::new();
219 collect_records(&value, &mut records);
220 for record in records {
221 read_record(&record, &mut parsed);
222 }
223 }
224 } else {
225 read_csv(text, &mut parsed);
226 }
227 if parsed.layer.features.is_empty() {
228 return Err(bad("no bus coordinates or branch routes found"));
229 }
230 if !declared_space {
231 parsed.layer.space = inferred_space(&parsed.layer);
232 }
233 Ok(parsed)
234 }
235
236 pub fn to_geojson_checked(&self) -> Result<String> {
241 if self.features.is_empty() {
242 return Err(bad("the network carries no coordinates to extract"));
243 }
244 Ok(self.to_geojson())
245 }
246
247 #[must_use]
251 pub fn to_geojson(&self) -> String {
252 let mut member = Map::new();
253 member.insert(
254 crate::version::VERSION_KEY.to_owned(),
255 json!(crate::VERSION),
256 );
257 let detail = match &self.space {
258 CoordinateSpace::Geographic { crs } | CoordinateSpace::Projected { crs } => {
259 crs.as_ref().map(crs_entry)
260 }
261 CoordinateSpace::Diagram { canvas } => canvas.as_ref().map(canvas_entry),
262 _ => None,
263 };
264 member.insert("space".to_owned(), json!(self.space.token()));
265 if let Some((key, value)) = detail {
266 member.insert(key.to_owned(), value);
267 }
268 if let Some(kind) = self.kind {
269 member.insert("kind".to_owned(), kind_value(kind));
270 }
271 let features: Vec<Value> = self.features.iter().map(feature_value).collect();
272 let document = json!({
273 "type": "FeatureCollection",
274 "powerio_geo": Value::Object(member),
275 "features": features,
276 });
277 serde_json::to_string_pretty(&document).unwrap_or_else(|_| document.to_string())
280 }
281}
282
283fn crs_entry(crs: &String) -> (&'static str, Value) {
284 ("crs", json!(crs))
285}
286
287fn canvas_entry(canvas: &Canvas) -> (&'static str, Value) {
288 (
289 "canvas",
290 serde_json::to_value(canvas).unwrap_or(Value::Null),
291 )
292}
293
294fn kind_value(kind: CoordsKind) -> Value {
295 serde_json::to_value(kind).unwrap_or(Value::Null)
296}
297
298fn feature_value(feature: &GeoFeature) -> Value {
299 let mut properties = Map::new();
300 properties.insert("target".to_owned(), json!(feature.target.token()));
301 if let Some(uid) = &feature.key.uid {
302 properties.insert("uid".to_owned(), json!(uid));
303 }
304 if let Some(id) = &feature.key.id {
305 properties.insert("id".to_owned(), json!(id));
306 }
307 if let Some(name) = &feature.key.name {
308 properties.insert("name".to_owned(), json!(name));
309 }
310 if let Some(from) = &feature.from {
311 properties.insert("from".to_owned(), json!(from));
312 }
313 if let Some(to) = &feature.to {
314 properties.insert("to".to_owned(), json!(to));
315 }
316 if let Some(kind) = feature.kind {
317 properties.insert("kind".to_owned(), kind_value(kind));
318 }
319 let geometry = match &feature.geometry {
320 GeoGeometry::Point(point) => json!({"type": "Point", "coordinates": point}),
321 GeoGeometry::LineString(path) => json!({"type": "LineString", "coordinates": path}),
322 };
323 json!({"type": "Feature", "geometry": geometry, "properties": Value::Object(properties)})
324}
325
326const BUS_ID_ALIASES: &[&str] = &["busi", "bus", "busid", "busnumber", "number", "id"];
334const LAT_ALIASES: &[&str] = &["lat", "latitude", "y"];
335const LON_ALIASES: &[&str] = &["lon", "lng", "longitude", "x"];
336const FROM_ALIASES: &[&str] = &["fbus", "from", "frombus", "busfrom"];
337const TO_ALIASES: &[&str] = &["tbus", "to", "tobus", "busto"];
338const BRANCH_ID_ALIASES: &[&str] = &["branch", "branchid", "branchnumber", "catsid"];
339const PATH_ALIASES: &[&str] = &["path", "geometry", "coordinates"];
340const FROM_LAT_ALIASES: &[&str] = &["lat1", "fromlat"];
341const FROM_LON_ALIASES: &[&str] = &["lon1", "lng1", "fromlon", "fromlng"];
342const TO_LAT_ALIASES: &[&str] = &["lat2", "tolat"];
343const TO_LON_ALIASES: &[&str] = &["lon2", "lng2", "tolon", "tolng"];
344const NAME_ALIASES: &[&str] = &["name", "busname"];
345
346fn bad(message: impl Into<String>) -> Error {
347 Error::FormatRead {
348 format: FMT,
349 message: message.into(),
350 }
351}
352
353fn sniff_json(bytes: &[u8]) -> bool {
354 bytes
355 .iter()
356 .copied()
357 .find(|byte| !byte.is_ascii_whitespace() && *byte != 0xEF && *byte != 0xBB && *byte != 0xBF)
358 .is_some_and(|byte| byte == b'{' || byte == b'[')
359}
360
361fn normalize_key(key: &str) -> String {
362 key.chars()
363 .filter(char::is_ascii_alphanumeric)
364 .map(|c| c.to_ascii_lowercase())
365 .collect()
366}
367
368struct Record {
370 fields: HashMap<String, Value>,
371}
372
373impl Record {
374 fn value(&self, aliases: &[&str]) -> Option<&Value> {
375 aliases.iter().find_map(|alias| self.fields.get(*alias))
376 }
377
378 fn number(&self, aliases: &[&str]) -> Option<f64> {
379 value_number(self.value(aliases)?)
380 }
381
382 fn string(&self, aliases: &[&str]) -> Option<String> {
383 match self.value(aliases)? {
384 Value::String(text) => {
385 let trimmed = text.trim();
386 (!trimmed.is_empty()).then(|| trimmed.to_owned())
387 }
388 Value::Number(number) => Some(number.to_string()),
389 _ => None,
390 }
391 }
392}
393
394fn value_number(value: &Value) -> Option<f64> {
395 match value {
396 Value::Number(number) => number.as_f64().filter(|v| v.is_finite()),
397 Value::String(text) => {
398 let trimmed = text.trim().trim_matches(|c| c == '\'' || c == '"');
399 trimmed.parse::<f64>().ok().filter(|v| v.is_finite())
400 }
401 _ => None,
402 }
403}
404
405fn feature_collection(value: &Value) -> Option<&Vec<Value>> {
406 value.get("features")?.as_array()
407}
408
409fn read_powerio_geo_member(value: &Value, layer: &mut GeoLayer) -> bool {
412 let Some(member) = value.get("powerio_geo").and_then(Value::as_object) else {
413 return false;
414 };
415 layer.kind = member.get("kind").and_then(read_kind);
416 let crs = member.get("crs").and_then(Value::as_str).map(str::to_owned);
417 let canvas = member
418 .get("canvas")
419 .and_then(|canvas| serde_json::from_value(canvas.clone()).ok());
420 match member.get("space").and_then(Value::as_str) {
421 Some("geographic") => layer.space = CoordinateSpace::Geographic { crs },
422 Some("projected") => layer.space = CoordinateSpace::Projected { crs },
423 Some("diagram") => layer.space = CoordinateSpace::Diagram { canvas },
424 Some(_) => layer.space = CoordinateSpace::Unknown,
425 None => return false,
426 }
427 true
428}
429
430fn read_kind(value: &Value) -> Option<CoordsKind> {
431 serde_json::from_value(value.clone()).ok()
432}
433
434fn read_geojson_feature(feature: &Value, parsed: &mut GeoParsed) {
435 let Some(geometry) = feature.get("geometry").and_then(Value::as_object) else {
436 return;
437 };
438 let properties = feature
439 .get("properties")
440 .and_then(Value::as_object)
441 .cloned()
442 .unwrap_or_default();
443 let record = Record {
444 fields: properties
445 .into_iter()
446 .map(|(key, value)| (normalize_key(&key), value))
447 .collect(),
448 };
449 let target = record.string(&["target"]);
450 let kind = record.value(&["kind"]).and_then(read_kind);
451 match geometry.get("type").and_then(Value::as_str) {
452 Some("Point") => {
453 let Some(point) = geometry.get("coordinates").and_then(coordinate) else {
454 parsed.note(
455 &crate::diagnostics::codes::READ_GEO_SOURCE_MALFORMED,
456 "skipped a Point feature with unusable coordinates",
457 );
458 return;
459 };
460 let target = match target.as_deref() {
462 Some(token) if token.eq_ignore_ascii_case("substation") => GeoTarget::Substation,
463 _ => GeoTarget::Bus,
464 };
465 parsed.layer.features.push(GeoFeature {
466 target,
467 key: point_key(&record),
468 geometry: GeoGeometry::Point(point),
469 from: None,
470 to: None,
471 kind,
472 });
473 }
474 Some("LineString") => {
475 let path = geometry
476 .get("coordinates")
477 .and_then(Value::as_array)
478 .map(|raw| coordinate_path(raw))
479 .unwrap_or_default();
480 if path.len() < 2 {
481 parsed.note(
482 &crate::diagnostics::codes::READ_GEO_SOURCE_MALFORMED,
483 "skipped a LineString feature with fewer than 2 points",
484 );
485 return;
486 }
487 push_branch_feature(&record, path, parsed);
488 }
489 Some(other) => {
490 let shown: String = other.chars().take(32).collect();
493 push_once(
494 parsed,
495 format!("skipped unsupported GeoJSON geometry `{shown}`"),
496 );
497 }
498 None => {}
499 }
500}
501
502fn point_key(record: &Record) -> ElementKey {
504 ElementKey {
505 uid: record.string(&["uid"]),
506 id: record.string(BUS_ID_ALIASES),
507 name: record.string(NAME_ALIASES),
508 index: None,
509 }
510}
511
512fn branch_key(record: &Record) -> ElementKey {
515 let id = record.string(BRANCH_ID_ALIASES).or_else(|| {
519 record.string(&["id"]).filter(|raw| {
520 raw.parse::<usize>().is_err()
521 || record.string(&["kind", "target"]).is_some_and(|kind| {
522 matches!(kind.to_ascii_lowercase().as_str(), "line" | "branch")
523 })
524 })
525 });
526 let index = id
527 .as_deref()
528 .and_then(|raw| raw.parse::<usize>().ok())
529 .filter(|_| record.string(&["uid"]).is_none());
530 ElementKey {
531 uid: record.string(&["uid"]),
532 id,
533 name: record.string(NAME_ALIASES),
534 index,
535 }
536}
537
538fn push_branch_feature(record: &Record, path: Vec<[f64; 2]>, parsed: &mut GeoParsed) {
539 let from = record.string(FROM_ALIASES);
540 let to = record.string(TO_ALIASES);
541 let key = branch_key(record);
542 if key.uid.is_none()
543 && key.id.is_none()
544 && key.name.is_none()
545 && (from.is_none() || to.is_none())
546 {
547 push_once(
548 parsed,
549 "skipped a branch route with no id, uid, name, or endpoint pair".to_owned(),
550 );
551 return;
552 }
553 parsed.layer.features.push(GeoFeature {
554 target: GeoTarget::Branch,
555 key,
556 geometry: GeoGeometry::LineString(path),
557 from,
558 to,
559 kind: record.value(&["kind"]).and_then(read_kind),
560 });
561}
562
563fn coordinate(raw: &Value) -> Option<[f64; 2]> {
564 let items = raw.as_array()?;
565 let x = value_number(items.first()?)?;
566 let y = value_number(items.get(1)?)?;
567 Some([x, y])
568}
569
570fn coordinate_path(raw: &[Value]) -> Vec<[f64; 2]> {
571 raw.iter()
572 .map(coordinate)
573 .collect::<Option<Vec<_>>>()
574 .unwrap_or_default()
575}
576
577fn collect_records(value: &Value, out: &mut Vec<Record>) {
581 match value {
582 Value::Array(items) => {
583 for item in items {
584 collect_records(item, out);
585 }
586 }
587 Value::Object(object) => {
588 let before = out.len();
589 for nested in object.values() {
590 if let Value::Array(items) = nested {
591 for item in items {
592 if item.is_object() {
593 collect_records(item, out);
594 }
595 }
596 }
597 }
598 if out.len() == before {
599 out.push(Record {
600 fields: object
601 .iter()
602 .map(|(key, value)| (normalize_key(key), value.clone()))
603 .collect(),
604 });
605 }
606 }
607 _ => {}
608 }
609}
610
611fn read_record(record: &Record, parsed: &mut GeoParsed) {
613 read_point_record(record, parsed);
614 read_branch_record(record, parsed);
615}
616
617fn read_point_record(record: &Record, parsed: &mut GeoParsed) {
618 let key = point_key(record);
619 if key.uid.is_none() && key.id.is_none() && key.name.is_none() {
620 return;
621 }
622 let (Some(lon), Some(lat)) = (record.number(LON_ALIASES), record.number(LAT_ALIASES)) else {
623 return;
624 };
625 parsed.layer.features.push(GeoFeature {
626 target: GeoTarget::Bus,
627 key,
628 geometry: GeoGeometry::Point([lon, lat]),
629 from: None,
630 to: None,
631 kind: None,
632 });
633}
634
635fn read_branch_record(record: &Record, parsed: &mut GeoParsed) {
636 let path = record_path(record);
637 if path.len() < 2 {
638 return;
639 }
640 push_branch_feature(record, path, parsed);
641}
642
643fn record_path(record: &Record) -> Vec<[f64; 2]> {
644 if let Some(Value::Array(raw)) = record.value(PATH_ALIASES) {
645 return coordinate_path(raw);
646 }
647 let endpoints = (
648 record.number(FROM_LON_ALIASES),
649 record.number(FROM_LAT_ALIASES),
650 record.number(TO_LON_ALIASES),
651 record.number(TO_LAT_ALIASES),
652 );
653 if let (Some(lon1), Some(lat1), Some(lon2), Some(lat2)) = endpoints {
654 return vec![[lon1, lat1], [lon2, lat2]];
655 }
656 Vec::new()
657}
658
659fn read_csv(text: &str, parsed: &mut GeoParsed) {
664 let rows = csv_rows(text);
665 let Some(first) = rows.first() else { return };
666 let has_header = first
667 .iter()
668 .any(|cell| is_known_alias(&normalize_key(cell)));
669 if has_header {
670 let headers: Vec<String> = first.iter().map(|cell| normalize_key(cell)).collect();
671 for cells in &rows[1..] {
672 let record = Record {
673 fields: headers
674 .iter()
675 .zip(cells)
676 .map(|(header, cell)| (header.clone(), Value::String(cell.clone())))
677 .collect(),
678 };
679 read_record(&record, parsed);
680 }
681 } else {
682 for cells in &rows {
684 read_buscoords_row(cells, parsed);
685 }
686 }
687}
688
689fn is_known_alias(normalized: &str) -> bool {
690 [
691 BUS_ID_ALIASES,
692 LAT_ALIASES,
693 LON_ALIASES,
694 FROM_ALIASES,
695 TO_ALIASES,
696 BRANCH_ID_ALIASES,
697 PATH_ALIASES,
698 NAME_ALIASES,
699 FROM_LAT_ALIASES,
700 FROM_LON_ALIASES,
701 TO_LAT_ALIASES,
702 TO_LON_ALIASES,
703 &["uid", "target", "kind"],
704 ]
705 .iter()
706 .any(|aliases| aliases.contains(&normalized))
707}
708
709fn read_buscoords_row(cells: &[String], parsed: &mut GeoParsed) {
710 let split: Vec<String>;
713 let cells = if cells.len() == 1 && cells[0].split_whitespace().count() >= 3 {
714 split = cells[0].split_whitespace().map(str::to_owned).collect();
715 &split
716 } else {
717 cells
718 };
719 if cells.len() < 3 {
720 push_once(
721 parsed,
722 "skipped a buscoords row with fewer than 3 columns".to_owned(),
723 );
724 return;
725 }
726 let bus = cells[0].trim();
727 let x = cells[1]
728 .trim()
729 .parse::<f64>()
730 .ok()
731 .filter(|v| v.is_finite());
732 let y = cells[2]
733 .trim()
734 .parse::<f64>()
735 .ok()
736 .filter(|v| v.is_finite());
737 let (Some(x), Some(y)) = (x, y) else {
738 push_once(
739 parsed,
740 "skipped a buscoords row with unparseable coordinates".to_owned(),
741 );
742 return;
743 };
744 if bus.is_empty() {
745 return;
746 }
747 parsed.layer.features.push(GeoFeature {
748 target: GeoTarget::Bus,
749 key: ElementKey {
750 uid: None,
751 id: Some(bus.to_owned()),
752 name: Some(bus.to_owned()),
753 index: None,
754 },
755 geometry: GeoGeometry::Point([x, y]),
756 from: None,
757 to: None,
758 kind: None,
759 });
760}
761
762fn csv_rows(text: &str) -> Vec<Vec<String>> {
767 let mut rows = Vec::new();
768 let mut row: Vec<String> = Vec::new();
769 let mut cell = String::new();
770 let mut quoted = false;
771 let mut chars = text.chars().peekable();
772 while let Some(c) = chars.next() {
773 if quoted {
774 if c == '"' && chars.peek() == Some(&'"') {
775 cell.push('"');
776 chars.next();
777 } else if c == '"' {
778 quoted = false;
779 } else {
780 cell.push(c);
781 }
782 continue;
783 }
784 match c {
785 '"' => quoted = true,
786 ',' => {
787 row.push(std::mem::take(&mut cell));
788 cell.clear();
789 }
790 '\n' => {
791 row.push(std::mem::take(&mut cell));
792 rows.push(std::mem::take(&mut row));
793 }
794 '\r' => {}
795 _ => cell.push(c),
796 }
797 }
798 if !cell.is_empty() || !row.is_empty() {
799 row.push(cell);
800 rows.push(row);
801 }
802 rows.retain(|row| row.iter().any(|cell| !cell.trim().is_empty()));
803 for row in &mut rows {
804 for cell in row.iter_mut() {
805 let trimmed = cell.trim();
807 if trimmed.len() != cell.len() {
808 *cell = trimmed.to_owned();
809 }
810 }
811 }
812 rows
813}
814
815fn inferred_space(layer: &GeoLayer) -> CoordinateSpace {
818 let mut points = layer.features.iter().flat_map(|feature| {
819 let slice: &[[f64; 2]] = match &feature.geometry {
820 GeoGeometry::Point(point) => std::slice::from_ref(point),
821 GeoGeometry::LineString(path) => path,
822 };
823 slice.iter()
824 });
825 if points.all(|[x, y]| x.abs() <= 180.0 && y.abs() <= 90.0) {
826 CoordinateSpace::Geographic { crs: None }
827 } else {
828 CoordinateSpace::Unknown
829 }
830}
831
832const MAX_READER_NOTES: usize = 16;
835
836fn push_once(parsed: &mut GeoParsed, warning: String) {
837 if parsed.diagnostics.len() >= MAX_READER_NOTES {
838 return;
839 }
840 if !parsed.diagnostics.iter().any(|d| d.message() == warning) {
841 parsed.note(
842 &crate::diagnostics::codes::READ_GEO_SOURCE_MALFORMED,
843 warning,
844 );
845 if parsed.diagnostics.len() == MAX_READER_NOTES {
846 parsed.note(
847 &crate::diagnostics::codes::READ_GEO_NOTES_TRUNCATED,
848 "further reader notes suppressed",
849 );
850 }
851 }
852}
853
854impl BalancedNetwork {
859 #[must_use]
863 pub fn to_geo_layer(&self) -> GeoLayer {
864 let mut features = Vec::new();
865 for (row, bus) in self.buses().iter().enumerate() {
866 let Some(location) = bus.location else {
867 continue;
868 };
869 features.push(GeoFeature {
870 target: GeoTarget::Bus,
871 key: ElementKey {
872 uid: Some(payload_uid("buses", row, bus.uid.as_deref())),
873 id: Some(bus.id.to_string()),
874 name: bus.name.clone(),
875 index: None,
876 },
877 geometry: GeoGeometry::Point([location.x, location.y]),
878 from: None,
879 to: None,
880 kind: location.kind,
881 });
882 }
883 for (row, branch) in self.branches().iter().enumerate() {
884 let Some(route) = &branch.route else {
885 continue;
886 };
887 features.push(GeoFeature {
888 target: GeoTarget::Branch,
889 key: ElementKey {
890 uid: Some(payload_uid("branches", row, branch.uid.as_deref())),
891 id: None,
892 name: None,
893 index: None,
894 },
895 geometry: GeoGeometry::LineString(
896 route.iter().map(|point| [point.x, point.y]).collect(),
897 ),
898 from: Some(branch.from.to_string()),
899 to: Some(branch.to.to_string()),
900 kind: None,
901 });
902 }
903 GeoLayer {
904 space: self
905 .geo()
906 .as_ref()
907 .map_or(CoordinateSpace::Unknown, |geo| geo.space.clone()),
908 kind: self.geo().as_ref().and_then(|geo| geo.kind),
909 features,
910 }
911 }
912
913 pub fn apply_geo_layer(&mut self, layer: &GeoLayer) -> GeoApplyReport {
919 let mut target = BalancedApply {
920 buses: BalancedBusIndex::new(self),
921 branches: BalancedBranchIndex::new(self),
922 net: self,
923 };
924 let mut report = apply_geo_features(layer, &mut target);
925 if report.matched_buses > 0 || report.matched_branches > 0 {
926 note_space_change(&mut report, self.geo().as_ref(), &layer.space);
927 *self.geo_mut() = Some(GeoMeta {
928 space: layer.space.clone(),
929 kind: layer.kind,
930 });
931 }
932 report
933 }
934}
935
936pub(super) fn note_space_change(
939 report: &mut GeoApplyReport,
940 previous: Option<&GeoMeta>,
941 space: &CoordinateSpace,
942) {
943 if let Some(previous) = previous
944 && previous.space != *space
945 {
946 report.notes.push(format!(
947 "the network's coordinate space changed from {} to {}",
948 previous.space.token(),
949 space.token()
950 ));
951 }
952}
953
954pub trait GeoApplyTarget {
957 fn bus_row(&self, key: &ElementKey) -> Option<usize>;
958 fn branch_row(&self, feature: &GeoFeature) -> Option<usize>;
959 fn place_bus(&mut self, row: usize, point: [f64; 2], kind: Option<CoordsKind>);
960 fn place_branch(&mut self, row: usize, path: &[[f64; 2]], kind: Option<CoordsKind>);
961 fn substation_note(&self, count: usize) -> String;
963 fn unlocated_counts(&self) -> (usize, usize);
966}
967
968pub fn apply_geo_features(layer: &GeoLayer, target: &mut impl GeoApplyTarget) -> GeoApplyReport {
973 let mut report = GeoApplyReport::default();
974 let mut substations = 0usize;
975 for feature in &layer.features {
976 match (&feature.target, &feature.geometry) {
977 (GeoTarget::Bus, GeoGeometry::Point(point)) => {
978 if let Some(row) = target.bus_row(&feature.key) {
979 target.place_bus(row, *point, feature.kind);
980 report.matched_buses += 1;
981 } else {
982 report.unmatched_features += 1;
983 }
984 }
985 (GeoTarget::Branch, GeoGeometry::LineString(path)) => {
986 if let Some(row) = target.branch_row(feature) {
987 target.place_branch(row, path, feature.kind);
988 report.matched_branches += 1;
989 } else {
990 report.unmatched_features += 1;
991 }
992 }
993 (GeoTarget::Substation, _) => substations += 1,
994 _ => report.unmatched_features += 1,
995 }
996 }
997 if substations > 0 {
998 report.unmatched_features += substations;
999 report.notes.push(target.substation_note(substations));
1000 }
1001 (report.unlocated_buses, report.unlocated_branches) = target.unlocated_counts();
1002 report
1003}
1004
1005struct BalancedApply<'a> {
1007 net: &'a mut BalancedNetwork,
1008 buses: BalancedBusIndex,
1009 branches: BalancedBranchIndex,
1010}
1011
1012impl GeoApplyTarget for BalancedApply<'_> {
1013 fn bus_row(&self, key: &ElementKey) -> Option<usize> {
1014 self.buses.row_for(key)
1015 }
1016
1017 fn branch_row(&self, feature: &GeoFeature) -> Option<usize> {
1018 self.branches.row_for(feature, self.net.branches().len())
1019 }
1020
1021 fn place_bus(&mut self, row: usize, point: [f64; 2], kind: Option<CoordsKind>) {
1022 self.net.buses_mut()[row].location = Some(Location {
1023 x: point[0],
1024 y: point[1],
1025 kind,
1026 });
1027 }
1028
1029 fn place_branch(&mut self, row: usize, path: &[[f64; 2]], kind: Option<CoordsKind>) {
1030 self.net.branches_mut()[row].route = Some(
1031 path.iter()
1032 .map(|[x, y]| Location { x: *x, y: *y, kind })
1033 .collect(),
1034 );
1035 }
1036
1037 fn substation_note(&self, count: usize) -> String {
1038 format!("{count} substation feature(s) not applied; join them with apply_substation_points")
1039 }
1040
1041 fn unlocated_counts(&self) -> (usize, usize) {
1042 unlocated_counts(self.net)
1043 }
1044}
1045
1046pub(super) fn unlocated_counts(net: &BalancedNetwork) -> (usize, usize) {
1050 (
1051 net.buses()
1052 .iter()
1053 .filter(|bus| bus.location.is_none())
1054 .count(),
1055 net.branches()
1056 .iter()
1057 .filter(|branch| branch.route.is_none())
1058 .count(),
1059 )
1060}
1061
1062struct BalancedBusIndex {
1065 ids: HashMap<BusId, usize>,
1066 uids: HashMap<String, usize>,
1067 names: HashMap<String, usize>,
1068}
1069
1070impl BalancedBusIndex {
1071 fn new(net: &BalancedNetwork) -> Self {
1072 let mut index = Self {
1073 ids: HashMap::new(),
1074 uids: HashMap::new(),
1075 names: HashMap::new(),
1076 };
1077 for (row, bus) in net.buses().iter().enumerate() {
1078 index.ids.insert(bus.id, row);
1079 index
1080 .uids
1081 .insert(payload_uid("buses", row, bus.uid.as_deref()), row);
1082 if let Some(uid) = &bus.uid {
1083 index.uids.insert(uid.clone(), row);
1084 }
1085 if let Some(name) = &bus.name {
1086 index.names.entry(name.to_ascii_lowercase()).or_insert(row);
1087 }
1088 }
1089 index
1090 }
1091
1092 fn row_for(&self, key: &ElementKey) -> Option<usize> {
1093 key.uid
1094 .as_ref()
1095 .and_then(|uid| self.uids.get(uid))
1096 .or_else(|| {
1097 let id = key.id.as_ref()?;
1101 match id.parse::<usize>() {
1102 Ok(id) => self.ids.get(&BusId(id)),
1103 Err(_) => self.names.get(&id.to_ascii_lowercase()),
1104 }
1105 })
1106 .or_else(|| {
1107 key.name
1108 .as_ref()
1109 .and_then(|name| self.names.get(&name.to_ascii_lowercase()))
1110 })
1111 .copied()
1112 }
1113}
1114
1115struct BalancedBranchIndex {
1118 uids: HashMap<String, usize>,
1119 pairs: HashMap<(BusId, BusId), usize>,
1120}
1121
1122impl BalancedBranchIndex {
1123 fn new(net: &BalancedNetwork) -> Self {
1124 let mut index = Self {
1125 uids: HashMap::new(),
1126 pairs: HashMap::new(),
1127 };
1128 for (row, branch) in net.branches().iter().enumerate() {
1129 index
1130 .uids
1131 .insert(payload_uid("branches", row, branch.uid.as_deref()), row);
1132 if let Some(uid) = &branch.uid {
1133 index.uids.insert(uid.clone(), row);
1134 }
1135 index
1136 .pairs
1137 .entry(ordered_pair(branch.from, branch.to))
1138 .or_insert(row);
1139 }
1140 index
1141 }
1142
1143 fn row_for(&self, feature: &GeoFeature, branches: usize) -> Option<usize> {
1144 feature
1145 .key
1146 .uid
1147 .as_ref()
1148 .and_then(|uid| self.uids.get(uid).copied())
1149 .or_else(|| {
1150 feature
1154 .key
1155 .id
1156 .as_ref()
1157 .and_then(|id| self.uids.get(id))
1158 .or_else(|| {
1159 feature
1160 .key
1161 .name
1162 .as_ref()
1163 .and_then(|name| self.uids.get(name))
1164 })
1165 .copied()
1166 })
1167 .or_else(|| {
1168 feature
1170 .key
1171 .index
1172 .and_then(|index| index.checked_sub(1))
1173 .filter(|row| *row < branches)
1174 })
1175 .or_else(|| {
1176 let from = feature.from.as_ref()?.parse::<usize>().ok()?;
1177 let to = feature.to.as_ref()?.parse::<usize>().ok()?;
1178 self.pairs
1179 .get(&ordered_pair(BusId(from), BusId(to)))
1180 .copied()
1181 })
1182 }
1183}
1184
1185fn payload_uid(table: &str, row: usize, uid: Option<&str>) -> String {
1189 uid.map_or_else(|| format!("{table}:{row}"), str::to_owned)
1190}
1191
1192fn ordered_pair(a: BusId, b: BusId) -> (BusId, BusId) {
1193 if b.0 < a.0 { (b, a) } else { (a, b) }
1194}
1195
1196#[cfg(test)]
1197mod bmopftools_geo_tests {
1198 use super::*;
1199 #[test]
1200 fn identities_endpoints_and_crs_follow_bmopftools_geojson() {
1201 let source = r#"{"type":"FeatureCollection","crs":"EPSG:2193","features":[{"type":"Feature","properties":{"kind":"bus","id":"a"},"geometry":{"type":"Point","coordinates":[1700000,5400000]}},{"type":"Feature","properties":{"kind":"line","id":"21","bus_from":"a","bus_to":"b"},"geometry":{"type":"LineString","coordinates":[[1700000,5400000],[1700010,5400000]]}}]}"#;
1202 let p = GeoLayer::parse(source, None).unwrap();
1203 assert!(p.diagnostics.is_empty());
1204 assert!(
1205 matches!(&p.layer.space,CoordinateSpace::Projected {crs:Some(s)} if s=="EPSG:2193")
1206 );
1207 let line = &p.layer.features[1];
1208 assert_eq!(line.key.id.as_deref(), Some("21"));
1209 assert_eq!(line.from.as_deref(), Some("a"));
1210 assert_eq!(line.to.as_deref(), Some("b"));
1211 assert_eq!(
1212 GeoLayer::parse(&p.layer.to_geojson(), None).unwrap().layer,
1213 p.layer
1214 );
1215 }
1216 #[test]
1217 fn malformed_route_vertex_never_becomes_a_different_route() {
1218 let source = r#"{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"bus","id":"a"},"geometry":{"type":"Point","coordinates":[-80,35]}},{"type":"Feature","properties":{"kind":"line","id":"a"},"geometry":{"type":"LineString","coordinates":[[-80,35],["bad",36],[-81,37]]}}]}"#;
1219 let p = GeoLayer::parse(source, None).unwrap();
1220 assert_eq!(p.layer.features.len(), 1);
1221 assert!(!p.diagnostics.is_empty());
1222 }
1223}