1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use super::DistCoordsKind;
8use crate::MulticonductorNetwork;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum DistLocationTarget {
16 Bus,
17 Line,
18 LineCode,
19 Switch,
20 Transformer,
21 Load,
22 Generator,
23 Ibr,
24 Shunt,
25 Capacitor,
26 Source,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[serde(deny_unknown_fields)]
35#[non_exhaustive]
36pub struct DistGeographicPoint {
37 pub latitude: Option<f64>,
38 pub longitude: Option<f64>,
39 pub datum: Option<String>,
40}
41
42impl DistGeographicPoint {
43 #[must_use]
44 pub const fn new(latitude: Option<f64>, longitude: Option<f64>) -> Self {
45 Self {
46 latitude,
47 longitude,
48 datum: None,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57#[serde(deny_unknown_fields)]
58#[non_exhaustive]
59pub struct DistComponentLocation {
60 pub target: DistLocationTarget,
61 pub name: String,
62 pub point: DistGeographicPoint,
63 pub kind: Option<DistCoordsKind>,
64}
65
66impl DistComponentLocation {
67 #[must_use]
68 pub fn new(
69 target: DistLocationTarget,
70 name: impl Into<String>,
71 point: DistGeographicPoint,
72 ) -> Self {
73 Self {
74 target,
75 name: name.into(),
76 point,
77 kind: None,
78 }
79 }
80}
81
82pub const MAX_COMPONENT_LOCATIONS: usize = 1_048_576;
84
85impl MulticonductorNetwork {
86 pub fn validate_component_locations(&self) -> Result<(), powerio_core::Error> {
89 validate(self).map_err(|message| {
90 powerio_core::Error::new(
91 &crate::diagnostics::codes::VALIDATE_DIST_COORDINATES_INVALID,
92 message,
93 )
94 })
95 }
96}
97
98fn validate(net: &MulticonductorNetwork) -> Result<(), String> {
99 let locations = net.component_locations();
100 if locations.is_empty() {
101 return Ok(());
102 }
103 if locations.len() > MAX_COMPONENT_LOCATIONS {
104 return Err(format!(
105 "component locations exceed {MAX_COMPONENT_LOCATIONS} records"
106 ));
107 }
108 let mut identities = BTreeSet::new();
109 macro_rules! names {
110 ($kind:ident, $rows:expr, $name:ident) => {
111 for row in $rows {
112 identities.insert((DistLocationTarget::$kind, row.$name.as_str()));
113 }
114 };
115 }
116 names!(Bus, net.buses(), id);
117 names!(Line, net.lines(), name);
118 names!(LineCode, net.line_codes(), name);
119 names!(Switch, net.switches(), name);
120 names!(Transformer, net.transformers(), name);
121 names!(Load, net.loads(), name);
122 names!(Generator, net.generators(), name);
123 names!(Ibr, net.ibrs(), name);
124 names!(Shunt, net.shunts(), name);
125 names!(Capacitor, net.capacitors(), name);
126 names!(Source, net.sources(), name);
127 let mut seen = BTreeSet::new();
128 for location in locations {
129 let key = (location.target, location.name.as_str());
130 if !identities.contains(&key) {
131 return Err(format!(
132 "coordinate attachment {key:?} has no exact component identity"
133 ));
134 }
135 if !seen.insert(key) {
136 return Err(format!("duplicate coordinate attachment {key:?}"));
137 }
138 let point = &location.point;
139 if point.latitude.is_none() && point.longitude.is_none() {
140 return Err(format!("coordinate attachment {key:?} has no stated axis"));
141 }
142 for (axis, value, bound) in [
143 ("latitude", point.latitude, 90.0),
144 ("longitude", point.longitude, 180.0),
145 ] {
146 if value.is_some_and(|v| !v.is_finite() || !(-bound..=bound).contains(&v)) {
147 return Err(format!("coordinate attachment {key:?} has invalid {axis}"));
148 }
149 }
150 if point.datum.as_ref().is_some_and(|d| d.trim().is_empty()) {
151 return Err(format!("coordinate attachment {key:?} has an empty datum"));
152 }
153 }
154 Ok(())
155}