Skip to main content

celestial_time/sidereal/
lmst.rs

1use super::angle::SiderealAngle;
2use super::gmst::GMST;
3use crate::scales::{TT, UT1};
4use crate::TimeResult;
5use celestial_core::Location;
6
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct LMST {
13    angle: SiderealAngle,
14    location: Location,
15}
16
17impl LMST {
18    pub fn from_ut1_tt_and_location(ut1: &UT1, tt: &TT, location: &Location) -> TimeResult<Self> {
19        let gmst = GMST::from_ut1_and_tt(ut1, tt)?;
20
21        let lmst_rad = gmst.radians() + location.longitude;
22
23        use celestial_core::angle::wrap_0_2pi;
24        let lmst_normalized = wrap_0_2pi(lmst_rad);
25
26        let angle = SiderealAngle::from_radians_exact(lmst_normalized);
27
28        Ok(Self {
29            angle,
30            location: *location,
31        })
32    }
33
34    pub fn from_hours(hours: f64, location: &Location) -> Self {
35        Self {
36            angle: SiderealAngle::from_hours(hours),
37            location: *location,
38        }
39    }
40
41    pub fn from_degrees(degrees: f64, location: &Location) -> Self {
42        Self {
43            angle: SiderealAngle::from_degrees(degrees),
44            location: *location,
45        }
46    }
47
48    pub fn from_radians(radians: f64, location: &Location) -> Self {
49        Self {
50            angle: SiderealAngle::from_radians(radians),
51            location: *location,
52        }
53    }
54
55    pub fn j2000(location: &Location) -> TimeResult<Self> {
56        let ut1 = UT1::j2000();
57        let tt = TT::j2000();
58        Self::from_ut1_tt_and_location(&ut1, &tt, location)
59    }
60
61    pub fn angle(&self) -> SiderealAngle {
62        self.angle
63    }
64
65    pub fn location(&self) -> Location {
66        self.location
67    }
68
69    pub fn hours(&self) -> f64 {
70        self.angle.hours()
71    }
72
73    pub fn degrees(&self) -> f64 {
74        self.angle.degrees()
75    }
76
77    pub fn radians(&self) -> f64 {
78        self.angle.radians()
79    }
80
81    pub fn hour_angle_to_target(&self, target_ra_hours: f64) -> f64 {
82        self.angle.hour_angle_to_target(target_ra_hours)
83    }
84
85    pub fn to_gmst(&self) -> GMST {
86        let longitude_hours = self.location.longitude * 12.0 / celestial_core::constants::PI;
87
88        let gmst_hours = self.hours() - longitude_hours;
89
90        GMST::from_hours(gmst_hours)
91    }
92}
93
94impl std::fmt::Display for LMST {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        let lat_deg = self.location.latitude.to_degrees();
97        let lon_deg = self.location.longitude.to_degrees();
98        write!(
99            f,
100            "LMST {} at ({:.4}°, {:.4}°)",
101            self.angle, lat_deg, lon_deg
102        )
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn mauna_kea() -> Location {
111        Location::from_degrees(19.8283, -155.4783, 4145.0).unwrap()
112    }
113
114    fn greenwich() -> Location {
115        Location::greenwich()
116    }
117
118    #[test]
119    fn test_lmst_at_greenwich_equals_gmst() {
120        // At Greenwich (0° longitude), LMST should equal GMST
121        let ut1 = UT1::j2000();
122        let tt = TT::j2000();
123        let location = greenwich();
124
125        let gmst = GMST::from_ut1_and_tt(&ut1, &tt).unwrap();
126        let lmst = LMST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
127
128        // Should be identical (within numerical precision)
129        assert!(
130            (lmst.hours() - gmst.hours()).abs() < 1e-14,
131            "LMST at Greenwich should equal GMST: LMST={}, GMST={}",
132            lmst.hours(),
133            gmst.hours()
134        );
135    }
136
137    #[test]
138    fn test_lmst_longitude_correction() {
139        // Test longitude correction: 1 degree = 4 minutes = 1/15 hour
140        let ut1 = UT1::j2000();
141        let tt = TT::j2000();
142
143        let greenwich_loc = greenwich();
144        let east_15deg = Location::from_degrees(0.0, 15.0, 0.0).unwrap(); // 15°E = +1 hour
145        let west_15deg = Location::from_degrees(0.0, -15.0, 0.0).unwrap(); // 15°W = -1 hour
146
147        let lmst_greenwich = LMST::from_ut1_tt_and_location(&ut1, &tt, &greenwich_loc).unwrap();
148        let lmst_east = LMST::from_ut1_tt_and_location(&ut1, &tt, &east_15deg).unwrap();
149        let lmst_west = LMST::from_ut1_tt_and_location(&ut1, &tt, &west_15deg).unwrap();
150
151        // 15°E should be +1 hour ahead of Greenwich
152        let diff_east = lmst_east.hours() - lmst_greenwich.hours();
153        assert!(
154            (diff_east - 1.0).abs() < 1e-12,
155            "15°E should be +1 hour: {}",
156            diff_east
157        );
158
159        // 15°W should be -1 hour behind Greenwich
160        let diff_west = lmst_west.hours() - lmst_greenwich.hours();
161        assert!(
162            (diff_west + 1.0).abs() < 1e-12,
163            "15°W should be -1 hour: {}",
164            diff_west
165        );
166    }
167
168    #[test]
169    fn test_lmst_mauna_kea() {
170        // Mauna Kea is at -155.4783° = -10.365 hours west of Greenwich
171        let ut1 = UT1::j2000();
172        let tt = TT::j2000();
173        let location = mauna_kea();
174
175        let gmst = GMST::from_ut1_and_tt(&ut1, &tt).unwrap();
176        let lmst = LMST::from_ut1_tt_and_location(&ut1, &tt, &location).unwrap();
177
178        // Expected longitude correction in hours
179        let expected_offset = -155.4783 / 15.0; // degrees to hours
180        let actual_offset = lmst.hours() - gmst.hours();
181
182        assert!(
183            (actual_offset - expected_offset).abs() < 1e-10,
184            "Mauna Kea LMST offset incorrect: expected={}, actual={}",
185            expected_offset,
186            actual_offset
187        );
188    }
189
190    #[test]
191    fn test_lmst_j2000() {
192        let location = mauna_kea();
193        let lmst = LMST::j2000(&location).unwrap();
194
195        // LMST should be in valid range
196        let hours = lmst.hours();
197        assert!(
198            (0.0..24.0).contains(&hours),
199            "LMST should be in [0, 24) hours: {}",
200            hours
201        );
202    }
203
204    #[test]
205    fn test_lmst_hour_angle_calculation() {
206        let location = mauna_kea();
207        let lmst = LMST::from_hours(12.0, &location);
208        let target_ra = 6.0;
209        let hour_angle = lmst.hour_angle_to_target(target_ra);
210        assert_eq!(hour_angle, 6.0);
211    }
212
213    #[test]
214    fn test_lmst_to_gmst_roundtrip() {
215        let location = mauna_kea();
216        let original_gmst = GMST::from_hours(15.5);
217
218        // Convert GMST -> LMST -> GMST
219        let longitude_hours = location.longitude * 12.0 / celestial_core::constants::PI;
220        let lmst_hours = original_gmst.hours() + longitude_hours;
221        let lmst = LMST::from_hours(lmst_hours, &location);
222        let recovered_gmst = lmst.to_gmst();
223
224        assert!(
225            (recovered_gmst.hours() - original_gmst.hours()).abs() < 1e-14,
226            "GMST->LMST->GMST roundtrip failed: original={}, recovered={}",
227            original_gmst.hours(),
228            recovered_gmst.hours()
229        );
230    }
231
232    #[test]
233    fn test_lmst_from_constructors() {
234        let location = mauna_kea();
235
236        // Test all constructor methods produce equivalent results
237        let hours = 14.5;
238        let degrees = hours * 15.0;
239        let radians = hours * celestial_core::constants::PI / 12.0;
240
241        let lmst_hours = LMST::from_hours(hours, &location);
242        let lmst_degrees = LMST::from_degrees(degrees, &location);
243        let lmst_radians = LMST::from_radians(radians, &location);
244
245        assert!((lmst_hours.hours() - lmst_degrees.hours()).abs() < 1e-14);
246        assert!((lmst_hours.hours() - lmst_radians.hours()).abs() < 1e-14);
247        assert_eq!(lmst_hours.location(), location);
248        assert_eq!(lmst_degrees.location(), location);
249        assert_eq!(lmst_radians.location(), location);
250    }
251
252    #[test]
253    fn test_lmst_display() {
254        let location = mauna_kea();
255        let lmst = LMST::from_hours(12.5, &location);
256        let display = format!("{}", lmst);
257
258        // Should include LMST time and location coordinates
259        assert!(display.contains("LMST"));
260        assert!(display.contains("19.8283")); // Latitude
261        assert!(display.contains("-155.4783")); // Longitude
262    }
263
264    #[test]
265    fn test_lmst_location_enforcement() {
266        // Test that LMST enforces location through type system
267        let location = mauna_kea();
268        let lmst = LMST::from_hours(12.0, &location);
269
270        // Location is always available and cannot be None/invalid
271        let stored_location = lmst.location();
272        assert_eq!(stored_location.latitude, location.latitude);
273        assert_eq!(stored_location.longitude, location.longitude);
274        assert_eq!(stored_location.height, location.height);
275    }
276
277    #[test]
278    fn test_extreme_longitudes() {
279        let ut1 = UT1::j2000();
280        let tt = TT::j2000();
281
282        // Test extreme valid longitudes
283        let east_extreme = Location::from_degrees(0.0, 180.0, 0.0).unwrap(); // 180°E = +12 hours
284        let west_extreme = Location::from_degrees(0.0, -180.0, 0.0).unwrap(); // 180°W = -12 hours
285
286        let gmst = GMST::from_ut1_and_tt(&ut1, &tt).unwrap();
287        let lmst_east = LMST::from_ut1_tt_and_location(&ut1, &tt, &east_extreme).unwrap();
288        let lmst_west = LMST::from_ut1_tt_and_location(&ut1, &tt, &west_extreme).unwrap();
289
290        // 180°E should be +12 hours ahead
291        let diff_east = lmst_east.hours() - gmst.hours();
292        let expected_east = if diff_east < 0.0 {
293            diff_east + 24.0
294        } else {
295            diff_east
296        };
297        assert!(
298            (expected_east - 12.0).abs() < 1e-12,
299            "180°E should be +12 hours"
300        );
301
302        // 180°W should be -12 hours behind (equivalent to +12 hours due to 24h wrap)
303        let diff_west = lmst_west.hours() - gmst.hours();
304        let expected_west = if diff_west > 12.0 {
305            diff_west - 24.0
306        } else {
307            diff_west
308        };
309        assert!(
310            (expected_west + 12.0).abs() < 1e-12,
311            "180°W should be -12 hours"
312        );
313    }
314
315    #[test]
316    fn test_lmst_constructors_and_accessors() {
317        let location = mauna_kea();
318
319        // Test from_degrees constructor
320        let lmst_deg = LMST::from_degrees(90.0, &location);
321        assert_eq!(lmst_deg.degrees(), 90.0);
322        assert_eq!(lmst_deg.hours(), 6.0);
323
324        // Test from_radians constructor
325        let lmst_rad = LMST::from_radians(celestial_core::constants::PI * 1.5, &location);
326        assert!((lmst_rad.radians() - celestial_core::constants::PI * 1.5).abs() < 1e-15);
327        assert_eq!(lmst_rad.hours(), 18.0);
328
329        // Test angle() accessor
330        let angle = lmst_deg.angle();
331        assert_eq!(angle.degrees(), 90.0);
332
333        // Test location() accessor
334        let stored_location = lmst_deg.location();
335        assert_eq!(stored_location.latitude, location.latitude);
336        assert_eq!(stored_location.longitude, location.longitude);
337        assert_eq!(stored_location.height, location.height);
338
339        // Test degrees() method
340        let degrees = lmst_deg.degrees();
341        assert_eq!(degrees, 90.0);
342    }
343}