Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mth5 \ mth5 \ io \ nims \ gps.py: 93%
240 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 00:01 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 00:01 -0800
1# -*- coding: utf-8 -*-
2"""
3NIMS GPS data parser for magnetotelluric surveys.
5This module provides functionality to parse GPS stamps from NIMS (North Island
6Magnetotelluric Survey) data files. It handles both GPRMC and GPGGA GPS message
7formats, extracting location, time, and other GPS-related information.
9Classes
10-------
11GPSError : Exception
12 Custom exception for GPS parsing errors.
13GPS : object
14 Main class for parsing and validating GPS stamp data.
16Notes
17-----
18The GPS parser handles two main GPS message types:
19- GPRMC: Provides full date/time information and magnetic declination
20- GPGGA: Provides elevation data and fix quality information
22Binary data contamination is automatically cleaned during parsing.
24Examples
25--------
26>>> from mth5.io.nims.gps import GPS
27>>> gps_string = "GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*"
28>>> gps = GPS(gps_string)
29>>> print(f"Latitude: {gps.latitude}, Longitude: {gps.longitude}")
31Author
32------
33jpeacock
35Created
36-------
37Thu Sep 1 11:43:56 2022
38"""
39# =============================================================================
40# Imports
41# =============================================================================
42from __future__ import annotations
44import datetime
46import dateutil
47from loguru import logger
50# =============================================================================
51class GPSError(Exception):
52 """
53 Custom exception for GPS parsing and validation errors.
55 Raised when GPS string parsing fails or when GPS data validation
56 encounters invalid values.
57 """
60class GPS:
61 """
62 Parser for GPS stamps from NIMS magnetotelluric data.
64 Handles parsing and validation of GPS strings from NIMS data files.
65 Supports both GPRMC and GPGGA message formats, automatically detecting
66 the type and extracting relevant geographic and temporal information.
68 Parameters
69 ----------
70 gps_string : str or bytes
71 Raw GPS string to be parsed. Can contain binary contamination
72 which will be automatically cleaned.
73 index : int, default 0
74 Index or sequence number for this GPS record.
76 Attributes
77 ----------
78 gps_string : str
79 The original GPS string provided for parsing.
80 index : int
81 Index or sequence number for this GPS record.
82 valid : bool
83 Whether the GPS string was successfully parsed and validated.
84 elevation_units : str
85 Units for elevation measurements, typically "meters".
86 logger : loguru.Logger
87 Logger instance for debugging and error reporting.
89 Notes
90 -----
91 GPS message format differences:
93 **GPRMC (Recommended Minimum Course)**
94 Contains: date, time, coordinates, speed, course, magnetic declination
95 Date: Full date information (year, month, day)
97 **GPGGA (Global Positioning System Fix Data)**
98 Contains: time, coordinates, fix quality, elevation
99 Date: Defaults to 1980-01-01 for time estimation only
101 The parser automatically handles:
102 - Binary contamination in GPS strings
103 - Missing comma delimiters
104 - GPS type auto-detection and correction
105 - Coordinate conversion from degrees-minutes to decimal degrees
107 Examples
108 --------
109 Parse a GPRMC string:
111 >>> gps_string = "GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*"
112 >>> gps = GPS(gps_string)
113 >>> print(f"Position: {gps.latitude:.5f}, {gps.longitude:.5f}")
114 Position: 34.72683, -115.73501
116 Parse a GPGGA string:
118 >>> gps_string = "GPGGA,183511,3443.6098,N,11544.1007,W,1,04,2.6,937.2,M,-28.1,M,*"
119 >>> gps = GPS(gps_string)
120 >>> print(f"Elevation: {gps.elevation} {gps.elevation_units}")
121 Elevation: 937.2 meters
123 Handle invalid GPS data:
125 >>> gps = GPS("invalid_string")
126 >>> print(f"Valid: {gps.valid}")
127 Valid: False
128 """
130 def __init__(self, gps_string: str | bytes, index: int = 0) -> None:
131 self.logger = logger
133 self.gps_string = gps_string
134 self.index = index
135 self._type = None
136 self._time = None
137 self._date = "010180"
138 self._latitude = None
139 self._latitude_hemisphere = None
140 self._longitude = None
141 self._longitude_hemisphere = None
142 self._declination = None
143 self._declination_hemisphere = None
144 self._elevation = None
145 self.valid = False
146 self.elevation_units = "meters"
148 self.type_dict = {
149 "gprmc": {
150 0: "type",
151 1: "time",
152 2: "fix",
153 3: "latitude",
154 4: "latitude_hemisphere",
155 5: "longitude",
156 6: "longitude_hemisphere",
157 7: "skip",
158 8: "skip",
159 9: "date",
160 10: "declination",
161 11: "declination_hemisphere",
162 "length": [12],
163 "type": 0,
164 "time": 1,
165 "fix": 2,
166 "latitude": 3,
167 "latitude_hemisphere": 4,
168 "longitude": 5,
169 "longitude_hemisphere": 6,
170 "date": 9,
171 "declination": 10,
172 },
173 "gpgga": {
174 0: "type",
175 1: "time",
176 2: "latitude",
177 3: "latitude_hemisphere",
178 4: "longitude",
179 5: "longitude_hemisphere",
180 6: "var_01",
181 7: "var_02",
182 8: "var_03",
183 9: "elevation",
184 10: "elevation_units",
185 11: "elevation_error",
186 12: "elevation_error_units",
187 13: "null_01",
188 14: "null_02",
189 "length": [14, 15],
190 "type": 0,
191 "time": 1,
192 "latitude": 2,
193 "latitude_hemisphere": 3,
194 "longitude": 4,
195 "longitude_hemisphere": 5,
196 "elevation": 9,
197 "elevation_units": 10,
198 "elevation_error": 11,
199 "elevation_error_units": 12,
200 },
201 }
202 self.parse_gps_string(self.gps_string)
204 def __str__(self) -> str:
205 """
206 String representation of GPS object.
208 Returns
209 -------
210 str
211 Formatted string containing GPS type, coordinates, elevation,
212 declination, and other key properties.
214 Examples
215 --------
216 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
217 >>> print(gps)
218 type = GPRMC
219 index = 0
220 fix = A
221 time_stamp = 2019-09-26T18:35:11
222 latitude = 34.72683
223 longitude = -115.73501166666667
224 elevation = 0.0
225 declination = 13.1
226 """
227 msg = [
228 f"type = {self.gps_type}",
229 f"index = {self.index}",
230 f"fix = {self.fix}",
231 f"time_stamp = {self.time_stamp}",
232 f"latitude = {self.latitude}",
233 f"longitude = {self.longitude}",
234 f"elevation = {self.elevation}",
235 f"declination = {self.declination}",
236 ]
238 return "\n".join(msg)
240 def __repr__(self) -> str:
241 """Return string representation of GPS object."""
242 return self.__str__()
244 def validate_gps_string(self, gps_string: str | bytes) -> str | None:
245 """
246 Validate and clean GPS string.
248 Removes binary contamination, finds string terminator, and validates
249 format. Handles both string and bytes input.
251 Parameters
252 ----------
253 gps_string : str or bytes
254 Raw GPS string to validate. May contain binary contamination
255 that will be automatically removed.
257 Returns
258 -------
259 str or None
260 Cleaned GPS string with terminator removed, or None if validation
261 fails due to missing terminator or decode errors.
263 Raises
264 ------
265 TypeError
266 If input is not string or bytes.
268 Notes
269 -----
270 Binary contamination bytes that are automatically removed:
271 - ``\\xd9``, ``\\xc7``, ``\\xcc``
272 - ``\\x00`` (null byte, replaced with '*' terminator)
274 The GPS string must end with '*' character to be considered valid.
276 Examples
277 --------
278 Clean a contaminated binary GPS string:
280 >>> gps = GPS("")
281 >>> contaminated = b"GPRMC,183511,A\\xd9,3443.6098,N*"
282 >>> clean = gps.validate_gps_string(contaminated)
283 >>> print(clean)
284 GPRMC,183511,A,3443.6098,N
286 Handle missing terminator:
288 >>> invalid = "GPRMC,183511,A,3443.6098,N" # No '*'
289 >>> result = gps.validate_gps_string(invalid)
290 >>> print(result)
291 None
292 """
294 if isinstance(gps_string, bytes):
295 for replace_str in [b"\xd9", b"\xc7", b"\xcc"]:
296 gps_string = gps_string.replace(replace_str, b"")
297 ### sometimes the end is set with a zero for some reason
298 gps_string = gps_string.replace(b"\x00", b"*")
300 if gps_string.find(b"*") < 0:
301 logger.debug(f"GPSError: No end to stamp {gps_string}")
302 return None
303 else:
304 try:
305 gps_string = gps_string[0 : gps_string.find(b"*")].decode()
306 return gps_string
307 except UnicodeDecodeError:
308 logger.debug(f"GPSError: stamp not correct format, {gps_string}")
309 return None
310 elif isinstance(gps_string, str):
311 if "*" not in gps_string:
312 logger.debug(f"GPSError: No end to stamp {gps_string}")
313 return None
314 return gps_string[0 : gps_string.find("*")]
315 else:
316 raise TypeError(
317 f"input must be a string or bytes object, not {type(gps_string)}"
318 )
320 def _split_gps_string(
321 self, gps_string: str | bytes, delimiter: str = ","
322 ) -> list[str]:
323 """
324 Split GPS string into components after validation.
326 Parameters
327 ----------
328 gps_string : str or bytes
329 GPS string to split.
330 delimiter : str, default ","
331 Character to split on (typically comma).
333 Returns
334 -------
335 list of str
336 GPS string components, or empty list if validation fails.
338 Notes
339 -----
340 The delimiter parameter is provided for flexibility but validation
341 always occurs first, which may affect the final splitting behavior.
342 """
343 gps_string = self.validate_gps_string(gps_string)
344 if gps_string is None:
345 self.valid = False
346 return []
347 return gps_string.strip().split(",")
349 def parse_gps_string(self, gps_string: str | bytes) -> None:
350 """
351 Parse GPS string and populate object attributes.
353 Main parsing method that validates the GPS string, identifies the
354 message type (GPRMC/GPGGA), and extracts all relevant information
355 into object attributes.
357 Parameters
358 ----------
359 gps_string : str or bytes
360 Raw GPS string from NIMS data file.
362 Notes
363 -----
364 This method performs the following operations:
365 1. Splits and validates the GPS string
366 2. Handles missing comma delimiter between time and coordinates
367 3. Validates each GPS field according to message type
368 4. Sets object attributes based on parsed values
369 5. Sets ``valid`` flag based on parsing success
371 If any validation errors occur, they are logged but parsing continues
372 with ``None`` values for invalid fields.
374 The method automatically detects GPS message type and applies
375 appropriate field validation rules.
377 Examples
378 --------
379 Parse a valid GPS string:
381 >>> gps = GPS("")
382 >>> gps.parse_gps_string("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
383 >>> print(f"Valid: {gps.valid}, Type: {gps.gps_type}")
384 Valid: True, Type: GPRMC
386 Handle invalid GPS string:
388 >>> gps.parse_gps_string("invalid_gps_data")
389 >>> print(f"Valid: {gps.valid}")
390 Valid: False
391 """
393 gps_list = self._split_gps_string(gps_string)
394 if gps_list == []:
395 self.logger.debug(f"GPS string is invalid, {gps_string}")
396 return
397 if len(gps_list) > 1:
398 if len(gps_list[1]) > 6:
399 self.logger.debug(
400 "GPS time and lat missing a comma adding one, check time"
401 )
402 gps_list = (
403 gps_list[0:1] + [gps_list[1][0:6], gps_list[1][6:]] + gps_list[2:]
404 )
405 ### validate the gps list to make sure it is usable
406 gps_list, error_list = self.validate_gps_list(gps_list)
407 if len(error_list) > 0:
408 for error in error_list:
409 logger.debug("GPSError: " + error)
410 if gps_list is None:
411 return
412 attr_dict = self.type_dict[gps_list[0].lower()]
414 for index, value in enumerate(gps_list):
415 setattr(self, "_" + attr_dict[index], value)
416 if None not in gps_list:
417 self.valid = True
418 self.gps_string = gps_string
420 def validate_gps_list(
421 self, gps_list: list[str]
422 ) -> tuple[list[str] | None, list[str]]:
423 """
424 Validate GPS field list and check format compliance.
426 Performs comprehensive validation of GPS message components including
427 type checking, length validation, and field-specific validation.
429 Parameters
430 ----------
431 gps_list : list of str
432 GPS message components split by delimiter.
434 Returns
435 -------
436 gps_list : list of str or None
437 Validated GPS list with corrected values, or None if
438 critical validation fails.
439 error_list : list of str
440 List of validation error messages encountered during processing.
442 Notes
443 -----
444 Validation steps performed:
445 1. GPS message type validation and correction
446 2. Message length validation based on type
447 3. Time format validation (6 digits)
448 4. Coordinate validation (latitude/longitude + hemisphere)
449 5. Date validation for GPRMC messages
450 6. Elevation validation for GPGGA messages
452 Non-critical validation errors are collected but don't halt processing.
453 Critical errors (type or length) return None and stop validation.
455 Examples
456 --------
457 Validate a correct GPS list:
459 >>> gps = GPS("")
460 >>> gps_data = ["GPRMC", "183511", "A", "3443.6098", "N", "11544.1007", "W",
461 ... "000.0", "000.0", "260919", "013.1", "E"]
462 >>> validated, errors = gps.validate_gps_list(gps_data)
463 >>> print(f"Errors: {len(errors)}")
464 Errors: 0
466 Handle validation errors:
468 >>> bad_data = ["INVALID", "time", "fix"]
469 >>> validated, errors = gps.validate_gps_list(bad_data)
470 >>> print(f"Result: {validated}, Errors: {len(errors)}")
471 Result: None, Errors: 1
472 """
473 error_list = []
474 try:
475 gps_list = self._validate_gps_type(gps_list)
476 except GPSError as error:
477 error_list.append(error.args[0])
478 return None, error_list
479 ### get the string type
480 g_type = gps_list[0].lower()
482 ### first check the length, if it is not the proper length then
483 ### return, cause you never know if everything else is correct
484 try:
485 self._validate_list_length(gps_list)
486 except GPSError as error:
487 error_list.append(error.args[0])
488 return None, error_list
489 try:
490 gps_list[self.type_dict[g_type]["time"]] = self._validate_time(
491 gps_list[self.type_dict[g_type]["time"]]
492 )
493 except GPSError as error:
494 error_list.append(error.args[0])
495 gps_list[self.type_dict[g_type]["time"]] = None
496 try:
497 gps_list[self.type_dict[g_type]["latitude"]] = self._validate_latitude(
498 gps_list[self.type_dict[g_type]["latitude"]],
499 gps_list[self.type_dict[g_type]["latitude_hemisphere"]],
500 )
501 except GPSError as error:
502 error_list.append(error.args[0])
503 gps_list[self.type_dict[g_type]["latitude"]] = None
504 try:
505 gps_list[self.type_dict[g_type]["longitude"]] = self._validate_longitude(
506 gps_list[self.type_dict[g_type]["longitude"]],
507 gps_list[self.type_dict[g_type]["longitude_hemisphere"]],
508 )
509 except GPSError as error:
510 error_list.append(error.args[0])
511 gps_list[self.type_dict[g_type]["longitude"]] = None
512 if g_type == "gprmc":
513 try:
514 gps_list[self.type_dict["gprmc"]["date"]] = self._validate_date(
515 gps_list[self.type_dict["gprmc"]["date"]]
516 )
517 except GPSError as error:
518 error_list.append(error.args[0])
519 gps_list[self.type_dict[g_type]["date"]] = None
520 elif g_type == "gpgga":
521 try:
522 gps_list[
523 self.type_dict["gpgga"]["elevation"]
524 ] = self._validate_elevation(
525 gps_list[self.type_dict["gpgga"]["elevation"]]
526 )
527 except GPSError as error:
528 error_list.append(error.args[0])
529 gps_list[self.type_dict["gpgga"]["elevation"]] = None
530 return gps_list, error_list
532 def _validate_gps_type(self, gps_list: list[str]) -> list[str]:
533 """
534 Validate and auto-correct GPS message type.
536 Parameters
537 ----------
538 gps_list : list of str
539 GPS message components with type as first element.
541 Returns
542 -------
543 list of str
544 GPS list with corrected type and possibly extracted time data.
546 Raises
547 ------
548 GPSError
549 If GPS type cannot be identified as GPGGA or GPRMC variant.
551 Notes
552 -----
553 Auto-correction rules:
554 - "GPG*" patterns → "GPGGA"
555 - "GPR*" patterns → "GPRMC"
556 - Handles concatenated type+time strings
557 - Validates final type is "gpgga" or "gprmc"
558 """
559 gps_type = gps_list[0].lower()
560 if "gpg" in gps_type:
561 if len(gps_type) > 5:
562 gps_list = ["GPGGA", gps_type[-6:]] + gps_list[1:]
563 elif len(gps_type) < 5:
564 gps_list[0] = "GPGGA"
565 elif "gpr" in gps_type:
566 if len(gps_type) > 5:
567 gps_list = ["GPRMC", gps_type[-6:]] + gps_list[1:]
568 elif len(gps_type) < 5:
569 gps_list[0] = "GPRMC"
570 gps_type = gps_list[0].lower()
571 if gps_type not in ["gpgga", "gprmc"]:
572 raise GPSError(
573 "GPS String type not correct. "
574 f"Expect GPGGA or GPRMC, got {gps_type.upper()}"
575 )
576 return gps_list
578 def _validate_list_length(self, gps_list: list[str]) -> None:
579 """
580 Validate GPS message length based on message type.
582 Parameters
583 ----------
584 gps_list : list of str
585 GPS message components.
587 Raises
588 ------
589 GPSError
590 If message length doesn't match expected length for the GPS type.
592 Notes
593 -----
594 Expected lengths:
595 - GPRMC: 12 components
596 - GPGGA: 14 or 15 components
597 """
598 gps_list_type = gps_list[0].lower()
599 expected_len = self.type_dict[gps_list_type]["length"]
600 if len(gps_list) not in expected_len:
601 raise GPSError(
602 f"GPS string not correct length for {gps_list_type.upper()}. "
603 f"Expected {expected_len}, got {len(gps_list)} "
604 f"{','.join(gps_list)}"
605 )
607 def _validate_time(self, time_str: str) -> str:
608 """
609 Validate GPS time string format.
611 Parameters
612 ----------
613 time_str : str
614 Time string in HHMMSS format.
616 Returns
617 -------
618 str
619 Validated time string.
621 Raises
622 ------
623 GPSError
624 If time string is not 6 characters or not numeric.
626 Examples
627 --------
628 >>> gps = GPS("")
629 >>> gps._validate_time("183511")
630 '183511'
631 """
632 if len(time_str) != 6:
633 raise GPSError(
634 f"Length of time string {time_str} not correct. "
635 f"Expected 6 got {len(time_str)}. string = {time_str}"
636 )
637 try:
638 int(time_str)
639 except ValueError:
640 raise GPSError(f"Could not convert time string {time_str}")
641 return time_str
643 def _validate_date(self, date_str: str) -> str:
644 """
645 Validate GPS date string format.
647 Parameters
648 ----------
649 date_str : str
650 Date string in DDMMYY format.
652 Returns
653 -------
654 str
655 Validated date string.
657 Raises
658 ------
659 GPSError
660 If date string is not 6 characters or not numeric.
662 Examples
663 --------
664 >>> gps = GPS("")
665 >>> gps._validate_date("260919")
666 '260919'
667 """
668 if len(date_str) != 6:
669 raise GPSError(
670 f"Length of date string not correct {date_str}. "
671 f"Expected 6 got {len(date_str)}. string = {date_str}"
672 )
673 try:
674 int(date_str)
675 except ValueError:
676 raise GPSError(f"Could not convert date string {date_str}")
677 return date_str
679 def _validate_latitude(self, latitude_str: str, hemisphere_str: str) -> str:
680 """
681 Validate latitude coordinate and hemisphere.
683 Parameters
684 ----------
685 latitude_str : str
686 Latitude in DDMM.MMMM format (degrees and decimal minutes).
687 hemisphere_str : str
688 Hemisphere indicator, must be 'N' or 'S'.
690 Returns
691 -------
692 str
693 Validated latitude string.
695 Raises
696 ------
697 GPSError
698 If latitude format is invalid, hemisphere is wrong length/value,
699 or coordinate cannot be converted to float.
701 Notes
702 -----
703 Latitude format: DDMM.MMMM where DD=degrees, MM.MMMM=minutes
704 Valid hemispheres: 'N' (North), 'S' (South)
705 Minimum expected length: 8 characters
707 Examples
708 --------
709 >>> gps = GPS("")
710 >>> gps._validate_latitude("3443.6098", "N")
711 '3443.6098'
712 """
713 if len(latitude_str) < 8:
714 raise GPSError(
715 f"Latitude string should be larger than 7 characters. "
716 f"Got {len(latitude_str)}. string = {latitude_str}"
717 )
718 if len(hemisphere_str) != 1:
719 raise GPSError(
720 "Latitude hemisphere should be 1 character. "
721 f"Got {len(hemisphere_str)}. string = {hemisphere_str}"
722 )
723 if hemisphere_str.lower() not in ["n", "s"]:
724 raise GPSError(
725 f"Latitude hemisphere {hemisphere_str.upper()} not understood"
726 )
727 try:
728 float(latitude_str)
729 except ValueError:
730 raise GPSError(f"Could not convert latitude string {latitude_str}")
731 return latitude_str
733 def _validate_longitude(self, longitude_str: str, hemisphere_str: str) -> str:
734 """
735 Validate longitude coordinate and hemisphere.
737 Parameters
738 ----------
739 longitude_str : str
740 Longitude in DDDMM.MMMM format (degrees and decimal minutes).
741 hemisphere_str : str
742 Hemisphere indicator, must be 'E' or 'W'.
744 Returns
745 -------
746 str
747 Validated longitude string.
749 Raises
750 ------
751 GPSError
752 If longitude format is invalid, hemisphere is wrong length/value,
753 or coordinate cannot be converted to float.
755 Notes
756 -----
757 Longitude format: DDDMM.MMMM where DDD=degrees, MM.MMMM=minutes
758 Valid hemispheres: 'E' (East), 'W' (West)
759 Minimum expected length: 8 characters
761 Examples
762 --------
763 >>> gps = GPS("")
764 >>> gps._validate_longitude("11544.1007", "W")
765 '11544.1007'
766 """
767 if len(longitude_str) < 8:
768 raise GPSError(
769 "Longitude string should be larger than 7 characters. "
770 f"Got {len(longitude_str)}. string = {longitude_str}"
771 )
772 if len(hemisphere_str) != 1:
773 raise GPSError(
774 "Longitude hemisphere should be 1 character. "
775 f"Got {len(hemisphere_str)}. string = {hemisphere_str}"
776 )
777 if hemisphere_str.lower() not in ["e", "w"]:
778 raise GPSError(
779 f"Longitude hemisphere {hemisphere_str.upper()} not understood"
780 )
781 try:
782 float(longitude_str)
783 except ValueError:
784 raise GPSError(f"Could not convert longitude string {longitude_str}")
785 return longitude_str
787 def _validate_elevation(self, elevation_str: str) -> str:
788 """
789 Validate elevation value and convert to standard format.
791 Parameters
792 ----------
793 elevation_str : str
794 Elevation string, may include 'M' or 'm' unit suffix.
796 Returns
797 -------
798 str
799 Validated elevation as string representation of float.
801 Raises
802 ------
803 GPSError
804 If elevation cannot be converted to float.
806 Notes
807 -----
808 - Automatically removes 'M' or 'm' unit suffixes
809 - Empty string is converted to "0.0"
810 - Result is always a string representation of a float
812 Examples
813 --------
814 >>> gps = GPS("")
815 >>> gps._validate_elevation("937.2")
816 '937.2'
817 >>> gps._validate_elevation("937.2M")
818 '937.2'
819 >>> gps._validate_elevation("")
820 '0.0'
821 """
822 elevation_str = elevation_str.lower().replace("m", "")
823 if elevation_str == "":
824 elevation_str = "0"
825 try:
826 elevation_str = f"{float(elevation_str)}"
827 except ValueError:
828 raise GPSError(f"Elevation could not be converted {elevation_str}")
829 return elevation_str
831 @property
832 def latitude(self) -> float:
833 """
834 Latitude in decimal degrees (WGS84).
836 Returns
837 -------
838 float
839 Latitude in decimal degrees. Negative values indicate
840 Southern hemisphere. Returns 0.0 if coordinate data is invalid.
842 Notes
843 -----
844 Converts from GPS format (DDMM.MMMM) to decimal degrees:
845 decimal_degrees = degrees + minutes/60
847 Southern hemisphere coordinates are automatically converted to negative values.
849 Examples
850 --------
851 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
852 >>> gps.latitude
853 34.72683
854 """
855 if self._latitude is not None and self._latitude_hemisphere is not None:
856 index = len(self._latitude) - 7
857 lat = float(self._latitude[0:index]) + float(self._latitude[index:]) / 60
858 if "s" in self._latitude_hemisphere.lower():
859 lat *= -1
860 return lat
861 return 0.0
863 @property
864 def longitude(self) -> float:
865 """
866 Longitude in decimal degrees (WGS84).
868 Returns
869 -------
870 float
871 Longitude in decimal degrees. Negative values indicate
872 Western hemisphere. Returns 0.0 if coordinate data is invalid.
874 Notes
875 -----
876 Converts from GPS format (DDDMM.MMMM) to decimal degrees:
877 decimal_degrees = degrees + minutes/60
879 Western hemisphere coordinates are automatically converted to negative values.
881 Examples
882 --------
883 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
884 >>> gps.longitude
885 -115.73501166666667
886 """
887 if self._longitude is not None and self._longitude_hemisphere is not None:
888 index = len(self._longitude) - 7
889 lon = float(self._longitude[0:index]) + float(self._longitude[index:]) / 60
890 if "w" in self._longitude_hemisphere.lower():
891 lon *= -1
892 return lon
893 return 0.0
895 @property
896 def elevation(self) -> float:
897 """
898 Elevation above sea level in meters.
900 Returns
901 -------
902 float
903 Elevation in meters. Returns 0.0 if elevation data is not
904 available or cannot be converted.
906 Notes
907 -----
908 Elevation is typically only available in GPGGA messages.
909 GPRMC messages will return 0.0 as they don't contain elevation data.
911 Conversion errors are logged but don't raise exceptions.
913 Examples
914 --------
915 >>> gps = GPS("GPGGA,183511,3443.6098,N,11544.1007,W,1,04,2.6,937.2,M,-28.1,M,*")
916 >>> gps.elevation
917 937.2
918 """
919 if self._elevation is not None:
920 try:
921 return float(self._elevation)
922 except ValueError:
923 self.logger.error(
924 "GPSError: Could not get elevation GPS string"
925 f"not complete {self.gps_string}"
926 )
927 return 0.0
929 @property
930 def time_stamp(self) -> datetime.datetime | None:
931 """
932 GPS timestamp as datetime object.
934 Returns
935 -------
936 datetime.datetime or None
937 Timestamp parsed from GPS data, or None if time data is invalid.
939 Notes
940 -----
941 For GPRMC messages: Uses full date and time information
942 For GPGGA messages: Uses time with default date of 1980-01-01
944 Time format: HHMMSS (hours, minutes, seconds)
945 Date format: DDMMYY (day, month, 2-digit year)
947 Invalid date strings are logged but return None rather than raising exceptions.
949 Examples
950 --------
951 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
952 >>> gps.time_stamp
953 datetime.datetime(2019, 9, 26, 18, 35, 11)
954 """
955 if self._time is None:
956 return None
957 if self._date is None:
958 self._date = "010180"
959 try:
960 return dateutil.parser.parse(
961 "{0} {1}".format(self._date, self._time), dayfirst=True
962 )
963 except ValueError:
964 self.logger.error(f"GPSError: bad date string {self.gps_string}")
965 return None
967 @property
968 def declination(self) -> float | None:
969 """
970 Magnetic declination in degrees from true north.
972 Returns
973 -------
974 float or None
975 Magnetic declination in degrees. Positive values indicate
976 eastward declination, negative values indicate westward
977 declination. Returns None if declination data is not available.
979 Notes
980 -----
981 Magnetic declination is only available in GPRMC messages.
982 GPGGA messages will return None as they don't contain declination data.
984 Western declination values are automatically converted to negative.
986 Examples
987 --------
988 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
989 >>> gps.declination
990 13.1
991 """
992 if self._declination is None or self._declination_hemisphere is None:
993 return None
994 dec = float(self._declination)
995 if "w" in self._declination_hemisphere.lower():
996 dec *= -1
997 return dec
999 @property
1000 def gps_type(self) -> str | None:
1001 """
1002 GPS message type.
1004 Returns
1005 -------
1006 str or None
1007 GPS message type: "GPRMC" or "GPGGA", or None if not set.
1009 Examples
1010 --------
1011 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
1012 >>> gps.gps_type
1013 'GPRMC'
1014 """
1015 return self._type
1017 @property
1018 def fix(self) -> str | None:
1019 """
1020 GPS fix status.
1022 Returns
1023 -------
1024 str or None
1025 GPS fix status (typically "A" for valid fix), or None
1026 if fix information is not available or not applicable for
1027 the message type.
1029 Notes
1030 -----
1031 Fix status is typically available in GPRMC messages:
1032 - "A": Valid fix
1033 - "V": Invalid fix
1035 GPGGA messages use different fix quality indicators.
1037 Examples
1038 --------
1039 >>> gps = GPS("GPRMC,183511,A,3443.6098,N,11544.1007,W,000.0,000.0,260919,013.1,E*")
1040 >>> gps.fix
1041 'A'
1042 """
1043 if hasattr(self, "_fix"):
1044 return self._fix
1045 return None