Skip to main content

powerio_dist/geo/
component.rs

1//! Geographic points on individual network components, independent of drawings.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use super::DistCoordsKind;
8use crate::MulticonductorNetwork;
9
10/// The typed table containing a located component. Names retain exact case.
11#[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/// Source or user supplied geographic coordinates, in decimal degrees.
30/// Neither axis is implied by the other; zero is a valid stated value.
31/// An absent datum is unknown, never an implicit WGS84 declaration.
32#[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/// A geographic point attached to one exact component identity.
54/// It is independent of bus drawing positions, line routes and electrical length.
55#[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
82/// Maximum component points in one stored network; checked before collection growth.
83pub const MAX_COMPONENT_LOCATIONS: usize = 1_048_576;
84
85impl MulticonductorNetwork {
86    /// Check point ranges, exact attachment identities and duplicate attachments.
87    /// This validates metadata only; it never changes electrical topology.
88    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}