Skip to main content

celestial_time/sidereal/
last.rs

1use super::angle::SiderealAngle;
2use super::gast::GAST;
3use super::gmst::GMST;
4use super::lmst::LMST;
5use crate::scales::{TT, UT1};
6use crate::transforms::nutation::NutationCalculator;
7use crate::TimeResult;
8use celestial_core::angle::wrap_0_2pi;
9use celestial_core::Location;
10
11#[cfg(feature = "serde")]
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16pub struct LAST {
17    angle: SiderealAngle,
18    location: Location,
19}
20
21impl LAST {
22    pub fn from_ut1_tt_and_location(ut1: &UT1, tt: &TT, location: &Location) -> TimeResult<Self> {
23        let gast = GAST::from_ut1_and_tt(ut1, tt)?;
24
25        let last_rad = gast.radians() + location.longitude;
26
27        let last_normalized = wrap_0_2pi(last_rad);
28
29        let angle = SiderealAngle::from_radians_exact(last_normalized);
30
31        Ok(Self {
32            angle,
33            location: *location,
34        })
35    }
36
37    pub fn from_lmst_and_equation_of_equinoxes(
38        ut1: &UT1,
39        tt: &TT,
40        location: &Location,
41    ) -> TimeResult<Self> {
42        Self::from_ut1_tt_and_location(ut1, tt, location)
43    }
44
45    pub fn from_hours(hours: f64, location: &Location) -> Self {
46        Self {
47            angle: SiderealAngle::from_hours(hours),
48            location: *location,
49        }
50    }
51
52    pub fn from_degrees(degrees: f64, location: &Location) -> Self {
53        Self {
54            angle: SiderealAngle::from_degrees(degrees),
55            location: *location,
56        }
57    }
58
59    pub fn from_radians(radians: f64, location: &Location) -> Self {
60        Self {
61            angle: SiderealAngle::from_radians(radians),
62            location: *location,
63        }
64    }
65
66    pub fn j2000(location: &Location) -> TimeResult<Self> {
67        let ut1 = UT1::j2000();
68        let tt = TT::j2000();
69        Self::from_ut1_tt_and_location(&ut1, &tt, location)
70    }
71
72    pub fn angle(&self) -> SiderealAngle {
73        self.angle
74    }
75
76    pub fn location(&self) -> Location {
77        self.location
78    }
79
80    pub fn hours(&self) -> f64 {
81        self.angle.hours()
82    }
83
84    pub fn degrees(&self) -> f64 {
85        self.angle.degrees()
86    }
87
88    pub fn radians(&self) -> f64 {
89        self.angle.radians()
90    }
91
92    pub fn hour_angle_to_target(&self, target_ra_hours: f64) -> f64 {
93        self.angle.hour_angle_to_target(target_ra_hours)
94    }
95
96    pub fn to_gast(&self) -> GAST {
97        let longitude_hours = self.location.longitude * 12.0 / celestial_core::constants::PI;
98
99        let gast_hours = self.hours() - longitude_hours;
100
101        GAST::from_hours(gast_hours)
102    }
103
104    pub fn to_lmst(&self, tt: &TT) -> TimeResult<LMST> {
105        let nutation = tt.nutation_iau2006a()?;
106
107        let jd = tt.to_julian_date();
108        let mean_obliquity = celestial_core::obliquity::iau_2006_mean_obliquity(jd.jd1(), jd.jd2());
109
110        let ee_rad = nutation.nutation_longitude() * mean_obliquity.cos();
111        let ee_hours = ee_rad * 12.0 / celestial_core::constants::PI;
112
113        let lmst_hours = self.hours() - ee_hours;
114
115        Ok(LMST::from_hours(lmst_hours, &self.location))
116    }
117
118    pub fn to_gmst(&self, tt: &TT) -> TimeResult<GMST> {
119        let lmst = self.to_lmst(tt)?;
120
121        Ok(lmst.to_gmst())
122    }
123}
124
125impl std::fmt::Display for LAST {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        let lat_deg = self.location.latitude.to_degrees();
128        let lon_deg = self.location.longitude.to_degrees();
129        write!(
130            f,
131            "LAST {} at ({:.4}°, {:.4}°)",
132            self.angle, lat_deg, lon_deg
133        )
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn mauna_kea() -> Location {
142        Location::from_degrees(19.8283, -155.4783, 4145.0).unwrap()
143    }
144
145    fn greenwich() -> Location {
146        Location::greenwich()
147    }
148
149    #[test]
150    fn test_last_at_greenwich_equals_gast() {
151        // At Greenwich (0° longitude), LAST should equal GAST
152        let ut1 = UT1::j2000();
153        let tt = TT::j2000();
154        let location = greenwich();
155
156        let gast = GAST::from_ut1_and_tt(&ut1, &tt).unwrap();
157        let last = LAST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
158
159        // Should be identical (within numerical precision)
160        assert!(
161            (last.hours() - gast.hours()).abs() < 1e-14,
162            "LAST at Greenwich should equal GAST: LAST={}, GAST={}",
163            last.hours(),
164            gast.hours()
165        );
166    }
167
168    #[test]
169    fn test_last_method_consistency() {
170        // Both calculation methods should give identical results
171        let ut1 = UT1::j2000();
172        let tt = TT::j2000();
173        let location = mauna_kea();
174
175        let last_method1 = LAST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
176        let last_method2 = LAST::from_lmst_and_equation_of_equinoxes(&ut1, &tt, &location).unwrap();
177
178        // Both methods should produce identical results within numerical precision
179        assert!(
180            (last_method1.hours() - last_method2.hours()).abs() < 1e-14,
181            "LAST calculation methods should match: method1={}, method2={}",
182            last_method1.hours(),
183            last_method2.hours()
184        );
185    }
186
187    #[test]
188    fn test_last_longitude_correction() {
189        // Test longitude correction: 1 degree = 4 minutes = 1/15 hour
190        let ut1 = UT1::j2000();
191        let tt = TT::j2000();
192
193        let greenwich_loc = greenwich();
194        let east_15deg = Location::from_degrees(0.0, 15.0, 0.0).unwrap(); // 15°E = +1 hour
195        let west_15deg = Location::from_degrees(0.0, -15.0, 0.0).unwrap(); // 15°W = -1 hour
196
197        let last_greenwich = LAST::from_ut1_tt_and_location(&ut1, &tt, &greenwich_loc).unwrap();
198        let last_east = LAST::from_ut1_tt_and_location(&ut1, &tt, &east_15deg).unwrap();
199        let last_west = LAST::from_ut1_tt_and_location(&ut1, &tt, &west_15deg).unwrap();
200
201        // 15°E should be +1 hour ahead of Greenwich
202        let diff_east = last_east.hours() - last_greenwich.hours();
203        assert!(
204            (diff_east - 1.0).abs() < 1e-12,
205            "15°E should be +1 hour: {}",
206            diff_east
207        );
208
209        // 15°W should be -1 hour behind Greenwich
210        let diff_west = last_west.hours() - last_greenwich.hours();
211        assert!(
212            (diff_west + 1.0).abs() < 1e-12,
213            "15°W should be -1 hour: {}",
214            diff_west
215        );
216    }
217
218    #[test]
219    fn test_last_vs_gast_longitude() {
220        // LAST = GAST + longitude correction
221        let ut1 = UT1::j2000();
222        let tt = TT::j2000();
223        let location = mauna_kea();
224
225        let last = LAST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
226        let gast = GAST::from_ut1_and_tt(&ut1, &tt).unwrap();
227
228        // Calculate longitude correction manually
229        let longitude_hours = location.longitude * 12.0 / celestial_core::constants::PI;
230
231        // LAST should equal GAST + longitude correction
232        let expected_last = gast.hours() + longitude_hours;
233        let expected_last_normalized = ((expected_last % 24.0) + 24.0) % 24.0;
234
235        assert!(
236            (last.hours() - expected_last_normalized).abs() < 1e-14,
237            "LAST = GAST + longitude: LAST={}, GAST={}, longitude={}, expected={}",
238            last.hours(),
239            gast.hours(),
240            longitude_hours,
241            expected_last_normalized
242        );
243    }
244
245    #[test]
246    fn test_last_mauna_kea() {
247        // Mauna Kea is at -155.4783° = -10.365 hours west of Greenwich
248        let ut1 = UT1::j2000();
249        let tt = TT::j2000();
250        let location = mauna_kea();
251
252        let gast = GAST::from_ut1_and_tt(&ut1, &tt).unwrap();
253        let last = LAST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
254
255        // Expected longitude correction in hours
256        let expected_offset = -155.4783 / 15.0; // degrees to hours
257        let actual_offset = last.hours() - gast.hours();
258
259        assert!(
260            (actual_offset - expected_offset).abs() < 1e-10,
261            "Mauna Kea LAST offset incorrect: expected={}, actual={}",
262            expected_offset,
263            actual_offset
264        );
265    }
266
267    #[test]
268    fn test_last_j2000() {
269        let location = mauna_kea();
270        let last = LAST::j2000(&location).unwrap();
271
272        // LAST should be in valid range
273        let hours = last.hours();
274        assert!(
275            (0.0..24.0).contains(&hours),
276            "LAST should be in [0, 24) hours: {}",
277            hours
278        );
279    }
280
281    #[test]
282    fn test_last_hour_angle_calculation() {
283        let location = mauna_kea();
284        let last = LAST::from_hours(12.0, &location);
285        let target_ra = 6.0;
286        let hour_angle = last.hour_angle_to_target(target_ra);
287        assert_eq!(hour_angle, 6.0);
288    }
289
290    #[test]
291    fn test_last_to_gast_roundtrip() {
292        let location = mauna_kea();
293        let original_gast = GAST::from_hours(15.5);
294
295        // Convert GAST -> LAST -> GAST
296        let longitude_hours = location.longitude * 12.0 / celestial_core::constants::PI;
297        let last_hours = original_gast.hours() + longitude_hours;
298        let last = LAST::from_hours(last_hours, &location);
299        let recovered_gast = last.to_gast();
300
301        assert!(
302            (recovered_gast.hours() - original_gast.hours()).abs() < 1e-14,
303            "GAST->LAST->GAST roundtrip failed: original={}, recovered={}",
304            original_gast.hours(),
305            recovered_gast.hours()
306        );
307    }
308
309    #[test]
310    fn test_last_to_lmst_conversion() {
311        let ut1 = UT1::j2000();
312        let tt = TT::j2000();
313        let location = mauna_kea();
314
315        let original_lmst = LMST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
316
317        // Convert LMST -> LAST -> LMST
318        let last = LAST::from_lmst_and_equation_of_equinoxes(&ut1, &tt, &location).unwrap();
319        let recovered_lmst = last.to_lmst(&tt).unwrap();
320
321        // Note: Small precision difference expected due to CIO-based LAST vs classical LMST
322        // Roundtrip precision limited by algorithm difference
323        assert!(
324            (recovered_lmst.hours() - original_lmst.hours()).abs() < 1e-7,
325            "LMST->LAST->LMST roundtrip failed: original={}, recovered={}",
326            original_lmst.hours(),
327            recovered_lmst.hours()
328        );
329    }
330
331    #[test]
332    fn test_last_from_constructors() {
333        let location = mauna_kea();
334
335        // Test all constructor methods produce equivalent results
336        let hours = 14.5;
337        let degrees = hours * 15.0;
338        let radians = hours * celestial_core::constants::PI / 12.0;
339
340        let last_hours = LAST::from_hours(hours, &location);
341        let last_degrees = LAST::from_degrees(degrees, &location);
342        let last_radians = LAST::from_radians(radians, &location);
343
344        assert!((last_hours.hours() - last_degrees.hours()).abs() < 1e-14);
345        assert!((last_hours.hours() - last_radians.hours()).abs() < 1e-14);
346        assert_eq!(last_hours.location(), location);
347        assert_eq!(last_degrees.location(), location);
348        assert_eq!(last_radians.location(), location);
349    }
350
351    #[test]
352    fn test_last_display() {
353        let location = mauna_kea();
354        let last = LAST::from_hours(12.5, &location);
355        let display = format!("{}", last);
356
357        // Should include LAST time and location coordinates
358        assert!(display.contains("LAST"));
359        assert!(display.contains("19.8283")); // Latitude
360        assert!(display.contains("-155.4783")); // Longitude
361    }
362
363    #[test]
364    fn test_last_location_enforcement() {
365        // Test that LAST enforces location through type system
366        let location = mauna_kea();
367        let last = LAST::from_hours(12.0, &location);
368
369        // Location is always available and cannot be None/invalid
370        let stored_location = last.location();
371        assert_eq!(stored_location.latitude, location.latitude);
372        assert_eq!(stored_location.longitude, location.longitude);
373        assert_eq!(stored_location.height, location.height);
374    }
375
376    #[test]
377    fn test_extreme_longitudes() {
378        let ut1 = UT1::j2000();
379        let tt = TT::j2000();
380
381        // Test extreme valid longitudes
382        let east_extreme = Location::from_degrees(0.0, 180.0, 0.0).unwrap(); // 180°E = +12 hours
383        let west_extreme = Location::from_degrees(0.0, -180.0, 0.0).unwrap(); // 180°W = -12 hours
384
385        let gast = GAST::from_ut1_and_tt(&ut1, &tt).unwrap();
386        let last_east = LAST::from_ut1_tt_and_location(&ut1, &tt, &east_extreme).unwrap();
387        let last_west = LAST::from_ut1_tt_and_location(&ut1, &tt, &west_extreme).unwrap();
388
389        // 180°E should be +12 hours ahead
390        let diff_east = last_east.hours() - gast.hours();
391        let expected_east = if diff_east < 0.0 {
392            diff_east + 24.0
393        } else {
394            diff_east
395        };
396        assert!(
397            (expected_east - 12.0).abs() < 1e-12,
398            "180°E should be +12 hours"
399        );
400
401        // 180°W should be -12 hours behind (equivalent to +12 hours due to 24h wrap)
402        let diff_west = last_west.hours() - gast.hours();
403        let expected_west = if diff_west > 12.0 {
404            diff_west - 24.0
405        } else {
406            diff_west
407        };
408        assert!(
409            (expected_west + 12.0).abs() < 1e-12,
410            "180°W should be -12 hours"
411        );
412    }
413
414    #[test]
415    fn test_last_equation_of_equinoxes_range() {
416        // Test that equation of equinoxes is reasonable (should be small)
417        let ut1 = UT1::j2000();
418        let tt = TT::j2000();
419        let location = mauna_kea();
420
421        let last = LAST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
422        let lmst = LMST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
423
424        // Equation of equinoxes should be small (typically < 1 second = 1/3600 hours)
425        let ee_hours = last.hours() - lmst.hours();
426        let ee_hours_normalized = if ee_hours > 12.0 {
427            ee_hours - 24.0
428        } else if ee_hours < -12.0 {
429            ee_hours + 24.0
430        } else {
431            ee_hours
432        };
433
434        assert!(
435            ee_hours_normalized.abs() < 0.001, // Less than 3.6 seconds
436            "Equation of equinoxes too large: {} hours = {} seconds",
437            ee_hours_normalized,
438            ee_hours_normalized * 3600.0
439        );
440    }
441
442    #[test]
443    fn test_last_constructors_and_accessors() {
444        let location = greenwich();
445
446        // Test from_degrees constructor
447        let last_deg = LAST::from_degrees(45.0, &location);
448        assert_eq!(last_deg.degrees(), 45.0);
449        assert_eq!(last_deg.hours(), 3.0);
450
451        // Test from_radians constructor
452        let last_rad = LAST::from_radians(celestial_core::constants::PI / 4.0, &location);
453        assert!((last_rad.radians() - celestial_core::constants::PI / 4.0).abs() < 1e-15);
454        assert_eq!(last_rad.hours(), 3.0);
455
456        // Test angle() accessor
457        let angle = last_deg.angle();
458        assert_eq!(angle.degrees(), 45.0);
459
460        // Test location() accessor
461        let stored_location = last_deg.location();
462        assert_eq!(stored_location.latitude, location.latitude);
463        assert_eq!(stored_location.longitude, location.longitude);
464        assert_eq!(stored_location.height, location.height);
465
466        // Test degrees() method
467        let degrees = last_deg.degrees();
468        assert_eq!(degrees, 45.0);
469    }
470}