1use crate::{CoordError, CoordResult};
2use celestial_core::constants::{
3 ARCSEC_TO_RAD, DAYS_PER_JULIAN_CENTURY, J2000_JD, MILLIARCSEC_TO_RAD, MJD_ZERO_POINT,
4};
5use celestial_time::{transforms::earth_rotation_angle, JulianDate};
6
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct EopRecord {
13 pub mjd: f64,
14
15 pub x_p_encoded: i32,
16
17 pub y_p_encoded: i32,
18
19 pub ut1_utc_encoded: i32,
20
21 pub lod_encoded: i32,
22
23 pub dx_encoded: Option<i16>,
24
25 pub dy_encoded: Option<i16>,
26
27 pub xrt_encoded: Option<i32>,
28
29 pub yrt_encoded: Option<i32>,
30
31 pub flags: EopFlags,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub struct EopFlags {
37 pub source: EopSource,
38
39 pub quality: EopQuality,
40
41 pub has_polar_motion: bool,
42
43 pub has_ut1_utc: bool,
44
45 pub has_cip_offsets: bool,
46
47 pub has_pole_rates: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub enum EopSource {
53 IersC04,
54
55 IersFinals,
56
57 IersPrediction,
58
59 UserData,
60
61 Interpolated,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
66pub enum EopQuality {
67 HighPrecision,
68
69 Standard,
70
71 LowPrecision,
72
73 Predicted,
74}
75
76impl EopRecord {
77 const ARCSEC_TO_UNITS: f64 = 10_000_000.0;
78 const SEC_TO_UNITS: f64 = 10_000_000.0;
79 const MILLIARCSEC_TO_UNITS: f64 = 10_000.0;
80
81 pub fn new(
82 mjd: f64,
83 x_p_arcsec: f64,
84 y_p_arcsec: f64,
85 ut1_utc_sec: f64,
86 lod_sec: f64,
87 ) -> CoordResult<Self> {
88 if x_p_arcsec.abs() > 6.0 {
89 return Err(CoordError::invalid_coordinate(format!(
90 "X polar motion out of range: {} arcsec",
91 x_p_arcsec
92 )));
93 }
94
95 if y_p_arcsec.abs() > 6.0 {
96 return Err(CoordError::invalid_coordinate(format!(
97 "Y polar motion out of range: {} arcsec",
98 y_p_arcsec
99 )));
100 }
101
102 if ut1_utc_sec.abs() > 1.0 {
103 return Err(CoordError::invalid_coordinate(format!(
104 "UT1-UTC out of range: {} sec",
105 ut1_utc_sec
106 )));
107 }
108
109 if lod_sec.abs() > 0.01 {
110 return Err(CoordError::invalid_coordinate(format!(
111 "LOD out of range: {} sec",
112 lod_sec
113 )));
114 }
115
116 Ok(Self {
117 mjd,
118 x_p_encoded: libm::round(x_p_arcsec * Self::ARCSEC_TO_UNITS) as i32,
119 y_p_encoded: libm::round(y_p_arcsec * Self::ARCSEC_TO_UNITS) as i32,
120 ut1_utc_encoded: libm::round(ut1_utc_sec * Self::SEC_TO_UNITS) as i32,
121 lod_encoded: libm::round(lod_sec * Self::SEC_TO_UNITS) as i32,
122 dx_encoded: None,
123 dy_encoded: None,
124 xrt_encoded: None,
125 yrt_encoded: None,
126 flags: EopFlags::default(),
127 })
128 }
129
130 pub fn with_cip_offsets(
131 mut self,
132 dx_milliarcsec: f64,
133 dy_milliarcsec: f64,
134 ) -> CoordResult<Self> {
135 if dx_milliarcsec.abs() > 1000.0 {
136 return Err(CoordError::invalid_coordinate(format!(
137 "CIP dX out of range: {} mas",
138 dx_milliarcsec
139 )));
140 }
141
142 if dy_milliarcsec.abs() > 1000.0 {
143 return Err(CoordError::invalid_coordinate(format!(
144 "CIP dY out of range: {} mas",
145 dy_milliarcsec
146 )));
147 }
148
149 self.dx_encoded = Some(libm::round(dx_milliarcsec * Self::MILLIARCSEC_TO_UNITS) as i16);
150 self.dy_encoded = Some(libm::round(dy_milliarcsec * Self::MILLIARCSEC_TO_UNITS) as i16);
151 self.flags.has_cip_offsets = true;
152
153 Ok(self)
154 }
155
156 pub fn with_pole_rates(
157 mut self,
158 xrt_arcsec_per_day: f64,
159 yrt_arcsec_per_day: f64,
160 ) -> CoordResult<Self> {
161 if xrt_arcsec_per_day.abs() > 1.0 {
162 return Err(CoordError::invalid_coordinate(format!(
163 "Pole rate xrt out of range: {} arcsec/day",
164 xrt_arcsec_per_day
165 )));
166 }
167 if yrt_arcsec_per_day.abs() > 1.0 {
168 return Err(CoordError::invalid_coordinate(format!(
169 "Pole rate yrt out of range: {} arcsec/day",
170 yrt_arcsec_per_day
171 )));
172 }
173 self.xrt_encoded = Some(libm::round(xrt_arcsec_per_day * Self::ARCSEC_TO_UNITS) as i32);
174 self.yrt_encoded = Some(libm::round(yrt_arcsec_per_day * Self::ARCSEC_TO_UNITS) as i32);
175 self.flags.has_pole_rates = true;
176 Ok(self)
177 }
178
179 pub fn with_flags(mut self, flags: EopFlags) -> Self {
180 self.flags = flags;
181 self
182 }
183
184 pub fn to_parameters(&self) -> EopParameters {
185 EopParameters {
186 mjd: self.mjd,
187 x_p: self.x_p_encoded as f64 / Self::ARCSEC_TO_UNITS,
188 y_p: self.y_p_encoded as f64 / Self::ARCSEC_TO_UNITS,
189 ut1_utc: self.ut1_utc_encoded as f64 / Self::SEC_TO_UNITS,
190 lod: self.lod_encoded as f64 / Self::SEC_TO_UNITS,
191 dx: self
192 .dx_encoded
193 .map(|v| v as f64 / Self::MILLIARCSEC_TO_UNITS),
194 dy: self
195 .dy_encoded
196 .map(|v| v as f64 / Self::MILLIARCSEC_TO_UNITS),
197 xrt: self.xrt_encoded.map(|v| v as f64 / Self::ARCSEC_TO_UNITS),
198 yrt: self.yrt_encoded.map(|v| v as f64 / Self::ARCSEC_TO_UNITS),
199 s_prime: 0.0,
200 flags: self.flags,
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq)]
206pub struct EopParameters {
207 pub mjd: f64,
208
209 pub x_p: f64,
210
211 pub y_p: f64,
212
213 pub ut1_utc: f64,
214
215 pub lod: f64,
216
217 pub dx: Option<f64>,
218
219 pub dy: Option<f64>,
220
221 pub xrt: Option<f64>,
222
223 pub yrt: Option<f64>,
224
225 pub s_prime: f64,
226
227 pub flags: EopFlags,
228}
229
230impl EopParameters {
231 pub fn compute_s_prime(&mut self) {
238 self.compute_s_prime_jd(self.mjd + MJD_ZERO_POINT, 0.0);
239 }
240
241 pub fn compute_s_prime_jd(&mut self, tt1: f64, tt2: f64) {
246 let t = ((tt1 - J2000_JD) + tt2) / DAYS_PER_JULIAN_CENTURY;
247 self.s_prime = -47e-6 * t * ARCSEC_TO_RAD;
248 }
249
250 pub fn compute_era(&self) -> CoordResult<f64> {
251 let ut1_jd = self.mjd
252 + MJD_ZERO_POINT
253 + self.ut1_utc / celestial_core::constants::SECONDS_PER_DAY_F64;
254
255 let ut1_jd1 = libm::floor(ut1_jd);
256 let ut1_jd2 = ut1_jd - ut1_jd1;
257
258 let jd = JulianDate::new(ut1_jd1, ut1_jd2);
259 earth_rotation_angle(&jd)
260 .map_err(|e| CoordError::external_library("Earth Rotation Angle", &e.to_string()))
261 }
262
263 pub fn corrected_cip_x(&self, x_iau: f64) -> f64 {
265 x_iau + self.dx.unwrap_or(0.0) * MILLIARCSEC_TO_RAD
266 }
267
268 pub fn corrected_cip_y(&self, y_iau: f64) -> f64 {
270 y_iau + self.dy.unwrap_or(0.0) * MILLIARCSEC_TO_RAD
271 }
272}
273
274impl Default for EopFlags {
275 fn default() -> Self {
276 Self {
277 source: EopSource::UserData,
278 quality: EopQuality::Standard,
279 has_polar_motion: true,
280 has_ut1_utc: true,
281 has_cip_offsets: false,
282 has_pole_rates: false,
283 }
284 }
285}
286
287impl std::fmt::Display for EopParameters {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 write!(
290 f,
291 "EOP(MJD={:.1}, xp={:.6}\", yp={:.6}\", UT1-UTC={:.7}s)",
292 self.mjd, self.x_p, self.y_p, self.ut1_utc
293 )
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_eop_record_encoding() {
303 let record = EopRecord::new(
304 59945.0, 0.123456, 0.234567, 0.0123456, 0.0012345, )
310 .unwrap();
311
312 let params = record.to_parameters();
313
314 assert!((params.x_p - 0.123456).abs() == 0.0);
316 assert!((params.y_p - 0.234567).abs() == 0.0);
317 assert!((params.ut1_utc - 0.0123456).abs() == 0.0);
318 assert!((params.lod - 0.0012345).abs() == 0.0);
319 assert_eq!(params.mjd, 59945.0);
320 }
321
322 #[test]
323 fn test_cip_offsets() {
324 let record = EopRecord::new(59945.0, 0.1, 0.2, 0.01, 0.001)
325 .unwrap()
326 .with_cip_offsets(0.5, -0.3) .unwrap();
328
329 let params = record.to_parameters();
330
331 assert_eq!(params.dx, Some(0.5));
332 assert_eq!(params.dy, Some(-0.3));
333 assert!(params.flags.has_cip_offsets);
334 }
335
336 #[test]
337 fn test_parameter_display() {
338 let mut params = EopParameters {
339 mjd: 59945.0,
340 x_p: 0.123456,
341 y_p: 0.234567,
342 ut1_utc: 0.0123456,
343 lod: 0.001,
344 dx: None,
345 dy: None,
346 xrt: None,
347 yrt: None,
348 s_prime: 0.0,
349 flags: EopFlags::default(),
350 };
351
352 params.compute_s_prime();
353
354 let display = format!("{}", params);
355 assert!(display.contains("MJD=59945.0"));
356 assert!(display.contains("xp=0.123456"));
357 assert!(display.contains("yp=0.234567"));
358 }
359
360 #[test]
361 fn test_validation_x_polar_motion_out_of_range() {
362 let result = EopRecord::new(59945.0, 6.1, 0.2, 0.01, 0.001);
363 assert!(result.is_err());
364 let err = result.unwrap_err();
365 assert!(err.to_string().contains("X polar motion out of range"));
366 }
367
368 #[test]
369 fn test_validation_y_polar_motion_out_of_range() {
370 let result = EopRecord::new(59945.0, 0.1, -6.1, 0.01, 0.001);
371 assert!(result.is_err());
372 let err = result.unwrap_err();
373 assert!(err.to_string().contains("Y polar motion out of range"));
374 }
375
376 #[test]
377 fn test_validation_ut1_utc_out_of_range() {
378 let result = EopRecord::new(59945.0, 0.1, 0.2, 1.1, 0.001);
379 assert!(result.is_err());
380 let err = result.unwrap_err();
381 assert!(err.to_string().contains("UT1-UTC out of range"));
382 }
383
384 #[test]
385 fn test_validation_lod_out_of_range() {
386 let result = EopRecord::new(59945.0, 0.1, 0.2, 0.01, 0.011);
387 assert!(result.is_err());
388 let err = result.unwrap_err();
389 assert!(err.to_string().contains("LOD out of range"));
390 }
391
392 #[test]
393 fn test_validation_cip_dx_out_of_range() {
394 let result = EopRecord::new(59945.0, 0.1, 0.2, 0.01, 0.001)
395 .unwrap()
396 .with_cip_offsets(1001.0, 0.0);
397 assert!(result.is_err());
398 let err = result.unwrap_err();
399 assert!(err.to_string().contains("CIP dX out of range"));
400 }
401
402 #[test]
403 fn test_validation_cip_dy_out_of_range() {
404 let result = EopRecord::new(59945.0, 0.1, 0.2, 0.01, 0.001)
405 .unwrap()
406 .with_cip_offsets(0.0, -1001.0);
407 assert!(result.is_err());
408 let err = result.unwrap_err();
409 assert!(err.to_string().contains("CIP dY out of range"));
410 }
411}