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