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