1use super::grid::{Ionex, IonexParts};
24use super::j2000_seconds_from_instant;
25use crate::astro::time::model::{Instant, InstantRepr};
26
27const IONEX_AXIS_DEG_LIMIT: f64 = 360.0;
28const NANOS_PER_SECOND: i128 = 1_000_000_000;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct TecSample {
33 pub epoch: Instant,
35 pub lat_deg: f64,
37 pub lon_deg: f64,
39 pub vtec_tecu: f64,
41 pub rms_tecu: Option<f64>,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct TecGridSamples {
48 pub map_epochs: Vec<Instant>,
50 pub lat_nodes_deg: Vec<f64>,
52 pub lon_nodes_deg: Vec<f64>,
54 pub dlat_deg: f64,
56 pub dlon_deg: f64,
58 pub shell_height_km: f64,
60 pub base_radius_km: f64,
62 pub exponent: i32,
64 pub tec_maps: Vec<Vec<Vec<f64>>>,
66 pub rms_maps: Vec<Vec<Vec<f64>>>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub enum TecSamplesError {
73 Empty,
75 TooFewNodes(usize),
77 NonMonotonicLat,
79 NonMonotonicLon,
81 NonMonotonicEpochs,
83 EpochNotRepresentable,
85 ShapeMismatch,
87 RmsCountMismatch,
89 NonFiniteValue,
91 NonPositiveStep,
93 AxisOutOfRange(f64),
95}
96
97impl core::fmt::Display for TecSamplesError {
98 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99 match self {
100 Self::Empty => write!(f, "no IONEX TEC samples supplied"),
101 Self::TooFewNodes(count) => {
102 write!(f, "IONEX grid axis has {count} nodes; need at least two")
103 }
104 Self::NonMonotonicLat => {
105 write!(f, "IONEX latitude nodes must be strictly descending")
106 }
107 Self::NonMonotonicLon => {
108 write!(f, "IONEX longitude nodes must be strictly ascending")
109 }
110 Self::NonMonotonicEpochs => {
111 write!(f, "IONEX map epochs must be strictly increasing")
112 }
113 Self::EpochNotRepresentable => {
114 write!(f, "IONEX map epoch is not an exact integer J2000 second")
115 }
116 Self::ShapeMismatch => {
117 write!(f, "IONEX TEC grid dimensions do not match the axes")
118 }
119 Self::RmsCountMismatch => write!(f, "IONEX RMS maps do not match TEC maps"),
120 Self::NonFiniteValue => write!(f, "IONEX sample value is not finite"),
121 Self::NonPositiveStep => write!(f, "IONEX grid step is not valid"),
122 Self::AxisOutOfRange(value) => {
123 write!(f, "IONEX axis value {value} is outside [-360, 360] degrees")
124 }
125 }
126 }
127}
128
129impl std::error::Error for TecSamplesError {}
130
131impl Ionex {
132 pub fn from_samples(samples: TecGridSamples) -> core::result::Result<Self, TecSamplesError> {
134 validate_grid_samples(&samples)?;
135 Self::from_parts(IonexParts {
136 lat_nodes_deg: samples.lat_nodes_deg,
137 lon_nodes_deg: samples.lon_nodes_deg,
138 dlat_deg: samples.dlat_deg,
139 dlon_deg: samples.dlon_deg,
140 shell_height_km: samples.shell_height_km,
141 base_radius_km: samples.base_radius_km,
142 exponent: samples.exponent,
143 map_epochs: samples.map_epochs,
144 tec_maps: samples.tec_maps,
145 rms_maps: samples.rms_maps,
146 skipped_records: 0,
147 })
148 .map_err(|_| {
149 TecSamplesError::ShapeMismatch
153 })
154 }
155
156 pub fn from_node_samples(
158 samples: impl IntoIterator<Item = TecSample>,
159 shell_height_km: f64,
160 base_radius_km: f64,
161 exponent: i32,
162 ) -> core::result::Result<Self, TecSamplesError> {
163 let samples: Vec<TecSample> = samples.into_iter().collect();
164 if samples.is_empty() {
165 return Err(TecSamplesError::Empty);
166 }
167 for sample in &samples {
168 validate_axis_value(sample.lat_deg)?;
169 validate_axis_value(sample.lon_deg)?;
170 validate_finite(sample.vtec_tecu)?;
171 if let Some(rms) = sample.rms_tecu {
172 validate_finite(rms)?;
173 }
174 exact_j2000_second(sample.epoch).ok_or(TecSamplesError::EpochNotRepresentable)?;
175 }
176
177 let mut map_epochs = Vec::new();
178 let mut lat_nodes_deg = Vec::new();
179 let mut lon_nodes_deg = Vec::new();
180 for sample in &samples {
181 let epoch_s =
182 exact_j2000_second(sample.epoch).ok_or(TecSamplesError::EpochNotRepresentable)?;
183 if !map_epochs
184 .iter()
185 .any(|&epoch| exact_j2000_second(epoch) == Some(epoch_s))
186 {
187 map_epochs.push(sample.epoch);
188 }
189 push_unique_bits(&mut lat_nodes_deg, sample.lat_deg);
190 push_unique_bits(&mut lon_nodes_deg, sample.lon_deg);
191 }
192
193 map_epochs.sort_by_key(|epoch| {
194 exact_j2000_second(*epoch).expect("sample epochs were already validated")
195 });
196 lat_nodes_deg.sort_by(|a, b| b.total_cmp(a));
197 lon_nodes_deg.sort_by(f64::total_cmp);
198
199 if lat_nodes_deg.len() < 2 {
200 return Err(TecSamplesError::TooFewNodes(lat_nodes_deg.len()));
201 }
202 if lon_nodes_deg.len() < 2 {
203 return Err(TecSamplesError::TooFewNodes(lon_nodes_deg.len()));
204 }
205
206 let nmap = map_epochs.len();
207 let nlat = lat_nodes_deg.len();
208 let nlon = lon_nodes_deg.len();
209 let has_rms = samples.iter().any(|sample| sample.rms_tecu.is_some());
210 let mut tec_maps = vec![vec![vec![f64::NAN; nlon]; nlat]; nmap];
211 let mut rms_maps = if has_rms {
212 vec![vec![vec![f64::NAN; nlon]; nlat]; nmap]
213 } else {
214 Vec::new()
215 };
216 let mut seen = vec![false; nmap * nlat * nlon];
217
218 for sample in samples {
219 let map_index = map_epochs
220 .iter()
221 .position(|&epoch| exact_j2000_second(epoch) == exact_j2000_second(sample.epoch))
222 .expect("sample epoch exists in the map axis");
223 let lat_index = find_bits(&lat_nodes_deg, sample.lat_deg)
224 .expect("sample latitude exists in the latitude axis");
225 let lon_index = find_bits(&lon_nodes_deg, sample.lon_deg)
226 .expect("sample longitude exists in the longitude axis");
227 let flat_index = (map_index * nlat + lat_index) * nlon + lon_index;
228 if seen[flat_index] {
229 return Err(TecSamplesError::ShapeMismatch);
230 }
231 seen[flat_index] = true;
232 tec_maps[map_index][lat_index][lon_index] = sample.vtec_tecu;
233 if has_rms {
234 let rms = sample.rms_tecu.ok_or(TecSamplesError::RmsCountMismatch)?;
235 rms_maps[map_index][lat_index][lon_index] = rms;
236 }
237 }
238 if seen.iter().any(|&value| !value) {
239 return Err(TecSamplesError::ShapeMismatch);
240 }
241
242 Self::from_samples(TecGridSamples {
243 map_epochs,
244 dlat_deg: lat_nodes_deg[1] - lat_nodes_deg[0],
245 dlon_deg: lon_nodes_deg[1] - lon_nodes_deg[0],
246 lat_nodes_deg,
247 lon_nodes_deg,
248 shell_height_km,
249 base_radius_km,
250 exponent,
251 tec_maps,
252 rms_maps,
253 })
254 }
255
256 pub fn tec_grid_samples(&self) -> TecGridSamples {
258 TecGridSamples {
259 map_epochs: self.map_epochs().to_vec(),
260 lat_nodes_deg: self.lat_nodes_deg().to_vec(),
261 lon_nodes_deg: self.lon_nodes_deg().to_vec(),
262 dlat_deg: self.dlat_deg(),
263 dlon_deg: self.dlon_deg(),
264 shell_height_km: self.shell_height_km(),
265 base_radius_km: self.base_radius_km(),
266 exponent: self.exponent(),
267 tec_maps: self.tec_maps().to_vec(),
268 rms_maps: self.rms_maps().to_vec(),
269 }
270 }
271
272 pub fn tec_samples(&self) -> Vec<TecSample> {
274 let nmap = self.map_epochs().len();
275 let nlat = self.lat_nodes_deg().len();
276 let nlon = self.lon_nodes_deg().len();
277 let mut out = Vec::with_capacity(nmap * nlat * nlon);
278 let has_rms = !self.rms_maps().is_empty();
279 for (map_index, &epoch) in self.map_epochs().iter().enumerate() {
280 for (lat_index, &lat_deg) in self.lat_nodes_deg().iter().enumerate() {
281 for (lon_index, &lon_deg) in self.lon_nodes_deg().iter().enumerate() {
282 out.push(TecSample {
283 epoch,
284 lat_deg,
285 lon_deg,
286 vtec_tecu: self.tec_maps()[map_index][lat_index][lon_index],
287 rms_tecu: if has_rms {
288 Some(self.rms_maps()[map_index][lat_index][lon_index])
289 } else {
290 None
291 },
292 });
293 }
294 }
295 }
296 out
297 }
298}
299
300fn validate_grid_samples(samples: &TecGridSamples) -> core::result::Result<(), TecSamplesError> {
301 if samples.map_epochs.is_empty() || samples.tec_maps.is_empty() {
302 return Err(TecSamplesError::Empty);
303 }
304 validate_axis(&samples.lat_nodes_deg, true)?;
305 validate_axis(&samples.lon_nodes_deg, false)?;
306 validate_finite(samples.dlat_deg)?;
307 validate_finite(samples.dlon_deg)?;
308 validate_axis_value(samples.dlat_deg)?;
309 validate_axis_value(samples.dlon_deg)?;
310 if samples.dlat_deg >= 0.0 || samples.dlon_deg <= 0.0 {
311 return Err(TecSamplesError::NonPositiveStep);
312 }
313 validate_finite(samples.shell_height_km)?;
314 validate_finite(samples.base_radius_km)?;
315 validate_epochs(&samples.map_epochs)?;
316
317 if samples.tec_maps.len() != samples.map_epochs.len() {
318 return Err(TecSamplesError::ShapeMismatch);
319 }
320 validate_maps(
321 &samples.tec_maps,
322 samples.map_epochs.len(),
323 samples.lat_nodes_deg.len(),
324 samples.lon_nodes_deg.len(),
325 TecSamplesError::ShapeMismatch,
326 )?;
327 if !samples.rms_maps.is_empty() {
328 if samples.rms_maps.len() != samples.map_epochs.len() {
329 return Err(TecSamplesError::RmsCountMismatch);
330 }
331 validate_maps(
332 &samples.rms_maps,
333 samples.map_epochs.len(),
334 samples.lat_nodes_deg.len(),
335 samples.lon_nodes_deg.len(),
336 TecSamplesError::RmsCountMismatch,
337 )?;
338 }
339 Ok(())
340}
341
342fn validate_axis(nodes: &[f64], descending: bool) -> core::result::Result<(), TecSamplesError> {
343 if nodes.len() < 2 {
344 return Err(TecSamplesError::TooFewNodes(nodes.len()));
345 }
346 for &node in nodes {
347 validate_axis_value(node)?;
348 }
349 if descending {
350 if nodes.windows(2).any(|w| w[1] >= w[0]) {
351 return Err(TecSamplesError::NonMonotonicLat);
352 }
353 } else if nodes.windows(2).any(|w| w[1] <= w[0]) {
354 return Err(TecSamplesError::NonMonotonicLon);
355 }
356 Ok(())
357}
358
359fn validate_epochs(map_epochs: &[Instant]) -> core::result::Result<(), TecSamplesError> {
360 let mut previous_s = None;
361 for &epoch in map_epochs {
362 let seconds = exact_j2000_second(epoch).ok_or(TecSamplesError::EpochNotRepresentable)?;
363 if previous_s.is_some_and(|previous| seconds <= previous) {
364 return Err(TecSamplesError::NonMonotonicEpochs);
365 }
366 previous_s = Some(seconds);
367 }
368 Ok(())
369}
370
371fn validate_maps(
372 maps: &[Vec<Vec<f64>>],
373 expected_maps: usize,
374 expected_lat: usize,
375 expected_lon: usize,
376 dimension_error: TecSamplesError,
377) -> core::result::Result<(), TecSamplesError> {
378 if maps.len() != expected_maps {
379 return Err(dimension_error.clone());
380 }
381 for map in maps {
382 if map.len() != expected_lat {
383 return Err(dimension_error.clone());
384 }
385 for row in map {
386 if row.len() != expected_lon {
387 return Err(dimension_error.clone());
388 }
389 for &value in row {
390 validate_finite(value)?;
391 }
392 }
393 }
394 Ok(())
395}
396
397fn validate_axis_value(value: f64) -> core::result::Result<(), TecSamplesError> {
398 validate_finite(value)?;
399 if !(-IONEX_AXIS_DEG_LIMIT..=IONEX_AXIS_DEG_LIMIT).contains(&value) {
400 return Err(TecSamplesError::AxisOutOfRange(value));
401 }
402 Ok(())
403}
404
405fn validate_finite(value: f64) -> core::result::Result<(), TecSamplesError> {
406 if value.is_finite() {
407 Ok(())
408 } else {
409 Err(TecSamplesError::NonFiniteValue)
410 }
411}
412
413fn exact_j2000_second(epoch: Instant) -> Option<i64> {
414 match epoch.repr {
415 InstantRepr::Nanos(nanos) => {
416 if nanos % NANOS_PER_SECOND != 0 {
417 return None;
418 }
419 let seconds = nanos / NANOS_PER_SECOND;
420 i64::try_from(seconds).ok()
421 }
422 InstantRepr::JulianDate(_) => j2000_seconds_from_instant(epoch),
423 }
424}
425
426fn push_unique_bits(values: &mut Vec<f64>, value: f64) {
427 if !values
428 .iter()
429 .any(|&existing| same_axis_node(existing, value))
430 {
431 values.push(value);
432 }
433}
434
435fn find_bits(values: &[f64], value: f64) -> Option<usize> {
436 values
437 .iter()
438 .position(|&existing| same_axis_node(existing, value))
439}
440
441fn same_axis_node(a: f64, b: f64) -> bool {
442 a == b || a.to_bits() == b.to_bits()
443}