astro_math/location.rs
1//! Geographic location representation and utilities.
2//!
3//! This module provides the [`Location`] struct for representing observer
4//! positions on Earth, with flexible coordinate parsing for various formats.
5//!
6//! # Supported Coordinate Formats
7//!
8//! The parsing system handles an extensive range of formats:
9//!
10//! ## Decimal Degrees
11//! - `"40.7128"` or `"-74.0060"`
12//! - `"40.7128N"` or `"74.0060W"`
13//! - `"N40.7128"` or `"W74.0060"`
14//!
15//! ## DMS (Degrees Minutes Seconds)
16//! - `"40 42 46"` or `"40° 42' 46\""`
17//! - `"40d42m46s"` or `"40deg42min46sec"`
18//! - `"40:42:46"` or `"40-42-46"`
19//! - `"40°42'46.08\"N"` (with decimals and direction)
20//!
21//! ## HMS (Hours Minutes Seconds) for longitude
22//! - `"4h 56m 27s"` or `"4:56:27"`
23//! - `"4h56m27s"` or `"4 hours 56 minutes 27 seconds"`
24//!
25//! ## Special handling
26//! - Unicode symbols: `"40°42′46″"` (with proper Unicode prime symbols)
27//! - Mixed formats: `"40° 42.767'"` (degrees and decimal minutes)
28//! - Fuzzy matching: handles typos, extra spaces, mixed separators
29//! - Case insensitive: `"40D42M46S"` or `"n40.7128"`
30//!
31//! # Error Handling
32//!
33//! Parsing returns `Result<Location>` with detailed error messages:
34//! - `AstroError::InvalidDmsFormat` with suggestions for fixing common issues
35
36use crate::time::julian_date;
37use crate::{local_mean_sidereal_time, sidereal::apparent_sidereal_time};
38use crate::error::{AstroError, Result};
39use chrono::{DateTime, Utc};
40use std::str::FromStr;
41use regex::{Regex, RegexBuilder};
42use lazy_static::lazy_static;
43
44// Pre-compiled regex patterns for performance
45lazy_static! {
46 /// HMS pattern with DoS protection
47 static ref HMS_REGEX: Regex = RegexBuilder::new(
48 r"(\d{1,3}(?:\.\d{1,10})?)\s*h\s*(\d{1,2}(?:\.\d{1,10})?)\s*m?\s*(\d{1,2}(?:\.\d{1,10})?)\s*s?"
49 )
50 .size_limit(1024 * 1024) // 1MB regex size limit
51 .dfa_size_limit(10 * 1024 * 1024) // 10MB DFA size limit
52 .build()
53 .expect("HMS regex compilation failed");
54
55 /// DMS pattern with DoS protection
56 static ref DMS_REGEX: Regex = RegexBuilder::new(
57 r#"([+-]?\d{1,3}(?:\.\d{1,10})?)\s*[°d]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*['′m]?\s*(\d{1,2}(?:\.\d{1,10})?)\s*["″s]?"#
58 )
59 .size_limit(1024 * 1024)
60 .dfa_size_limit(10 * 1024 * 1024)
61 .build()
62 .expect("DMS regex compilation failed");
63
64 /// Decimal degrees pattern with size limits
65 static ref DECIMAL_REGEX: Regex = RegexBuilder::new(
66 r"^[+-]?\d{1,3}(?:\.\d{1,15})?[NSEW]?$"
67 )
68 .case_insensitive(true)
69 .size_limit(1024 * 1024)
70 .build()
71 .expect("Decimal regex compilation failed");
72
73 /// Compact format pattern (DDMM.mmm or DDMMSS) with validation
74 static ref COMPACT_REGEX: Regex = RegexBuilder::new(
75 r"^([+-]?)(\d{2,3})(\d{2})(?:(\d{2})(?:\.(\d{1,6}))?)?$"
76 )
77 .size_limit(1024 * 1024)
78 .build()
79 .expect("Compact regex compilation failed");
80}
81
82/// Represents a physical observer location on Earth.
83///
84/// Used for computing local sidereal time, converting celestial coordinates,
85/// and modeling telescope geometry.
86#[derive(Debug, Clone, Copy)]
87pub struct Location {
88 /// Latitude in degrees (+N, -S)
89 pub latitude_deg: f64,
90 /// Longitude in degrees (+E, -W, Greenwich = 0)
91 pub longitude_deg: f64,
92 /// Altitude above sea level in meters
93 pub altitude_m: f64,
94}
95
96impl Location {
97 /// Parses a location from flexible coordinate strings.
98 ///
99 /// Automatically detects the coordinate format and applies appropriate parsing.
100 ///
101 /// # Supported Formats
102 ///
103 /// ## Decimal degrees
104 /// - `"40.7128"` or `"-74.0060"`
105 /// - `"40.7128N"` or `"74.0060W"`
106 /// - `"N40.7128"` or `"W 74.0060"`
107 /// - `"40.7128 N"` or `"74.0060 West"`
108 /// - `"north 40.7128"` or `"west 74.0060"`
109 ///
110 /// ## DMS (Degrees Minutes Seconds)
111 /// - `"40 42 46"` or `"40° 42' 46\""`
112 /// - `"40d42m46s"` or `"40deg42min46sec"`
113 /// - `"40:42:46"` or `"40-42-46"`
114 /// - `"40°42'46.08\"N"` (with decimals and direction)
115 /// - `"40d 42' 46.08\" N"` (mixed separators)
116 /// - `"40 degrees 42 minutes 46 seconds"`
117 ///
118 /// ## DM (Degrees Decimal Minutes)
119 /// - `"40° 42.767'"` or `"40d 42.767m"`
120 /// - `"40 42.767"` (assumed DM if only 2 parts)
121 ///
122 /// ## HMS (Hours Minutes Seconds) for longitude
123 /// - `"4h 56m 27s"` or `"4:56:27"`
124 /// - `"4h56m27.5s"` or `"4 hours 56 minutes 27.5 seconds"`
125 /// - `"4h 56' 27\""` (using arcminute/arcsecond symbols)
126 ///
127 /// ## Special handling
128 /// - Unicode: `"40°42′46″"` (proper Unicode prime/double-prime)
129 /// - Compact: `"404246N"` or `"0740060W"` (DDMMSS format)
130 /// - Aviation: `"4042.767N"` (DDMM.mmm format)
131 /// - Fuzzy: Handles extra spaces, mixed case, common typos
132 ///
133 /// # Arguments
134 /// - `lat_str`: Latitude string in any supported format
135 /// - `lon_str`: Longitude string in any supported format
136 /// - `alt_m`: Altitude in meters
137 ///
138 /// # Returns
139 /// `Ok(Location)` if parsing succeeds
140 ///
141 /// # Errors
142 /// Returns `Err(AstroError::InvalidDmsFormat)` with helpful error messages
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use astro_math::location::Location;
148 ///
149 /// // Decimal degrees with compass directions
150 /// let loc = Location::parse("40.7128 N", "74.0060 W", 10.0).unwrap();
151 /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-6);
152 /// assert!((loc.longitude_deg + 74.0060).abs() < 1e-6);
153 ///
154 /// // DMS with symbols
155 /// let loc = Location::parse("40°42'46.08\"N", "74°0'21.6\"W", 10.0).unwrap();
156 /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-4);
157 ///
158 /// // HMS for longitude
159 /// let loc = Location::parse("51.5074 N", "0h 7m 39.84s W", 0.0).unwrap();
160 /// assert!((loc.longitude_deg + 1.9166).abs() < 1e-3);
161 ///
162 /// // Mixed formats and fuzzy matching
163 /// let loc = Location::parse("40d 42m 46s North", "74 deg 0 min 21.6 sec west", 10.0).unwrap();
164 /// assert!((loc.latitude_deg - 40.7128).abs() < 1e-4);
165 /// ```
166 pub fn parse(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
167 let lat = parse_coordinate(lat_str, true)?;
168 let lon = parse_coordinate(lon_str, false)?;
169 Ok(Location {
170 latitude_deg: lat,
171 longitude_deg: lon,
172 altitude_m: alt_m,
173 })
174 }
175
176 /// Parses a `Location` from sexagesimal (DMS) strings for latitude and longitude.
177 ///
178 /// Supports a wide range of common DMS formats:
179 /// - `"39 00 01.7"`
180 /// - `"39:00:01.7"`
181 /// - `"39°00'01.7\""`
182 ///
183 /// # Arguments
184 /// - `lat_str`: Latitude string in sexagesimal format
185 /// - `lon_str`: Longitude string in sexagesimal format
186 /// - `alt_m`: Altitude in meters
187 ///
188 /// # Returns
189 /// `Ok(Location)` if parsing succeeds
190 ///
191 /// # Errors
192 /// Returns `Err(AstroError::InvalidDmsFormat)` if:
193 /// - String doesn't match any supported DMS format
194 /// - Degrees, minutes, or seconds are out of valid ranges
195 ///
196 /// # Examples
197 ///
198 /// ## DMS with spaces
199 /// ```
200 /// use astro_math::location::Location;
201 /// let loc = Location::from_dms("+39 00 01.7", "-92 18 03.2", 250.0).unwrap();
202 /// assert!((loc.latitude_deg - 39.0004722).abs() < 1e-6);
203 /// assert!((loc.longitude_deg + 92.3008888).abs() < 1e-6);
204 /// ```
205 ///
206 /// ## DMS with colons
207 /// ```
208 /// use astro_math::location::Location;
209 /// let loc = Location::from_dms("+39:00:01.7", "-92:18:03.2", 250.0).unwrap();
210 /// assert!((loc.latitude_deg - 39.0004722).abs() < 1e-6);
211 /// ```
212 ///
213 /// ## ASCII punctuation
214 /// ```
215 /// use astro_math::location::Location;
216 /// let loc = Location::from_dms("+39°00'01.7\"", "-92°18'03.2\"", 250.0).unwrap();
217 /// assert!((loc.longitude_deg + 92.3008888).abs() < 1e-6);
218 /// ```
219 ///
220 /// ## Invalid input
221 /// ```
222 /// use astro_math::location::Location;
223 /// use astro_math::error::AstroError;
224 ///
225 /// match Location::from_dms("foo", "bar", 100.0) {
226 /// Err(AstroError::InvalidDmsFormat { input, .. }) => {
227 /// assert_eq!(input, "foo");
228 /// }
229 /// _ => panic!("Expected InvalidDmsFormat error"),
230 /// }
231 /// ```
232 pub fn from_dms(lat_str: &str, lon_str: &str, alt_m: f64) -> Result<Self> {
233 let lat = parse_dms(lat_str)?;
234 let lon = parse_dms(lon_str)?;
235 Ok(Location {
236 latitude_deg: lat,
237 longitude_deg: lon,
238 altitude_m: alt_m,
239 })
240 }
241
242 pub fn latitude_dms_string(&self) -> String {
243 format_dms(self.latitude_deg, true)
244 }
245
246 pub fn longitude_dms_string(&self) -> String {
247 format_dms(self.longitude_deg, false)
248 }
249
250 /// Computes the Local Sidereal Time (LST) at this location for a given UTC timestamp.
251 ///
252 /// # Arguments
253 /// - `datetime`: UTC datetime
254 ///
255 /// # Returns
256 /// Local Sidereal Time in fractional hours
257 ///
258 /// # Example
259 /// ```
260 /// use chrono::{Utc, TimeZone};
261 /// use astro_math::location::Location;
262 ///
263 /// let dt = Utc.with_ymd_and_hms(1987, 4, 10, 19, 21, 0).unwrap();
264 /// let loc = Location {
265 /// latitude_deg: 32.0,
266 /// longitude_deg: -64.0,
267 /// altitude_m: 200.0,
268 /// };
269 /// let lst = loc.local_sidereal_time(dt);
270 /// assert!((lst - 4.3157).abs() < 1e-3);
271 /// ```
272 pub fn local_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
273 let jd = julian_date(datetime);
274 apparent_sidereal_time(jd, self.longitude_deg)
275 }
276
277 /// Local Mean Sidreal Time (LMST) is calculated using the
278 /// "mean equinox," a theoretical reference point in space that
279 /// moves at a constant rate.
280 /// # Arguments
281 /// - `datetime`: UTC datetime
282 ///
283 /// # Returns
284 /// Local Sidereal Time in fractional hours
285 ///
286 /// # Example
287 /// ```
288 /// use chrono::{Utc, TimeZone};
289 /// use astro_math::location::Location;
290 ///
291 /// let dt = Utc.with_ymd_and_hms(1987, 4, 10, 19, 21, 0).unwrap();
292 /// let loc = Location {
293 /// latitude_deg: 32.0,
294 /// longitude_deg: -64.0,
295 /// altitude_m: 200.0,
296 /// };
297 /// let lst = loc.local_mean_sidereal_time(dt);
298 /// assert!((lst - 4.315).abs() < 1e-3);
299 /// ```
300 pub fn local_mean_sidereal_time(&self, datetime: DateTime<Utc>) -> f64 {
301 let jd = julian_date(datetime);
302 local_mean_sidereal_time(jd, self.longitude_deg)
303 }
304
305 /// Returns latitude formatted as ±DD° MM′ SS.sss″ (DMS)
306 pub fn latitude_dms(&self) -> String {
307 format_dms(self.latitude_deg, true)
308 }
309
310 /// Returns longitude formatted as ±DDD° MM′ SS.sss″ (DMS)
311 pub fn longitude_dms(&self) -> String {
312 format_dms(self.longitude_deg, false)
313 }
314}
315
316/// Converts decimal degrees to DMS string format:
317/// - `±DD° MM′ SS.sss″` for latitude
318/// - `±DDD° MM′ SS.sss″` for longitude
319fn format_dms(deg: f64, is_lat: bool) -> String {
320 let sign = if deg < 0.0 { "-" } else { "" };
321 let abs = deg.abs();
322 let d = abs.trunc();
323 let m = ((abs - d) * 60.0).trunc();
324 let s = ((abs - d) * 60.0 - m) * 60.0;
325
326 if is_lat {
327 format!("{sign}{:02.0}° {:02.0}′ {:06.3}″", d, m, s)
328 } else {
329 format!("{sign}{:03.0}° {:02.0}′ {:06.3}″", d, m, s)
330 }
331}
332
333// Legacy DMS parser for backward compatibility
334fn parse_dms(s: &str) -> Result<f64> {
335 // Accepts: "+39 00 01.7", "-92 18 03.2", "39:00:01.7", "-00 30 00"
336 let original = s.trim();
337
338 // Check for negative sign at the beginning
339 let is_negative = original.starts_with('-');
340
341 let cleaned = original
342 .replace(['°', '\'', ':', '"'], " ");
343
344 let parts: Vec<&str> = cleaned.split_whitespace().collect();
345 if parts.len() < 2 {
346 return Err(AstroError::InvalidDmsFormat {
347 input: s.to_string(),
348 expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
349 });
350 }
351
352 let d = f64::from_str(parts[0].trim_start_matches(['+', '-']))
353 .map_err(|_| AstroError::InvalidDmsFormat {
354 input: s.to_string(),
355 expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
356 })?;
357 let m = f64::from_str(parts.get(1).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
358 input: s.to_string(),
359 expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
360 })?;
361 let s = f64::from_str(parts.get(2).unwrap_or(&"0")).map_err(|_| AstroError::InvalidDmsFormat {
362 input: s.to_string(),
363 expected: "DD MM SS.s or DD:MM:SS.s or DD°MM'SS.s\"",
364 })?;
365
366 // Calculate the absolute value first, then apply sign
367 let abs_value = d.abs() + m / 60.0 + s / 3600.0;
368
369 // Apply negative sign if original string started with -
370 Ok(if is_negative { -abs_value } else { abs_value })
371}
372
373/// Parse coordinate from various input formats
374fn parse_coordinate(input: &str, is_latitude: bool) -> Result<f64> {
375 let s = input.trim();
376
377 // Extract compass direction if present
378 let (value_str, compass_dir) = extract_compass_direction(s);
379
380 // Try various parsing strategies in order of likelihood
381
382 // 1. Try compact formats first (specific patterns)
383 if let Ok(deg) = try_parse_compact(&value_str) {
384 return apply_compass_direction(deg, compass_dir, is_latitude);
385 }
386
387 // 2. Try decimal degrees (most common)
388 if let Ok(deg) = try_parse_decimal_degrees(&value_str) {
389 return apply_compass_direction(deg, compass_dir, is_latitude);
390 }
391
392 // 3. Try HMS format (for longitude)
393 if !is_latitude {
394 if let Ok(deg) = try_parse_hms(&value_str) {
395 return apply_compass_direction(deg, compass_dir, is_latitude);
396 }
397 }
398
399 // 4. Try DMS format
400 if let Ok(deg) = try_parse_dms(&value_str) {
401 return apply_compass_direction(deg, compass_dir, is_latitude);
402 }
403
404 // 5. Try degrees + decimal minutes
405 if let Ok(deg) = try_parse_dm(&value_str) {
406 return apply_compass_direction(deg, compass_dir, is_latitude);
407 }
408
409 // If all parsing fails, provide helpful error message
410 Err(AstroError::InvalidDmsFormat {
411 input: input.to_string(),
412 expected: if is_latitude {
413 "Examples: 40.7128, 40.7128N, N40.7128, 40°42'46\", 40 42 46, 40d42m46s"
414 } else {
415 "Examples: -74.0060, 74.0060W, W74.0060, 74°0'21.6\", 74 0 21.6, 4h56m27s"
416 }
417 })
418}
419
420/// Extract compass direction from string and return cleaned value
421fn extract_compass_direction(s: &str) -> (String, Option<char>) {
422 let upper = s.to_uppercase();
423
424 // Check for direction at the beginning
425 if let Some(first_char) = upper.chars().next() {
426 if matches!(first_char, 'N' | 'S' | 'E' | 'W') {
427 // Handle cases like "N40.7" or "N 40.7"
428 let remainder = s[1..].trim_start();
429 return (remainder.to_string(), Some(first_char));
430 }
431 }
432
433 // Check for direction at the end - but only if it's a standalone letter or at the end of a word boundary
434 if let Some(last_char) = upper.chars().last() {
435 if matches!(last_char, 'N' | 'S' | 'E' | 'W') {
436 // Check if this is likely a compass direction vs part of a word
437 // Look at the character before the last one
438 let chars: Vec<char> = upper.chars().collect();
439 #[allow(clippy::comparison_chain)]
440 if chars.len() == 1 {
441 // Single character, definitely a direction
442 let value = s[..s.len()-1].trim_end();
443 return (value.to_string(), Some(last_char));
444 } else if chars.len() > 1 {
445 let second_to_last = chars[chars.len()-2];
446 // If preceded by space, digit, or punctuation, it's likely a direction
447 // If preceded by a letter, it's probably part of a word like "seconds"
448 // BUT: watch out for patterns like "40d42m46s" where 's' is seconds, not South
449 if !second_to_last.is_alphabetic() {
450 // Special case: detect "seconds" vs "South" direction
451 if last_char == 'S' && chars.len() >= 3 {
452 // Look for patterns that indicate "seconds" vs "South"
453 // "46s" or "27s" = seconds (digit immediately before 's', no other indicators)
454 // "8\"S" or "33.8688 S" = South (has space, quotes, or other separators)
455 let has_separators = s.contains(' ') || s.contains('"') || s.contains('\'') || s.contains('°');
456 if !has_separators && second_to_last.is_ascii_digit() {
457 // Pattern like "46s" - likely seconds
458 } else {
459 // Pattern like "8\"S" or "33.8688 S" - likely South direction
460 let value = s[..s.len()-1].trim_end();
461 return (value.to_string(), Some(last_char));
462 }
463 } else {
464 let value = s[..s.len()-1].trim_end();
465 return (value.to_string(), Some(last_char));
466 }
467 }
468 }
469 }
470 }
471
472 // Check for spelled out directions
473 let words: Vec<&str> = upper.split_whitespace().collect();
474 let s_upper = s.to_uppercase();
475 for word in &words {
476 match *word {
477 "NORTH" => return (s_upper.replace("NORTH", "").trim().to_string(), Some('N')),
478 "SOUTH" => return (s_upper.replace("SOUTH", "").trim().to_string(), Some('S')),
479 "EAST" => return (s_upper.replace("EAST", "").trim().to_string(), Some('E')),
480 "WEST" => return (s_upper.replace("WEST", "").trim().to_string(), Some('W')),
481 _ => {}
482 }
483 }
484
485 (s.to_string(), None)
486}
487
488/// Apply compass direction to coordinate value
489fn apply_compass_direction(mut value: f64, direction: Option<char>, is_latitude: bool) -> Result<f64> {
490 if let Some(dir) = direction {
491 match dir {
492 'S' if is_latitude => value = -value.abs(),
493 'W' if !is_latitude => value = -value.abs(),
494 'N' if !is_latitude => return Err(AstroError::InvalidDmsFormat {
495 input: format!("{}{}", value, dir),
496 expected: "N/S for latitude, E/W for longitude"
497 }),
498 'E' if is_latitude => return Err(AstroError::InvalidDmsFormat {
499 input: format!("{}{}", value, dir),
500 expected: "N/S for latitude, E/W for longitude"
501 }),
502 _ => {}
503 }
504 }
505
506 // Validate ranges
507 if is_latitude {
508 crate::error::validate_latitude(value)?;
509 } else {
510 crate::error::validate_longitude(value)?;
511 }
512
513 Ok(value)
514}
515
516/// Try to parse decimal degrees
517fn try_parse_decimal_degrees(s: &str) -> Result<f64> {
518 // Must not contain letters (except scientific notation)
519 if s.chars().any(|c| c.is_alphabetic() && c != 'e' && c != 'E') {
520 return Err(AstroError::InvalidDmsFormat {
521 input: s.to_string(),
522 expected: "decimal degrees"
523 });
524 }
525
526 // Simple decimal number
527 if let Ok(value) = f64::from_str(s) {
528 return Ok(value);
529 }
530
531 // Handle leading + or - with spaces
532 let cleaned = s.trim_start_matches('+').trim();
533 if let Ok(value) = f64::from_str(cleaned) {
534 return Ok(value);
535 }
536
537 Err(AstroError::InvalidDmsFormat {
538 input: s.to_string(),
539 expected: "decimal degrees"
540 })
541}
542
543/// Input validation to prevent DoS attacks
544fn validate_input_length(s: &str, _context: &str) -> Result<()> {
545 const MAX_INPUT_LENGTH: usize = 1000; // Prevent extremely long inputs
546 const MAX_UNICODE_LENGTH: usize = 500; // Unicode chars can be larger
547
548 if s.len() > MAX_INPUT_LENGTH {
549 return Err(AstroError::InvalidDmsFormat {
550 input: format!("Input too long ({} chars)", s.len()),
551 expected: "Input must be < 1000 characters",
552 });
553 }
554
555 if s.chars().count() > MAX_UNICODE_LENGTH {
556 return Err(AstroError::InvalidDmsFormat {
557 input: format!("Too many Unicode characters ({} chars)", s.chars().count()),
558 expected: "Input must be < 500 Unicode characters",
559 });
560 }
561
562 Ok(())
563}
564
565/// Try to parse HMS format (for longitude)
566fn try_parse_hms(s: &str) -> Result<f64> {
567 validate_input_length(s, "HMS")?;
568
569 let normalized = s.to_lowercase()
570 .replace("hours", "h").replace("hour", "h")
571 .replace("minutes", "m").replace("minute", "m")
572 .replace("seconds", "s").replace("second", "s")
573 .replace("hrs", "h").replace("hr", "h")
574 .replace("mins", "m").replace("min", "m")
575 .replace("secs", "s").replace("sec", "s")
576 .replace('′', "'") // Unicode prime
577 .replace(['″', '"'], "\"");
578
579 if let Some(caps) = HMS_REGEX.captures(&normalized) {
580 let h = f64::from_str(&caps[1]).map_err(|_| AstroError::InvalidDmsFormat {
581 input: s.to_string(),
582 expected: "HMS format"
583 })?;
584 let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
585 let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
586
587 // Convert HMS to degrees (15 degrees per hour)
588 return Ok((h + m/60.0 + s/3600.0) * 15.0);
589 }
590
591 // Try colon-separated HMS
592 if s.contains('h') || s.contains('H') {
593 let parts: Vec<&str> = s.split(':').collect();
594 if parts.len() >= 2 {
595 let h_part = parts[0].trim_end_matches(['h', 'H']);
596 if let Ok(h) = f64::from_str(h_part) {
597 let m = parts.get(1).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
598 let s = parts.get(2).and_then(|p| f64::from_str(p.trim()).ok()).unwrap_or(0.0);
599 return Ok((h + m/60.0 + s/3600.0) * 15.0);
600 }
601 }
602 }
603
604 Err(AstroError::InvalidDmsFormat {
605 input: s.to_string(),
606 expected: "HMS format"
607 })
608}
609
610/// Try to parse DMS format with maximum flexibility
611fn try_parse_dms(s: &str) -> Result<f64> {
612 // First handle verbose format like "40 degrees 42 minutes 46 seconds"
613 let verbose_normalized = s.to_lowercase()
614 .replace("degrees", "d")
615 .replace("degree", "d")
616 .replace("deg", "d")
617 .replace("minutes", "m")
618 .replace("minute", "m")
619 .replace("min", "m")
620 .replace("seconds", "s")
621 .replace("second", "s")
622 .replace("sec", "s");
623
624 // Try parsing the verbose-normalized version first
625 if verbose_normalized != s.to_lowercase() {
626 // Only try this if we actually made substitutions
627 // But make sure to preserve the original negative sign detection
628 if let Ok(mut result) = try_parse_dms_internal(&verbose_normalized) {
629 // Check the original string for negative sign since that's what we want to preserve
630 if s.starts_with('-') {
631 result = -result.abs();
632 }
633 return Ok(result);
634 }
635 }
636
637 // Then try the original string
638 try_parse_dms_internal(s)
639}
640
641/// Internal DMS parser that handles the actual parsing logic
642fn try_parse_dms_internal(s: &str) -> Result<f64> {
643 validate_input_length(s, "DMS")?;
644
645 if let Some(caps) = DMS_REGEX.captures(s) {
646 if caps.get(2).is_some() { // Ensure at least degrees and minutes
647 let d_str = &caps[1];
648 let is_negative = s.starts_with('-') || d_str.starts_with('-');
649
650 let d = f64::from_str(d_str.trim_start_matches('-')).map_err(|_| AstroError::InvalidDmsFormat {
651 input: s.to_string(),
652 expected: "DMS format"
653 })?;
654 let m = caps.get(2).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
655 let s = caps.get(3).and_then(|c| f64::from_str(c.as_str()).ok()).unwrap_or(0.0);
656
657 let abs_value = d + m/60.0 + s/3600.0;
658 return Ok(if is_negative { -abs_value } else { abs_value });
659 }
660 }
661
662 // Normalize Unicode and common symbols
663 let _normalized = s
664 .replace(['°', 'º', '′', '″', '\'', '"', '"', '`'], " ")
665 .replace("''", " ") // Double apostrophe as seconds
666 .replace(['d', 'D', 'm', 'M', 's', 'S'], " ")
667 .to_lowercase();
668
669 // Try various separators
670 let separators = [' ', ':', ',', ';'];
671
672 // Check if the string starts with a negative sign
673 let is_negative = s.starts_with('-');
674
675 for sep in &separators {
676 let parts: Vec<&str> = s.split(*sep).filter(|p| !p.is_empty()).collect();
677 if parts.len() >= 2 {
678 // Clean up parts
679 let clean_parts: Vec<String> = parts.iter().enumerate().map(|(i, p)| {
680 let cleaned = p.trim()
681 .trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c));
682 // For the first part, also trim leading sign
683 if i == 0 {
684 cleaned.trim_start_matches(['+', '-']).to_string()
685 } else {
686 cleaned.to_string()
687 }
688 }).collect();
689
690 if let Ok(d) = f64::from_str(&clean_parts[0]) {
691 if let Ok(m) = f64::from_str(&clean_parts[1]) {
692 let s = clean_parts.get(2)
693 .and_then(|p| f64::from_str(p).ok())
694 .unwrap_or(0.0);
695
696 let abs_value = d + m/60.0 + s/3600.0;
697 return Ok(if is_negative { -abs_value } else { abs_value });
698 }
699 }
700 }
701 }
702
703 // Try dash separator specially
704 if s.contains('-') {
705 // For negative numbers, skip the first dash
706 let dash_parts: Vec<&str> = if is_negative {
707 let no_first_dash = &s[1..]; // Remove the leading -
708 no_first_dash.split('-').collect()
709 } else {
710 s.split('-').collect()
711 };
712
713 let parts: Vec<&str> = dash_parts.into_iter().filter(|p| !p.is_empty()).collect();
714 if parts.len() >= 2 {
715 let clean_parts: Vec<String> = parts.iter().map(|p| {
716 p.trim()
717 .trim_end_matches(|c: char| c.is_alphabetic() || "°'\"″′".contains(c))
718 .to_string()
719 }).collect();
720
721 if let Ok(d) = f64::from_str(&clean_parts[0]) {
722 if let Ok(m) = f64::from_str(&clean_parts[1]) {
723 let s = clean_parts.get(2)
724 .and_then(|p| f64::from_str(p).ok())
725 .unwrap_or(0.0);
726
727 let abs_value = d + m/60.0 + s/3600.0;
728 return Ok(if is_negative { -abs_value } else { abs_value });
729 }
730 }
731 }
732 }
733
734 Err(AstroError::InvalidDmsFormat {
735 input: s.to_string(),
736 expected: "DMS format"
737 })
738}
739
740/// Try to parse compact formats like DDMMSS or DDMM.mmm
741fn try_parse_compact(s: &str) -> Result<f64> {
742 // Only try compact format if string has no spaces or separators
743 if s.contains(' ') || s.contains(':') || s.contains('-') || s.contains('°') {
744 return Err(AstroError::InvalidDmsFormat {
745 input: s.to_string(),
746 expected: "compact format"
747 });
748 }
749
750 // Remove all non-digit characters except decimal point
751 let digits_only: String = s.chars()
752 .filter(|c| c.is_ascii_digit() || *c == '.')
753 .collect();
754
755 // Must be mostly digits
756 if digits_only.len() < s.len() / 2 {
757 return Err(AstroError::InvalidDmsFormat {
758 input: s.to_string(),
759 expected: "compact format"
760 });
761 }
762
763 // DDMM.mmm format (aviation/marine)
764 if digits_only.contains('.') && digits_only.len() >= 6 {
765 let parts: Vec<&str> = digits_only.split('.').collect();
766 if parts[0].len() == 4 || parts[0].len() == 5 { // DDMM or DDDMM
767 if let Ok(ddmm) = i32::from_str(parts[0]) {
768 let dd = ddmm / 100;
769 let mm = ddmm % 100;
770 if mm < 60 { // Valid minutes
771 let decimal_minutes = parts.get(1)
772 .and_then(|p| f64::from_str(&format!("0.{}", p)).ok())
773 .unwrap_or(0.0);
774
775 return Ok(dd as f64 + (mm as f64 + decimal_minutes) / 60.0);
776 }
777 }
778 }
779 }
780
781 // DDMMSS format
782 if !digits_only.contains('.') && (digits_only.len() == 6 || digits_only.len() == 7) {
783 // DDMMSS or DDDMMSS
784 let (dd_len, _is_longitude) = if digits_only.len() == 7 { (3, true) } else { (2, false) };
785
786 if let Ok(dd) = i32::from_str(&digits_only[..dd_len]) {
787 if let Ok(mm) = i32::from_str(&digits_only[dd_len..dd_len+2]) {
788 if let Ok(ss) = i32::from_str(&digits_only[dd_len+2..]) {
789 if mm < 60 && ss < 60 { // Valid minutes and seconds
790 return Ok(dd as f64 + mm as f64 / 60.0 + ss as f64 / 3600.0);
791 }
792 }
793 }
794 }
795 }
796
797 Err(AstroError::InvalidDmsFormat {
798 input: s.to_string(),
799 expected: "compact format"
800 })
801}
802
803/// Try to parse degrees and decimal minutes
804fn try_parse_dm(s: &str) -> Result<f64> {
805 // Normalize the string
806 let normalized = s
807 .replace(['°', '′', '\'', 'd', 'm'], " ")
808 .to_lowercase();
809
810 // Split and clean parts
811 let parts: Vec<&str> = normalized.split_whitespace()
812 .filter(|p| p.chars().any(|c| c.is_ascii_digit()))
813 .collect();
814
815 if parts.len() == 2 {
816 if let Ok(d) = f64::from_str(parts[0]) {
817 if let Ok(m) = f64::from_str(parts[1]) {
818 // Check if minutes value makes sense (should be < 60 if integer part)
819 if m < 60.0 || m.fract() != 0.0 {
820 let sign = if d < 0.0 { -1.0 } else { 1.0 };
821 return Ok(sign * (d.abs() + m / 60.0));
822 }
823 }
824 }
825 }
826
827 Err(AstroError::InvalidDmsFormat {
828 input: s.to_string(),
829 expected: "degrees and decimal minutes"
830 })
831}