1use core::f64::consts::PI;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Dms {
19 pub sign: i8,
20 pub degrees: u32,
21 pub minutes: u32,
22 pub seconds: f64,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Hms {
28 pub hours: u32,
29 pub minutes: u32,
30 pub seconds: f64,
31}
32
33#[must_use]
37pub fn normalize_degrees(angle: f64) -> f64 {
38 if !angle.is_finite() {
39 return 0.0;
40 }
41 let result = angle % 360.0;
42 let result = if result < 0.0 { result + 360.0 } else { result };
43 if result >= 360.0 { 0.0 } else { result }
45}
46
47#[must_use]
51pub fn normalize_degrees_signed(angle: f64) -> f64 {
52 if !angle.is_finite() {
53 return 0.0;
54 }
55 let mut result = normalize_degrees(angle);
56 if result >= 180.0 {
57 result -= 360.0;
58 }
59 result
60}
61
62#[must_use]
66pub fn normalize_radians(angle: f64) -> f64 {
67 if !angle.is_finite() {
68 return 0.0;
69 }
70 let two_pi = 2.0 * PI;
71 let result = angle % two_pi;
72 let result = if result < 0.0 {
73 result + two_pi
74 } else {
75 result
76 };
77 if result >= two_pi { 0.0 } else { result }
79}
80
81#[must_use]
83pub fn deg_to_rad(deg: f64) -> f64 {
84 deg * (PI / 180.0)
85}
86
87#[must_use]
89pub fn rad_to_deg(rad: f64) -> f64 {
90 rad * (180.0 / PI)
91}
92
93#[must_use]
95#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
96pub fn deg_to_dms(decimal_degrees: f64) -> Dms {
97 let sign = if decimal_degrees < 0.0 { -1i8 } else { 1i8 };
98 let abs_deg = decimal_degrees.abs();
99 let mut degrees = abs_deg as u32;
100 let remainder = (abs_deg - f64::from(degrees)) * 60.0;
101 let mut minutes = remainder as u32;
102 let mut seconds = (remainder - f64::from(minutes)) * 60.0;
103 if seconds >= 60.0 {
105 seconds -= 60.0;
106 minutes += 1;
107 }
108 if minutes >= 60 {
109 minutes -= 60;
110 degrees += 1;
111 }
112 Dms {
113 sign,
114 degrees,
115 minutes,
116 seconds,
117 }
118}
119
120#[must_use]
122pub fn dms_to_deg(dms: &Dms) -> f64 {
123 let abs_val = f64::from(dms.degrees) + f64::from(dms.minutes) / 60.0 + dms.seconds / 3600.0;
124 f64::from(dms.sign) * abs_val
125}
126
127#[must_use]
131#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
132pub fn deg_to_hms(decimal_degrees: f64) -> Hms {
133 let normalized = normalize_degrees(decimal_degrees);
134 let total_hours = normalized / 15.0;
135 let mut hours = total_hours as u32;
136 let remainder = (total_hours - f64::from(hours)) * 60.0;
137 let mut minutes = remainder as u32;
138 let mut seconds = (remainder - f64::from(minutes)) * 60.0;
139 if seconds >= 60.0 {
141 seconds -= 60.0;
142 minutes += 1;
143 }
144 if minutes >= 60 {
145 minutes -= 60;
146 hours += 1;
147 }
148 Hms {
149 hours,
150 minutes,
151 seconds,
152 }
153}
154
155#[must_use]
157pub fn hms_to_deg(hms: &Hms) -> f64 {
158 (f64::from(hms.hours) + f64::from(hms.minutes) / 60.0 + hms.seconds / 3600.0) * 15.0
159}
160
161#[must_use]
165pub fn angular_separation(a: f64, b: f64) -> f64 {
166 let diff = normalize_degrees(a - b);
167 if diff > 180.0 { 360.0 - diff } else { diff }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 const EPS: f64 = 1e-12;
175
176 #[test]
179 fn normalize_degrees_zero() {
180 assert!((normalize_degrees(0.0) - 0.0).abs() < EPS);
181 }
182
183 #[test]
184 fn normalize_degrees_positive() {
185 assert!((normalize_degrees(45.0) - 45.0).abs() < EPS);
186 }
187
188 #[test]
189 fn normalize_degrees_360_becomes_0() {
190 assert!((normalize_degrees(360.0) - 0.0).abs() < EPS);
191 }
192
193 #[test]
194 fn normalize_degrees_negative() {
195 assert!((normalize_degrees(-90.0) - 270.0).abs() < EPS);
196 }
197
198 #[test]
199 fn normalize_degrees_large_positive() {
200 assert!((normalize_degrees(720.0) - 0.0).abs() < EPS);
201 }
202
203 #[test]
204 fn normalize_degrees_large_negative() {
205 assert!((normalize_degrees(-450.0) - 270.0).abs() < EPS);
206 }
207
208 #[test]
209 fn normalize_degrees_near_360() {
210 assert!((normalize_degrees(359.9999) - 359.9999).abs() < EPS);
211 }
212
213 #[test]
214 fn normalize_degrees_nan() {
215 assert!((normalize_degrees(f64::NAN) - 0.0).abs() < EPS);
216 }
217
218 #[test]
219 fn normalize_degrees_inf() {
220 assert!((normalize_degrees(f64::INFINITY) - 0.0).abs() < EPS);
221 }
222
223 #[test]
226 fn normalize_degrees_signed_positive() {
227 assert!((normalize_degrees_signed(90.0) - 90.0).abs() < EPS);
228 }
229
230 #[test]
231 fn normalize_degrees_signed_negative() {
232 assert!((normalize_degrees_signed(-45.0) - (-45.0)).abs() < EPS);
233 }
234
235 #[test]
236 fn normalize_degrees_signed_270_becomes_neg90() {
237 assert!((normalize_degrees_signed(270.0) - (-90.0)).abs() < EPS);
238 }
239
240 #[test]
241 fn normalize_degrees_signed_180() {
242 assert!((normalize_degrees_signed(180.0) - (-180.0)).abs() < EPS);
244 }
245
246 #[test]
249 fn normalize_radians_zero() {
250 assert!((normalize_radians(0.0) - 0.0).abs() < EPS);
251 }
252
253 #[test]
254 fn normalize_radians_pi() {
255 assert!((normalize_radians(PI) - PI).abs() < EPS);
256 }
257
258 #[test]
259 fn normalize_radians_two_pi() {
260 assert!((normalize_radians(2.0 * PI) - 0.0).abs() < EPS);
261 }
262
263 #[test]
264 fn normalize_radians_negative() {
265 assert!((normalize_radians(-PI) - PI).abs() < EPS);
266 }
267
268 #[test]
271 fn deg_rad_roundtrip() {
272 let angle = 123.456_f64;
273 assert!((rad_to_deg(deg_to_rad(angle)) - angle).abs() < EPS);
274 }
275
276 #[test]
277 fn deg_rad_90_is_half_pi() {
278 assert!((deg_to_rad(90.0) - PI / 2.0).abs() < EPS);
279 }
280
281 #[test]
284 fn dms_positive() {
285 let dms = deg_to_dms(45.5);
286 assert_eq!(dms.sign, 1);
287 assert_eq!(dms.degrees, 45);
288 assert_eq!(dms.minutes, 30);
289 assert!((dms.seconds - 0.0).abs() < 0.01);
290 }
291
292 #[test]
293 fn dms_negative() {
294 let dms = deg_to_dms(-10.25);
295 assert_eq!(dms.sign, -1);
296 assert_eq!(dms.degrees, 10);
297 assert_eq!(dms.minutes, 15);
298 assert!((dms.seconds - 0.0).abs() < 0.01);
299 }
300
301 #[test]
302 fn dms_roundtrip() {
303 let original = 37.123_456_f64;
304 let dms = deg_to_dms(original);
305 let back = dms_to_deg(&dms);
306 assert!((back - original).abs() < 1e-10);
307 }
308
309 #[test]
310 fn dms_zero() {
311 let dms = deg_to_dms(0.0);
312 assert_eq!(dms.sign, 1);
313 assert_eq!(dms.degrees, 0);
314 assert_eq!(dms.minutes, 0);
315 assert!((dms.seconds - 0.0).abs() < 0.01);
316 }
317
318 #[test]
321 fn hms_zero() {
322 let hms = deg_to_hms(0.0);
323 assert_eq!(hms.hours, 0);
324 assert_eq!(hms.minutes, 0);
325 assert!((hms.seconds - 0.0).abs() < 0.01);
326 }
327
328 #[test]
329 fn hms_90deg() {
330 let hms = deg_to_hms(90.0);
331 assert_eq!(hms.hours, 6);
332 assert_eq!(hms.minutes, 0);
333 assert!((hms.seconds - 0.0).abs() < 0.01);
334 }
335
336 #[test]
337 fn hms_roundtrip() {
338 let original = 135.0_f64;
339 let hms = deg_to_hms(original);
340 let back = hms_to_deg(&hms);
341 assert!((back - original).abs() < 1e-10);
342 }
343
344 #[test]
347 fn separation_same() {
348 assert!((angular_separation(45.0, 45.0) - 0.0).abs() < EPS);
349 }
350
351 #[test]
352 fn separation_opposite() {
353 assert!((angular_separation(0.0, 180.0) - 180.0).abs() < EPS);
354 }
355
356 #[test]
357 fn separation_wrap_around() {
358 assert!((angular_separation(10.0, 350.0) - 20.0).abs() < EPS);
360 }
361
362 #[test]
363 fn separation_negative_input() {
364 assert!((angular_separation(-10.0, 10.0) - 20.0).abs() < EPS);
366 }
367}