Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mth5 \ mth5 \ io \ phoenix \ readers \ mtu \ mtu_table.py: 94%

228 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-10 00:01 -0800

1import struct 

2from pathlib import Path 

3 

4from loguru import logger 

5from mt_metadata.timeseries import Electric, Magnetic, Run, Station, Survey 

6 

7 

8class MTUTable: 

9 """ 

10 ======================================================================= 

11 DECODING METHOD FOR TBL VALUES: 

12 

13 The Phoenix TBL file is a series of 25-byte blocks containing key-value pairs: 

14 - Bytes 0-11: Tag name (4-character string, null-padded) 

15 - Bytes 12-24: Value (13 bytes, mixed data types) 

16 

17 Values can be decoded as follows: 

18 1. INT (4 bytes): struct.unpack('<i', bytes[0:4]) - Little-endian signed int 

19 2. DOUBLE (8 bytes): struct.unpack('<d', bytes[0:8]) - Little-endian double 

20 3. CHAR (variable): bytes.decode('latin-1').strip() - Null-terminated string 

21 4. BYTE (1 byte): struct.unpack('<B', bytes[0:1]) - Unsigned byte 

22 5. TIME (6 bytes): [sec, min, hour, day, month, year-2000] format 

23 

24 The TBL_TAG_TYPES dictionary maps each known tag to its data type, enabling 

25 automatic decoding via decode_tbl_value() function. Unknown tags return raw bytes. 

26 

27 Example usage: 

28 # Automatic decoding: 

29 tbl_dict = get_dictionary_from_tbl('file.TBL', decode_values=True) 

30 

31 # Manual decoding with read_tbl (legacy): 

32 info = read_tbl('/path', 'file.TBL') 

33 

34 ======================================================================= 

35 original comments from MATLAB script: 

36 

37 read_tbl - reads a (binary) TBL table file of the legacy Phoenix format 

38 (MTU-5A) and output the "info" metadata dictionary. 

39 

40 Parameters: 

41 fpath: path to the tbl 

42 fname: name of the tbl file (including extensions) 

43 

44 Returns: 

45 info: output dict of the TBL metadata 

46 

47 ======================================================================= 

48 definition of the TBL tags (or what I guessed after reading the user 

49 manual and fiddling with their files) 

50 SITE: site name 

51 SNUM: serial number (of the box) 

52 FILE: file name recorded 

53 CMPY: company/institute of the survey 

54 SRVY: survey project name 

55 EXLN: Ex channel dipole length 

56 EYLN: Ey channel dipole length 

57 NREF: North reference (true, or magnetic north) 

58 LNGG: longitude in degree-minute format (DDD MM.MM) 

59 LATG: latitude in degree-minute format (DD MM.MM) 

60 ELEV: elevation (in metres) 

61 HXSN: Hx channel coil serial number 

62 HYSN: Hy channel coil serial number 

63 HZSN: Hz channel coil serial number 

64 STIM: starting time (UTC) 

65 ETIM: ending time (UTC) 

66 LFRQ: powerline frequency for filtering (can only be 50 or 60 Hz) 

67 HGN: final H-channel gain 

68 HGNC: H-channel gain control: HGN = PA * 2^HGNC (note: PA = 

69 PreAmplifier gain) 

70 EGN: final E-channel gain 

71 EGNC: E-channel gain control: HGN = PA * 2^HGNC (note: PA = 

72 PreAmplifier gain) 

73 HSMP: L3 and L4 time slot in second (MTU-5A) or minute (MTU-5P), 

74 this means the instrument will record L3NS seconds for L3 and L4NS 

75 seconds for L4, for every HSMP time slot. 

76 L3NS: L3 sample time (in second) 

77 L4NS: L4 sample time (in second) 

78 SRL3: L3 sample rate 

79 SRL4: L4 sample rate 

80 SRL5: L5 sample rate 

81 HATT: H channel attenuation (1/4.3 for MTU-5A) 

82 HNOM: H channel normalization (mA/nT) 

83 TCMB: Type of comb filter (probably used to suppress the harmonics of the 

84 powerline noise. 

85 TALS: Type of anti-aliasing filter 

86 LPFR: Parameter of Low-pass/VLF filter. this is a quite complicated 

87 part as the low-pass filter is simply an R-C circuit with a switch 

88 to connect to different capacitors. To ensure enough bandwidth 

89 (proportion to 1/RC), one should use smaller capacitors with larger 

90 ground resistance. 

91 ACDC: AC/DC coupling (DC = 0, AC = 1; MT should always be DC) 

92 FSCV: full scaling A-D converter voltage (in unit of V) 

93 ======================================================================= 

94 note: 

95 Phoenix Legacy TBL is a straight-forward parameter-value metadata file, 

96 stored in a bizarre format. The parameter tag and value are stored in a 

97 series of 25-byte data blocks, in mixed data type: the first 12 bytes are 

98 reserved for the tag name (first 4 bytes as char). The values are stored 

99 in the 13 bytes afterwards, in various formats (char, int, float, etc.). 

100 

101 So a good practice is to read in those blocks one by one and extract all 

102 of them. However, not every thing is useful for the metadata, so I only 

103 extract a few of them, for now. 

104 

105 Original author: 

106 Hao 

107 2012.07.04 

108 Beijing 

109 

110 Translated to Python and enhanced by: 

111 J. Peacock (2025-12-31) 

112 

113 Main changes: 

114 

115 - Encapsulated in MTUTable class 

116 - Automatic type detection and decoding based on TBL_TAG_TYPES 

117 - Added properties to extract metadata as mt_metadata objects 

118 ======================================================================= 

119 """ 

120 

121 def __init__(self, file_path: str | Path | None = None, **kwargs) -> None: 

122 """ 

123 Initialize MTUTable reader. 

124 

125 Parameters 

126 ---------- 

127 file_path : str or Path, optional 

128 Path to the TBL file including the file name. If not provided, the object can be initialized without a file. 

129 

130 Examples 

131 -------- 

132 >>> tbl = MTUTable('/data/phoenix/1690C16C.TBL') 

133 >>> tbl.read_tbl() 

134 >>> print(tbl.tbl_dict['SITE']) 

135 '10441W10' 

136 """ 

137 self.file_path = Path(file_path) if file_path else None 

138 self.tbl_dict: dict[str, int | float | str | bytes] = {} 

139 

140 # TBL tag data type mapping 

141 # Format: 'TAG': ('type', description) 

142 # Types: 'int', 'double', 'char', 'byte', 'time' 

143 self.TBL_TAG_TYPES = { 

144 "SNUM": ("int", "Serial number"), 

145 "SITE": ("char", "Site name"), 

146 "FILE": ("char", "File name recorded"), 

147 "FLEN": ("int", "File length in bytes"), 

148 "FTIM": ("time", "File creation time UTC"), 

149 "CMPY": ("char", "Company/institute"), 

150 "SRVY": ("char", "Survey project name"), 

151 "LATG": ("char", "Latitude in degree-minute format"), 

152 "LNGG": ("char", "Longitude in degree-minute format"), 

153 "ELEV": ("int", "Elevation in metres"), 

154 "NREF": ("int", "North reference"), 

155 "STIM": ("time", "Starting time UTC"), 

156 "ETIM": ("time", "Ending time UTC"), 

157 "EXLN": ("double", "Ex channel dipole length"), 

158 "EYLN": ("double", "Ey channel dipole length"), 

159 "HXSN": ("char", "Hx channel coil serial number"), 

160 "HYSN": ("char", "Hy channel coil serial number"), 

161 "HZSN": ("char", "Hz channel coil serial number"), 

162 "EAZM": ("double", "E azimuth"), 

163 "HAZM": ("double", "H azimuth"), 

164 "HTIM": ("time", "H channel calibration time"), 

165 "HSMP": ("int", "L3 and L4 time slot"), 

166 "L3NS": ("int", "L3 sample time in seconds"), 

167 "L4NS": ("int", "L4 sample time in seconds"), 

168 "LTIME": ("time", "L channel calibration time ?"), 

169 "SRL3": ("int", "L3 sample rate"), 

170 "SRL4": ("int", "L4 sample rate"), 

171 "SRL5": ("int", "L5 sample rate"), 

172 "LFRQ": ("byte", "Powerline frequency"), 

173 "EGNC": ("int", "E-channel gain control"), 

174 "HGNC": ("int", "H-channel gain control"), 

175 "EGN": ("int", "Final E-channel gain"), 

176 "HGN": ("int", "Final H-channel gain"), 

177 "HATT": ("double", "H channel attenuation"), 

178 "HNOM": ("double", "H channel normalization mA/nT"), 

179 "TCMB": ("byte", "Type of comb filter"), 

180 "TALS": ("byte", "Type of anti-aliasing filter"), 

181 "LPFR": ("byte", "Low-pass/VLF filter parameter"), 

182 "ACDC": ("byte", "AC/DC coupling"), 

183 "FSCV": ("double", "Full scaling A-D converter voltage"), 

184 "TEMP": ("int", "Temperature"), 

185 "TERR": ("int", "Temperature error"), 

186 "V5SR": ("int", "MTU-5 serial number"), 

187 "NSAT": ("int", "Number of GPS satellites"), 

188 # Additional tags that may appear in TBL files 

189 "DECL": ("double", "Declination"), 

190 "TSTV": ("double", "Test voltage"), 

191 "VER": ("char", "Version"), 

192 "HW": ("16s", "Hardware"), 

193 "EXAC": ("double", "Ex AC"), 

194 "EXDC": ("double", "Ex DC"), 

195 "EYAC": ("double", "Ey AC"), 

196 "EYDC": ("double", "Ey DC"), 

197 "HXAC": ("double", "Hx AC"), 

198 "HXDC": ("double", "Hx DC"), 

199 "HYAC": ("double", "Hy AC"), 

200 "HYDC": ("double", "Hy DC"), 

201 "HZAC": ("double", "Hz AC"), 

202 "HZDC": ("double", "Hz DC"), 

203 "STDE": ("double", "Standard error in Electric channels"), 

204 "STDH": ("double", "Standard error in Magnetic channels"), 

205 "SPTH": ("char", "system path"), 

206 "CHEX": ("int", "EX channel type"), 

207 "CHEY": ("int", "EY channel type"), 

208 "CHHX": ("int", "HX channel type"), 

209 "CHHY": ("int", "HY channel type"), 

210 "CHHZ": ("int", "HZ channel type"), 

211 } 

212 

213 for key, value in kwargs.items(): 

214 setattr(self, key, value) 

215 

216 if self.file_path: 

217 self.read_tbl() 

218 

219 def decode_tbl_value( 

220 self, value_bytes: bytes, data_type: str 

221 ) -> int | float | str | bytes: 

222 """ 

223 Decode TBL value bytes based on the specified data type. 

224 

225 Parameters 

226 ---------- 

227 value_bytes : bytes 

228 13 bytes from position 12-24 in the 25-byte block containing the value. 

229 data_type : str 

230 Type of the data: 'int', 'double', 'char', 'byte', or 'time'. 

231 

232 Returns 

233 ------- 

234 int or float or str or bytes 

235 Decoded value in appropriate Python type. Returns raw bytes if 

236 decoding fails or data_type is unrecognized. 

237 

238 Examples 

239 -------- 

240 >>> tbl = MTUTable('/data', 'file.TBL') 

241 >>> value = tbl.decode_tbl_value(b'\x9a\x06\x00\x00...', 'int') 

242 >>> print(value) 

243 1690 

244 """ 

245 if data_type == "int": 

246 return struct.unpack("<i", value_bytes[0:4])[0] 

247 elif data_type == "double": 

248 return struct.unpack("<d", value_bytes[0:8])[0] 

249 elif data_type == "char": 

250 return value_bytes.decode("latin-1").strip("\x00").strip() 

251 elif data_type == "byte": 

252 return struct.unpack("<B", value_bytes[0:1])[0] 

253 elif data_type == "time": 

254 # Time format: bytes are [sec, min, hour, day, month, year-2000] 

255 # Return formatted string: YYYY-MM-DD HH:MM:SS 

256 return f"20{value_bytes[5]:02}-{value_bytes[4]:02}-{value_bytes[3]:02}-T{value_bytes[2]:02}:{value_bytes[1]:02}:{value_bytes[0]:02}" 

257 elif data_type == "16s": 

258 # Pad to 16 bytes if needed (value_bytes is 13 bytes from TBL file) 

259 value_padded = value_bytes[:16].ljust(16, b"\x00") 

260 value_unpacked = struct.unpack("16s", value_padded)[0] # Read as bytes 

261 # Decode and truncate at first null byte 

262 return ( 

263 value_unpacked.split(b"\x00", 1)[0] 

264 .decode("ascii", errors="ignore") 

265 .strip() 

266 ) 

267 else: 

268 # Return raw bytes for unknown types 

269 return value_bytes 

270 

271 def _get_dictionary_from_tbl( 

272 self, file_path: Path, decode_values: bool = True 

273 ) -> dict[str, int | float | str | bytes]: 

274 """ 

275 Read TBL file and return a dictionary of all tag-value pairs. 

276 

277 Parameters 

278 ---------- 

279 file_path : Path 

280 Full path to the TBL file. 

281 decode_values : bool, default True 

282 If True, decode values according to TBL_TAG_TYPES mapping. 

283 If False, return raw bytes for all values. 

284 

285 Returns 

286 ------- 

287 dict[str, int | float | str | bytes] 

288 Dictionary with tag names as keys and decoded (or raw) values. 

289 Duplicate keys are handled by appending numeric suffixes (e.g., 'EGN_1', 'EGN_2'). 

290 

291 Notes 

292 ----- 

293 This method reads the entire TBL file in 25-byte blocks, extracting 

294 key-value pairs. Each block contains: 

295 

296 - Bytes 0-11: Tag name (null-terminated string) 

297 - Bytes 12-24: Value (13 bytes in various formats) 

298 

299 Examples 

300 -------- 

301 >>> tbl = MTUTable('/data', 'file.TBL') 

302 >>> data = tbl._get_dictionary_from_tbl(Path('/data/file.TBL')) 

303 >>> print(data['SNUM']) 

304 1690 

305 """ 

306 tbl_dict = {} 

307 

308 with open(file_path, "rb") as fid: 

309 while True: 

310 block = fid.read(25) 

311 if len(block) < 25: 

312 break 

313 

314 # Extract key from first 12 bytes 

315 key = block[0:12].decode("latin-1").split("\x00")[0].strip() 

316 

317 # Skip empty keys 

318 if not key: 

319 continue 

320 

321 # Extract value from last 13 bytes 

322 value_bytes = block[12:] 

323 

324 if decode_values: 

325 if key in self.TBL_TAG_TYPES.keys(): 

326 data_type = self.TBL_TAG_TYPES[key][0] 

327 else: 

328 data_type = "char" 

329 try: 

330 value = self.decode_tbl_value(value_bytes, data_type) 

331 except Exception as e: 

332 logger.warning( 

333 f"Failed to decode {key}: {e}, storing raw bytes" 

334 ) 

335 value = value_bytes 

336 else: 

337 # Store raw bytes for unknown tags or if decode_values is False 

338 value = value_bytes 

339 

340 # Handle duplicate keys by appending index 

341 if key in tbl_dict: 

342 # If this is the first duplicate, rename the original 

343 if f"{key}_1" not in tbl_dict: 

344 tbl_dict[f"{key}_1"] = tbl_dict[key] 

345 del tbl_dict[key] 

346 # Find next available index 

347 idx = 2 

348 while f"{key}_{idx}" in tbl_dict: 

349 idx += 1 

350 tbl_dict[f"{key}_{idx}"] = value 

351 else: 

352 tbl_dict[key] = value 

353 

354 return tbl_dict 

355 

356 def read_tbl(self) -> None: 

357 """ 

358 Read and decode the TBL file, populating the tbl_dict attribute. 

359 

360 This method reads the TBL file specified during initialization and 

361 decodes all tag-value pairs according to their known types. The 

362 results are stored in `self.tbl_dict`. 

363 

364 Returns 

365 ------- 

366 None 

367 Results are stored in the `tbl_dict` attribute. 

368 

369 Examples 

370 -------- 

371 >>> tbl = MTUTable('/data/phoenix', '1690C16C.TBL') 

372 >>> tbl.read_tbl() 

373 >>> print(tbl.tbl_dict['SITE']) 

374 '10441W10' 

375 >>> print(tbl.tbl_dict['SNUM']) 

376 1690 

377 """ 

378 if self.file_path is None: 

379 raise ValueError("file_path is not set. Cannot read TBL file.") 

380 elif self.file_path.is_file() is False: 

381 raise FileNotFoundError(f"TBL file not found: {self.file_path}") 

382 elif self.file_path.suffix.upper() != ".TBL": 

383 raise ValueError(f"Not a TBL file: {self.file_path}") 

384 elif self.file_path.stat().st_size == 0: 

385 raise ValueError(f"TBL file is empty: {self.file_path}") 

386 elif self.file_path.stat().st_size < 25: 

387 raise ValueError(f"TBL file is too small to be valid: {self.file_path}") 

388 elif self.file_path.exists() is False: 

389 raise FileNotFoundError(f"TBL file does not exist: {self.file_path}") 

390 

391 self.tbl_dict = self._get_dictionary_from_tbl( 

392 self.file_path, decode_values=True 

393 ) 

394 

395 def _has_metadata(self) -> bool: 

396 """ 

397 Check if TBL metadata has been loaded. 

398 

399 Returns 

400 ------- 

401 bool 

402 True if tbl_dict is populated, False otherwise. 

403 """ 

404 return bool(self.tbl_dict) 

405 

406 def _read_latitude(self, lat_str: str) -> float: 

407 """ 

408 Convert latitude from degree-minute format to decimal degrees. 

409 

410 Parameters 

411 ---------- 

412 lat_str : str 

413 Latitude string in format 'DDMM.MMM,H' where H is hemisphere (N/S). 

414 Example: '4100.388,N' represents 41° 00.388' North. 

415 

416 Returns 

417 ------- 

418 float 

419 Latitude in decimal degrees. Negative for Southern hemisphere. 

420 

421 Examples 

422 -------- 

423 >>> tbl = MTUTable('/data', 'file.TBL') 

424 >>> lat = tbl._read_latitude('4100.388,N') 

425 >>> print(f"{lat:.6f}") 

426 41.006467 

427 """ 

428 try: 

429 parts = lat_str.split(",", 1) 

430 value = float(parts[0]) / 100.0 

431 quadrant = parts[1] 

432 hemisphere = 1 

433 if quadrant.lower().startswith("s"): 

434 hemisphere = -1 

435 

436 return value * hemisphere 

437 

438 except Exception as e: 

439 logger.warning(f"Failed to parse latitude '{lat_str}': {e}") 

440 return 0.0 

441 

442 def _read_longitude(self, lon_str: str) -> float: 

443 """ 

444 Convert longitude from degree-minute format to decimal degrees. 

445 

446 Parameters 

447 ---------- 

448 lon_str : str 

449 Longitude string in format 'DDDMM.MMM,H' where H is hemisphere (E/W). 

450 Example: '10400.536,E' represents 104° 00.536' East. 

451 

452 Returns 

453 ------- 

454 float 

455 Longitude in decimal degrees. Negative for Western hemisphere. 

456 

457 Examples 

458 -------- 

459 >>> tbl = MTUTable('/data', 'file.TBL') 

460 >>> lon = tbl._read_longitude('10400.536,E') 

461 >>> print(f"{lon:.6f}") 

462 104.008933 

463 """ 

464 try: 

465 parts = lon_str.split(",", 1) 

466 value = float(parts[0]) / 100.0 

467 quadrant = parts[1] 

468 hemisphere = 1 

469 if quadrant.lower().startswith("w"): 

470 hemisphere = -1 

471 

472 return value * hemisphere 

473 

474 except Exception as e: 

475 logger.warning(f"Failed to parse longitude '{lon_str}': {e}") 

476 return 0.0 

477 

478 @property 

479 def channel_keys(self) -> dict[str, int]: 

480 """ 

481 Get list of channel keys present in the TBL metadata. 

482 

483 Returns 

484 ------- 

485 dict[str, int] 

486 Dictionary of channel keys and their corresponding values found in tbl_dict (e.g., 'CHEX', 'CHEY', 'CHHX', etc.). 

487 

488 Examples 

489 -------- 

490 >>> tbl = MTUTable('/data', 'file.TBL') 

491 >>> tbl.read_tbl() 

492 >>> keys = tbl.channel_keys 

493 >>> print(keys) 

494 {'ex': 1, 'ey': 2, 'hx': 3, 'hy': 4, 'hz': 5} 

495 """ 

496 channel_keys = {} 

497 for key in ["CHEX", "CHEY", "CHHX", "CHHY", "CHHZ"]: 

498 if key in self.tbl_dict: 

499 channel_keys[f"{key[2:].lower()}"] = self.tbl_dict[key] 

500 return channel_keys 

501 

502 @property 

503 def survey_metadata(self) -> Survey: 

504 """ 

505 Extract survey metadata from TBL file. 

506 

507 Returns 

508 ------- 

509 Survey 

510 mt_metadata Survey object populated with survey-level information 

511 from the TBL file (survey ID, company/author). 

512 

513 Notes 

514 ----- 

515 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

516 empty Survey object with a warning. 

517 

518 Examples 

519 -------- 

520 >>> tbl = MTUTable('/data', 'file.TBL') 

521 >>> tbl.read_tbl() 

522 >>> survey = tbl.survey_metadata 

523 >>> print(survey.id) 

524 'MT_Survey_2024' 

525 """ 

526 survey = Survey() # type: ignore 

527 if not self._has_metadata(): 

528 logger.warning( 

529 "No TBL metadata loaded. Call read_tbl() first. Returning empty Survey." 

530 ) 

531 else: 

532 survey.id = self.tbl_dict.get("SRVY", "Unknown_Survey") 

533 survey.acquired_by.author = self.tbl_dict.get("CMPY", "Unknown_Company") 

534 survey.add_station(self.station_metadata) 

535 

536 return survey 

537 

538 @property 

539 def station_metadata(self) -> Station: 

540 """ 

541 Extract station metadata from TBL file. 

542 

543 Returns 

544 ------- 

545 Station 

546 mt_metadata Station object populated with station-level information 

547 including location (latitude, longitude, elevation, declination) 

548 and time period. 

549 

550 Notes 

551 ----- 

552 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

553 empty Station object with a warning. 

554 

555 Examples 

556 -------- 

557 >>> tbl = MTUTable('/data', 'file.TBL') 

558 >>> tbl.read_tbl() 

559 >>> station = tbl.station_metadata 

560 >>> print(station.id) 

561 '10441W10' 

562 >>> print(f"{station.location.latitude:.6f}") 

563 41.006467 

564 """ 

565 station = Station() # type: ignore 

566 if not self._has_metadata(): 

567 logger.warning( 

568 "No TBL metadata loaded. Call read_tbl() first. Returning empty Station." 

569 ) 

570 else: 

571 station.id = self.tbl_dict.get("SITE", "Unknown_Site") 

572 # location 

573 station.location.elevation = self.tbl_dict.get("ELEV", 0.0) 

574 station.location.latitude = self._read_latitude( 

575 self.tbl_dict.get("LATG", "0.0,N") 

576 ) 

577 station.location.longitude = self._read_longitude( 

578 self.tbl_dict.get("LNGG", "0.0,E") 

579 ) 

580 station.location.declination.value = self.tbl_dict.get("DECL", 0.0) 

581 

582 # time 

583 station.time_period.start = self.tbl_dict.get("STIM", "1980-01-01T00:00:00") 

584 station.time_period.end = self.tbl_dict.get("ETIM", "1980-01-01T00:00:00") 

585 

586 # runs 

587 station.add_run(self.run_metadata) 

588 # Populate station metadata from tbl_dict as needed 

589 return station 

590 

591 @property 

592 def run_metadata(self) -> Run: 

593 """ 

594 Extract run metadata from TBL file. 

595 

596 Returns 

597 ------- 

598 Run 

599 mt_metadata Run object populated with data logger information 

600 and channel metadata. 

601 

602 Notes 

603 ----- 

604 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

605 empty Run object with a warning. 

606 

607 The run includes all channel metadata (ex, ey, hx, hy, hz) obtained 

608 from their respective property methods. 

609 

610 Examples 

611 -------- 

612 >>> tbl = MTUTable('/data', 'file.TBL') 

613 >>> tbl.read_tbl() 

614 >>> run = tbl.run_metadata 

615 >>> print(run.id) 

616 'run_1690' 

617 >>> print(run.data_logger.id) 

618 'MTU_1690' 

619 """ 

620 run = Run() # type: ignore 

621 if not self._has_metadata(): 

622 logger.warning( 

623 "No TBL metadata loaded. Call read_tbl() first. Returning empty Run." 

624 ) 

625 else: 

626 # Populate run metadata from tbl_dict as needed 

627 run.id = f"run_{self.tbl_dict.get('SNUM', 'Unknown')}" 

628 run.data_logger.id = f"MTU_{self.tbl_dict.get('SNUM', 'Unknown')}" 

629 run.data_logger.firmware.version = self.tbl_dict.get( 

630 "HW", "Unknown_Version" 

631 ) 

632 run.data_logger.timing_system.type = "GPS" 

633 run.data_logger.timing_system.n_satellites = self.tbl_dict.get("NSAT", 0) 

634 

635 run.add_channel(self.ex_metadata) 

636 run.add_channel(self.ey_metadata) 

637 run.add_channel(self.hx_metadata) 

638 run.add_channel(self.hy_metadata) 

639 run.add_channel(self.hz_metadata) 

640 run.update_time_period() 

641 

642 return run 

643 

644 @property 

645 def ex_metadata(self) -> Electric: 

646 """ 

647 Extract Ex electric channel metadata from TBL file. 

648 

649 Returns 

650 ------- 

651 Electric 

652 mt_metadata Electric object for Ex component with dipole length, 

653 azimuth, AC/DC start values, and channel number. 

654 

655 Notes 

656 ----- 

657 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

658 empty Electric object with a warning. 

659 

660 Examples 

661 -------- 

662 >>> tbl = MTUTable('/data', 'file.TBL') 

663 >>> tbl.read_tbl() 

664 >>> ex = tbl.ex_metadata 

665 >>> print(ex.dipole_length) 

666 100.0 

667 """ 

668 ex_channel = Electric(component="ex") # type: ignore 

669 if not self._has_metadata(): 

670 logger.warning( 

671 "No TBL metadata loaded. Call read_tbl() first. Returning empty EX channel." 

672 ) 

673 else: 

674 ex_channel.dipole_length = self.tbl_dict.get("EXLN", 0.0) 

675 ex_channel.measurement_azimuth = self.tbl_dict.get("EAZM", 0.0) 

676 ex_channel.ac.start = self.tbl_dict.get("EXAC", 0.0) 

677 ex_channel.dc.start = self.tbl_dict.get("EXDC", 0.0) 

678 ex_channel.channel_number = self.tbl_dict.get("CHEX", 4) 

679 ex_channel.time_period.start = self.tbl_dict.get( 

680 "STIM", "1980-01-01T00:00:00" 

681 ) 

682 ex_channel.time_period.end = self.tbl_dict.get( 

683 "ETIM", "1980-01-01T00:00:00" 

684 ) 

685 ex_channel.units = "digital counts" 

686 return ex_channel 

687 

688 @property 

689 def ey_metadata(self) -> Electric: 

690 """ 

691 Extract Ey electric channel metadata from TBL file. 

692 

693 Returns 

694 ------- 

695 Electric 

696 mt_metadata Electric object for Ey component with dipole length, 

697 azimuth (Ex azimuth + 90°), AC/DC start values, and channel number. 

698 

699 Notes 

700 ----- 

701 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

702 empty Electric object with a warning. 

703 

704 Examples 

705 -------- 

706 >>> tbl = MTUTable('/data', 'file.TBL') 

707 >>> tbl.read_tbl() 

708 >>> ey = tbl.ey_metadata 

709 >>> print(ey.dipole_length) 

710 100.0 

711 """ 

712 ey_channel = Electric(component="ey") # type: ignore 

713 if not self._has_metadata(): 

714 logger.warning( 

715 "No TBL metadata loaded. Call read_tbl() first. Returning empty EY channel." 

716 ) 

717 else: 

718 ey_channel.dipole_length = self.tbl_dict.get("EYLN", 0.0) 

719 ey_channel.measurement_azimuth = self.tbl_dict.get("EAZM", 0.0) + 90.0 

720 ey_channel.ac.start = self.tbl_dict.get("EYAC", 0.0) 

721 ey_channel.dc.start = self.tbl_dict.get("EYDC", 0.0) 

722 ey_channel.channel_number = self.tbl_dict.get("CHEY", 5) 

723 ey_channel.time_period.start = self.tbl_dict.get( 

724 "STIM", "1980-01-01T00:00:00" 

725 ) 

726 ey_channel.time_period.end = self.tbl_dict.get( 

727 "ETIM", "1980-01-01T00:00:00" 

728 ) 

729 ey_channel.units = "digital counts" 

730 return ey_channel 

731 

732 @property 

733 def hx_metadata(self) -> Magnetic: 

734 """ 

735 Extract Hx magnetic channel metadata from TBL file. 

736 

737 Returns 

738 ------- 

739 Magnetic 

740 mt_metadata Magnetic object for Hx component with maximum field, 

741 channel number, azimuth, and sensor serial number. 

742 

743 Notes 

744 ----- 

745 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

746 empty Magnetic object with a warning. 

747 

748 Examples 

749 -------- 

750 >>> tbl = MTUTable('/data', 'file.TBL') 

751 >>> tbl.read_tbl() 

752 >>> hx = tbl.hx_metadata 

753 >>> print(hx.sensor.id) 

754 'coil1693' 

755 """ 

756 hx_channel = Magnetic(component="hx") # type: ignore 

757 if not self._has_metadata(): 

758 logger.warning( 

759 "No TBL metadata loaded. Call read_tbl() first. Returning empty HX channel." 

760 ) 

761 else: 

762 # Note: HXAC is a single value, but h_field_max expects StartEndRange 

763 # Skipping assignment for now 

764 hx_channel.channel_number = self.tbl_dict.get("CHHX", 1) 

765 hx_channel.measurement_azimuth = self.tbl_dict.get("HAZM", 0.0) 

766 hx_channel.sensor.id = self.tbl_dict.get("HXSN", "Unknown_serial") 

767 hx_channel.time_period.start = self.tbl_dict.get( 

768 "STIM", "1980-01-01T00:00:00" 

769 ) 

770 hx_channel.time_period.end = self.tbl_dict.get( 

771 "ETIM", "1980-01-01T00:00:00" 

772 ) 

773 hx_channel.units = "digital counts" 

774 return hx_channel 

775 

776 @property 

777 def hy_metadata(self) -> Magnetic: 

778 """ 

779 Extract Hy magnetic channel metadata from TBL file. 

780 

781 Returns 

782 ------- 

783 Magnetic 

784 mt_metadata Magnetic object for Hy component with maximum field, 

785 channel number, azimuth (Hx azimuth + 90°), and sensor serial number. 

786 

787 Notes 

788 ----- 

789 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

790 empty Magnetic object with a warning. 

791 

792 Examples 

793 -------- 

794 >>> tbl = MTUTable('/data', 'file.TBL') 

795 >>> tbl.read_tbl() 

796 >>> hy = tbl.hy_metadata 

797 >>> print(hy.sensor.id) 

798 'coil1694' 

799 """ 

800 hy_channel = Magnetic(component="hy") # type: ignore 

801 if not self._has_metadata(): 

802 logger.warning( 

803 "No TBL metadata loaded. Call read_tbl() first. Returning empty HY channel." 

804 ) 

805 else: 

806 # Note: HYAC is a single value, but h_field_max expects StartEndRange 

807 # Skipping assignment for now 

808 hy_channel.channel_number = self.tbl_dict.get("CHHY", 2) 

809 hy_channel.measurement_azimuth = self.tbl_dict.get("HAZM", 0.0) + 90.0 

810 hy_channel.sensor.id = self.tbl_dict.get("HYSN", "Unknown_serial") 

811 hy_channel.time_period.start = self.tbl_dict.get( 

812 "STIM", "1980-01-01T00:00:00" 

813 ) 

814 hy_channel.time_period.end = self.tbl_dict.get( 

815 "ETIM", "1980-01-01T00:00:00" 

816 ) 

817 hy_channel.units = "digital counts" 

818 return hy_channel 

819 

820 @property 

821 def hz_metadata(self) -> Magnetic: 

822 """ 

823 Extract Hz magnetic channel metadata from TBL file. 

824 

825 Returns 

826 ------- 

827 Magnetic 

828 mt_metadata Magnetic object for Hz component with maximum field, 

829 channel number, and sensor serial number. 

830 

831 Notes 

832 ----- 

833 If TBL metadata has not been loaded (via `read_tbl()`), returns an 

834 empty Magnetic object with a warning. 

835 

836 Examples 

837 -------- 

838 >>> tbl = MTUTable('/data', 'file.TBL') 

839 >>> tbl.read_tbl() 

840 >>> hz = tbl.hz_metadata 

841 >>> print(hz.sensor.id) 

842 'coil1695' 

843 """ 

844 hz_channel = Magnetic(component="hz") # type: ignore 

845 if not self._has_metadata(): 

846 logger.warning( 

847 "No TBL metadata loaded. Call read_tbl() first. Returning empty HZ channel." 

848 ) 

849 else: 

850 # Note: HZAC is a single value, but h_field_max expects StartEndRange 

851 # Skipping assignment for now 

852 hz_channel.channel_number = self.tbl_dict.get("CHHZ", 3) 

853 hz_channel.sensor.id = self.tbl_dict.get("HZSN", "Unknown_serial") 

854 hz_channel.time_period.start = self.tbl_dict.get( 

855 "STIM", "1980-01-01T00:00:00" 

856 ) 

857 hz_channel.time_period.end = self.tbl_dict.get( 

858 "ETIM", "1980-01-01T00:00:00" 

859 ) 

860 hz_channel.units = "digital counts" 

861 return hz_channel 

862 

863 @property 

864 def ex_calibration(self) -> float | None: 

865 """ 

866 Calculate Ex channel calibration factor. 

867 

868 Returns 

869 ------- 

870 float or None 

871 Calibration factor to convert raw ADC values to mV/km. 

872 Returns None if TBL metadata has not been loaded. 

873 

874 Notes 

875 ----- 

876 The calibration factor is calculated as: 

877 

878 .. math:: 

879 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{EGN}} \\times \\frac{1000}{\\text{EXLN}} 

880 

881 where: 

882 

883 - FSCV: Full-scale converter voltage 

884 - EGN: Electric channel gain 

885 - EXLN: Ex dipole length in meters 

886 

887 Examples 

888 -------- 

889 >>> tbl = MTUTable('/data', 'file.TBL') 

890 >>> tbl.read_tbl() 

891 >>> cal = tbl.ex_calibration 

892 >>> print(f"{cal:.6f}") 

893 0.000762 

894 """ 

895 

896 if not self._has_metadata(): 

897 logger.warning( 

898 "No TBL metadata loaded. Call read_tbl() first. Returning None." 

899 ) 

900 return None 

901 # E field as mV/km 

902 return ( 

903 float(self.tbl_dict["FSCV"]) 

904 / 2**23 

905 * 1000 

906 / float(self.tbl_dict["EGN"]) 

907 / float(self.tbl_dict["EXLN"]) 

908 * 1000 

909 ) 

910 

911 @property 

912 def ey_calibration(self) -> float | None: 

913 """ 

914 Calculate Ey channel calibration factor. 

915 

916 Returns 

917 ------- 

918 float or None 

919 Calibration factor to convert raw ADC values to mV/km. 

920 Returns None if TBL metadata has not been loaded. 

921 

922 Notes 

923 ----- 

924 The calibration factor is calculated as: 

925 

926 .. math:: 

927 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{EGN}} \\times \\frac{1000}{\\text{EYLN}} 

928 

929 where: 

930 

931 - FSCV: Full-scale converter voltage 

932 - EGN: Electric channel gain 

933 - EYLN: Ey dipole length in meters 

934 

935 Examples 

936 -------- 

937 >>> tbl = MTUTable('/data', 'file.TBL') 

938 >>> tbl.read_tbl() 

939 >>> cal = tbl.ey_calibration 

940 >>> print(f"{cal:.6f}") 

941 0.000762 

942 """ 

943 

944 if not self._has_metadata(): 

945 logger.warning( 

946 "No TBL metadata loaded. Call read_tbl() first. Returning None." 

947 ) 

948 return None 

949 # E field as mV/km 

950 return ( 

951 float(self.tbl_dict["FSCV"]) 

952 / 2**23 

953 * 1000 

954 / float(self.tbl_dict["EGN"]) 

955 / float(self.tbl_dict["EYLN"]) 

956 * 1000 

957 ) 

958 

959 @property 

960 def magnetic_calibration(self) -> float | None: 

961 """ 

962 Calculate magnetic channel calibration factor. 

963 

964 Returns 

965 ------- 

966 float or None 

967 Calibration factor to convert raw ADC values to nT. 

968 Returns None if TBL metadata has not been loaded. 

969 

970 Notes 

971 ----- 

972 The calibration factor is calculated as: 

973 

974 .. math:: 

975 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{HGN} \\times \\text{HATT} \\times \\text{HNOM}} 

976 

977 where: 

978 

979 - FSCV: Full-scale converter voltage 

980 - HGN: Magnetic channel gain 

981 - HATT: Magnetic channel attenuation 

982 - HNOM: Magnetic channel normalization (mA/nT) 

983 

984 This calibration applies to all magnetic channels (Hx, Hy, Hz). 

985 

986 Examples 

987 -------- 

988 >>> tbl = MTUTable('/data', 'file.TBL') 

989 >>> tbl.read_tbl() 

990 >>> cal = tbl.magnetic_calibration 

991 >>> print(f"{cal:.9f}") 

992 0.000000229 

993 """ 

994 

995 if not self._has_metadata(): 

996 logger.warning( 

997 "No TBL metadata loaded. Call read_tbl() first. Returning None." 

998 ) 

999 return None 

1000 # H field as nT 

1001 return ( 

1002 float(self.tbl_dict["FSCV"]) 

1003 / 2**23 

1004 * 1000 

1005 / float(self.tbl_dict["HGN"]) 

1006 / float(self.tbl_dict["HATT"]) 

1007 / float(self.tbl_dict["HNOM"]) 

1008 )