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

1# -*- coding: utf-8 -*- 

2""" 

3NIMS GPS data parser for magnetotelluric surveys. 

4 

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. 

8 

9Classes 

10------- 

11GPSError : Exception 

12 Custom exception for GPS parsing errors. 

13GPS : object 

14 Main class for parsing and validating GPS stamp data. 

15 

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 

21 

22Binary data contamination is automatically cleaned during parsing. 

23 

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}") 

30 

31Author 

32------ 

33jpeacock 

34 

35Created 

36------- 

37Thu Sep 1 11:43:56 2022 

38""" 

39# ============================================================================= 

40# Imports 

41# ============================================================================= 

42from __future__ import annotations 

43 

44import datetime 

45 

46import dateutil 

47from loguru import logger 

48 

49 

50# ============================================================================= 

51class GPSError(Exception): 

52 """ 

53 Custom exception for GPS parsing and validation errors. 

54 

55 Raised when GPS string parsing fails or when GPS data validation 

56 encounters invalid values. 

57 """ 

58 

59 

60class GPS: 

61 """ 

62 Parser for GPS stamps from NIMS magnetotelluric data. 

63 

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. 

67 

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. 

75 

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. 

88 

89 Notes 

90 ----- 

91 GPS message format differences: 

92 

93 **GPRMC (Recommended Minimum Course)** 

94 Contains: date, time, coordinates, speed, course, magnetic declination 

95 Date: Full date information (year, month, day) 

96 

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 

100 

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 

106 

107 Examples 

108 -------- 

109 Parse a GPRMC string: 

110 

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 

115 

116 Parse a GPGGA string: 

117 

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 

122 

123 Handle invalid GPS data: 

124 

125 >>> gps = GPS("invalid_string") 

126 >>> print(f"Valid: {gps.valid}") 

127 Valid: False 

128 """ 

129 

130 def __init__(self, gps_string: str | bytes, index: int = 0) -> None: 

131 self.logger = logger 

132 

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" 

147 

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) 

203 

204 def __str__(self) -> str: 

205 """ 

206 String representation of GPS object. 

207 

208 Returns 

209 ------- 

210 str 

211 Formatted string containing GPS type, coordinates, elevation, 

212 declination, and other key properties. 

213 

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 ] 

237 

238 return "\n".join(msg) 

239 

240 def __repr__(self) -> str: 

241 """Return string representation of GPS object.""" 

242 return self.__str__() 

243 

244 def validate_gps_string(self, gps_string: str | bytes) -> str | None: 

245 """ 

246 Validate and clean GPS string. 

247 

248 Removes binary contamination, finds string terminator, and validates 

249 format. Handles both string and bytes input. 

250 

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. 

256 

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. 

262 

263 Raises 

264 ------ 

265 TypeError 

266 If input is not string or bytes. 

267 

268 Notes 

269 ----- 

270 Binary contamination bytes that are automatically removed: 

271 - ``\\xd9``, ``\\xc7``, ``\\xcc`` 

272 - ``\\x00`` (null byte, replaced with '*' terminator) 

273 

274 The GPS string must end with '*' character to be considered valid. 

275 

276 Examples 

277 -------- 

278 Clean a contaminated binary GPS string: 

279 

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 

285 

286 Handle missing terminator: 

287 

288 >>> invalid = "GPRMC,183511,A,3443.6098,N" # No '*' 

289 >>> result = gps.validate_gps_string(invalid) 

290 >>> print(result) 

291 None 

292 """ 

293 

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"*") 

299 

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 ) 

319 

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. 

325 

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). 

332 

333 Returns 

334 ------- 

335 list of str 

336 GPS string components, or empty list if validation fails. 

337 

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(",") 

348 

349 def parse_gps_string(self, gps_string: str | bytes) -> None: 

350 """ 

351 Parse GPS string and populate object attributes. 

352 

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. 

356 

357 Parameters 

358 ---------- 

359 gps_string : str or bytes 

360 Raw GPS string from NIMS data file. 

361 

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 

370 

371 If any validation errors occur, they are logged but parsing continues 

372 with ``None`` values for invalid fields. 

373 

374 The method automatically detects GPS message type and applies 

375 appropriate field validation rules. 

376 

377 Examples 

378 -------- 

379 Parse a valid GPS string: 

380 

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 

385 

386 Handle invalid GPS string: 

387 

388 >>> gps.parse_gps_string("invalid_gps_data") 

389 >>> print(f"Valid: {gps.valid}") 

390 Valid: False 

391 """ 

392 

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()] 

413 

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 

419 

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. 

425 

426 Performs comprehensive validation of GPS message components including 

427 type checking, length validation, and field-specific validation. 

428 

429 Parameters 

430 ---------- 

431 gps_list : list of str 

432 GPS message components split by delimiter. 

433 

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. 

441 

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 

451 

452 Non-critical validation errors are collected but don't halt processing. 

453 Critical errors (type or length) return None and stop validation. 

454 

455 Examples 

456 -------- 

457 Validate a correct GPS list: 

458 

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 

465 

466 Handle validation errors: 

467 

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() 

481 

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 

531 

532 def _validate_gps_type(self, gps_list: list[str]) -> list[str]: 

533 """ 

534 Validate and auto-correct GPS message type. 

535 

536 Parameters 

537 ---------- 

538 gps_list : list of str 

539 GPS message components with type as first element. 

540 

541 Returns 

542 ------- 

543 list of str 

544 GPS list with corrected type and possibly extracted time data. 

545 

546 Raises 

547 ------ 

548 GPSError 

549 If GPS type cannot be identified as GPGGA or GPRMC variant. 

550 

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 

577 

578 def _validate_list_length(self, gps_list: list[str]) -> None: 

579 """ 

580 Validate GPS message length based on message type. 

581 

582 Parameters 

583 ---------- 

584 gps_list : list of str 

585 GPS message components. 

586 

587 Raises 

588 ------ 

589 GPSError 

590 If message length doesn't match expected length for the GPS type. 

591 

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 ) 

606 

607 def _validate_time(self, time_str: str) -> str: 

608 """ 

609 Validate GPS time string format. 

610 

611 Parameters 

612 ---------- 

613 time_str : str 

614 Time string in HHMMSS format. 

615 

616 Returns 

617 ------- 

618 str 

619 Validated time string. 

620 

621 Raises 

622 ------ 

623 GPSError 

624 If time string is not 6 characters or not numeric. 

625 

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 

642 

643 def _validate_date(self, date_str: str) -> str: 

644 """ 

645 Validate GPS date string format. 

646 

647 Parameters 

648 ---------- 

649 date_str : str 

650 Date string in DDMMYY format. 

651 

652 Returns 

653 ------- 

654 str 

655 Validated date string. 

656 

657 Raises 

658 ------ 

659 GPSError 

660 If date string is not 6 characters or not numeric. 

661 

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 

678 

679 def _validate_latitude(self, latitude_str: str, hemisphere_str: str) -> str: 

680 """ 

681 Validate latitude coordinate and hemisphere. 

682 

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'. 

689 

690 Returns 

691 ------- 

692 str 

693 Validated latitude string. 

694 

695 Raises 

696 ------ 

697 GPSError 

698 If latitude format is invalid, hemisphere is wrong length/value, 

699 or coordinate cannot be converted to float. 

700 

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 

706 

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 

732 

733 def _validate_longitude(self, longitude_str: str, hemisphere_str: str) -> str: 

734 """ 

735 Validate longitude coordinate and hemisphere. 

736 

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'. 

743 

744 Returns 

745 ------- 

746 str 

747 Validated longitude string. 

748 

749 Raises 

750 ------ 

751 GPSError 

752 If longitude format is invalid, hemisphere is wrong length/value, 

753 or coordinate cannot be converted to float. 

754 

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 

760 

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 

786 

787 def _validate_elevation(self, elevation_str: str) -> str: 

788 """ 

789 Validate elevation value and convert to standard format. 

790 

791 Parameters 

792 ---------- 

793 elevation_str : str 

794 Elevation string, may include 'M' or 'm' unit suffix. 

795 

796 Returns 

797 ------- 

798 str 

799 Validated elevation as string representation of float. 

800 

801 Raises 

802 ------ 

803 GPSError 

804 If elevation cannot be converted to float. 

805 

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 

811 

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 

830 

831 @property 

832 def latitude(self) -> float: 

833 """ 

834 Latitude in decimal degrees (WGS84). 

835 

836 Returns 

837 ------- 

838 float 

839 Latitude in decimal degrees. Negative values indicate 

840 Southern hemisphere. Returns 0.0 if coordinate data is invalid. 

841 

842 Notes 

843 ----- 

844 Converts from GPS format (DDMM.MMMM) to decimal degrees: 

845 decimal_degrees = degrees + minutes/60 

846 

847 Southern hemisphere coordinates are automatically converted to negative values. 

848 

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 

862 

863 @property 

864 def longitude(self) -> float: 

865 """ 

866 Longitude in decimal degrees (WGS84). 

867 

868 Returns 

869 ------- 

870 float 

871 Longitude in decimal degrees. Negative values indicate 

872 Western hemisphere. Returns 0.0 if coordinate data is invalid. 

873 

874 Notes 

875 ----- 

876 Converts from GPS format (DDDMM.MMMM) to decimal degrees: 

877 decimal_degrees = degrees + minutes/60 

878 

879 Western hemisphere coordinates are automatically converted to negative values. 

880 

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 

894 

895 @property 

896 def elevation(self) -> float: 

897 """ 

898 Elevation above sea level in meters. 

899 

900 Returns 

901 ------- 

902 float 

903 Elevation in meters. Returns 0.0 if elevation data is not 

904 available or cannot be converted. 

905 

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. 

910 

911 Conversion errors are logged but don't raise exceptions. 

912 

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 

928 

929 @property 

930 def time_stamp(self) -> datetime.datetime | None: 

931 """ 

932 GPS timestamp as datetime object. 

933 

934 Returns 

935 ------- 

936 datetime.datetime or None 

937 Timestamp parsed from GPS data, or None if time data is invalid. 

938 

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 

943 

944 Time format: HHMMSS (hours, minutes, seconds) 

945 Date format: DDMMYY (day, month, 2-digit year) 

946 

947 Invalid date strings are logged but return None rather than raising exceptions. 

948 

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 

966 

967 @property 

968 def declination(self) -> float | None: 

969 """ 

970 Magnetic declination in degrees from true north. 

971 

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. 

978 

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. 

983 

984 Western declination values are automatically converted to negative. 

985 

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 

998 

999 @property 

1000 def gps_type(self) -> str | None: 

1001 """ 

1002 GPS message type. 

1003 

1004 Returns 

1005 ------- 

1006 str or None 

1007 GPS message type: "GPRMC" or "GPGGA", or None if not set. 

1008 

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 

1016 

1017 @property 

1018 def fix(self) -> str | None: 

1019 """ 

1020 GPS fix status. 

1021 

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. 

1028 

1029 Notes 

1030 ----- 

1031 Fix status is typically available in GPRMC messages: 

1032 - "A": Valid fix 

1033 - "V": Invalid fix 

1034 

1035 GPGGA messages use different fix quality indicators. 

1036 

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