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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-10 00:01 -0800
1import struct
2from pathlib import Path
4from loguru import logger
5from mt_metadata.timeseries import Electric, Magnetic, Run, Station, Survey
8class MTUTable:
9 """
10 =======================================================================
11 DECODING METHOD FOR TBL VALUES:
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)
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
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.
27 Example usage:
28 # Automatic decoding:
29 tbl_dict = get_dictionary_from_tbl('file.TBL', decode_values=True)
31 # Manual decoding with read_tbl (legacy):
32 info = read_tbl('/path', 'file.TBL')
34 =======================================================================
35 original comments from MATLAB script:
37 read_tbl - reads a (binary) TBL table file of the legacy Phoenix format
38 (MTU-5A) and output the "info" metadata dictionary.
40 Parameters:
41 fpath: path to the tbl
42 fname: name of the tbl file (including extensions)
44 Returns:
45 info: output dict of the TBL metadata
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.).
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.
105 Original author:
106 Hao
107 2012.07.04
108 Beijing
110 Translated to Python and enhanced by:
111 J. Peacock (2025-12-31)
113 Main changes:
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 """
121 def __init__(self, file_path: str | Path | None = None, **kwargs) -> None:
122 """
123 Initialize MTUTable reader.
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.
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] = {}
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 }
213 for key, value in kwargs.items():
214 setattr(self, key, value)
216 if self.file_path:
217 self.read_tbl()
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.
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'.
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.
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
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.
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.
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').
291 Notes
292 -----
293 This method reads the entire TBL file in 25-byte blocks, extracting
294 key-value pairs. Each block contains:
296 - Bytes 0-11: Tag name (null-terminated string)
297 - Bytes 12-24: Value (13 bytes in various formats)
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 = {}
308 with open(file_path, "rb") as fid:
309 while True:
310 block = fid.read(25)
311 if len(block) < 25:
312 break
314 # Extract key from first 12 bytes
315 key = block[0:12].decode("latin-1").split("\x00")[0].strip()
317 # Skip empty keys
318 if not key:
319 continue
321 # Extract value from last 13 bytes
322 value_bytes = block[12:]
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
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
354 return tbl_dict
356 def read_tbl(self) -> None:
357 """
358 Read and decode the TBL file, populating the tbl_dict attribute.
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`.
364 Returns
365 -------
366 None
367 Results are stored in the `tbl_dict` attribute.
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}")
391 self.tbl_dict = self._get_dictionary_from_tbl(
392 self.file_path, decode_values=True
393 )
395 def _has_metadata(self) -> bool:
396 """
397 Check if TBL metadata has been loaded.
399 Returns
400 -------
401 bool
402 True if tbl_dict is populated, False otherwise.
403 """
404 return bool(self.tbl_dict)
406 def _read_latitude(self, lat_str: str) -> float:
407 """
408 Convert latitude from degree-minute format to decimal degrees.
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.
416 Returns
417 -------
418 float
419 Latitude in decimal degrees. Negative for Southern hemisphere.
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
436 return value * hemisphere
438 except Exception as e:
439 logger.warning(f"Failed to parse latitude '{lat_str}': {e}")
440 return 0.0
442 def _read_longitude(self, lon_str: str) -> float:
443 """
444 Convert longitude from degree-minute format to decimal degrees.
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.
452 Returns
453 -------
454 float
455 Longitude in decimal degrees. Negative for Western hemisphere.
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
472 return value * hemisphere
474 except Exception as e:
475 logger.warning(f"Failed to parse longitude '{lon_str}': {e}")
476 return 0.0
478 @property
479 def channel_keys(self) -> dict[str, int]:
480 """
481 Get list of channel keys present in the TBL metadata.
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.).
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
502 @property
503 def survey_metadata(self) -> Survey:
504 """
505 Extract survey metadata from TBL file.
507 Returns
508 -------
509 Survey
510 mt_metadata Survey object populated with survey-level information
511 from the TBL file (survey ID, company/author).
513 Notes
514 -----
515 If TBL metadata has not been loaded (via `read_tbl()`), returns an
516 empty Survey object with a warning.
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)
536 return survey
538 @property
539 def station_metadata(self) -> Station:
540 """
541 Extract station metadata from TBL file.
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.
550 Notes
551 -----
552 If TBL metadata has not been loaded (via `read_tbl()`), returns an
553 empty Station object with a warning.
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)
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")
586 # runs
587 station.add_run(self.run_metadata)
588 # Populate station metadata from tbl_dict as needed
589 return station
591 @property
592 def run_metadata(self) -> Run:
593 """
594 Extract run metadata from TBL file.
596 Returns
597 -------
598 Run
599 mt_metadata Run object populated with data logger information
600 and channel metadata.
602 Notes
603 -----
604 If TBL metadata has not been loaded (via `read_tbl()`), returns an
605 empty Run object with a warning.
607 The run includes all channel metadata (ex, ey, hx, hy, hz) obtained
608 from their respective property methods.
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)
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()
642 return run
644 @property
645 def ex_metadata(self) -> Electric:
646 """
647 Extract Ex electric channel metadata from TBL file.
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.
655 Notes
656 -----
657 If TBL metadata has not been loaded (via `read_tbl()`), returns an
658 empty Electric object with a warning.
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
688 @property
689 def ey_metadata(self) -> Electric:
690 """
691 Extract Ey electric channel metadata from TBL file.
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.
699 Notes
700 -----
701 If TBL metadata has not been loaded (via `read_tbl()`), returns an
702 empty Electric object with a warning.
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
732 @property
733 def hx_metadata(self) -> Magnetic:
734 """
735 Extract Hx magnetic channel metadata from TBL file.
737 Returns
738 -------
739 Magnetic
740 mt_metadata Magnetic object for Hx component with maximum field,
741 channel number, azimuth, and sensor serial number.
743 Notes
744 -----
745 If TBL metadata has not been loaded (via `read_tbl()`), returns an
746 empty Magnetic object with a warning.
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
776 @property
777 def hy_metadata(self) -> Magnetic:
778 """
779 Extract Hy magnetic channel metadata from TBL file.
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.
787 Notes
788 -----
789 If TBL metadata has not been loaded (via `read_tbl()`), returns an
790 empty Magnetic object with a warning.
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
820 @property
821 def hz_metadata(self) -> Magnetic:
822 """
823 Extract Hz magnetic channel metadata from TBL file.
825 Returns
826 -------
827 Magnetic
828 mt_metadata Magnetic object for Hz component with maximum field,
829 channel number, and sensor serial number.
831 Notes
832 -----
833 If TBL metadata has not been loaded (via `read_tbl()`), returns an
834 empty Magnetic object with a warning.
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
863 @property
864 def ex_calibration(self) -> float | None:
865 """
866 Calculate Ex channel calibration factor.
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.
874 Notes
875 -----
876 The calibration factor is calculated as:
878 .. math::
879 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{EGN}} \\times \\frac{1000}{\\text{EXLN}}
881 where:
883 - FSCV: Full-scale converter voltage
884 - EGN: Electric channel gain
885 - EXLN: Ex dipole length in meters
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 """
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 )
911 @property
912 def ey_calibration(self) -> float | None:
913 """
914 Calculate Ey channel calibration factor.
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.
922 Notes
923 -----
924 The calibration factor is calculated as:
926 .. math::
927 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{EGN}} \\times \\frac{1000}{\\text{EYLN}}
929 where:
931 - FSCV: Full-scale converter voltage
932 - EGN: Electric channel gain
933 - EYLN: Ey dipole length in meters
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 """
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 )
959 @property
960 def magnetic_calibration(self) -> float | None:
961 """
962 Calculate magnetic channel calibration factor.
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.
970 Notes
971 -----
972 The calibration factor is calculated as:
974 .. math::
975 \\text{cal} = \\frac{\\text{FSCV}}{2^{23}} \\times \\frac{1000}{\\text{HGN} \\times \\text{HATT} \\times \\text{HNOM}}
977 where:
979 - FSCV: Full-scale converter voltage
980 - HGN: Magnetic channel gain
981 - HATT: Magnetic channel attenuation
982 - HNOM: Magnetic channel normalization (mA/nT)
984 This calibration applies to all magnetic channels (Hx, Hy, Hz).
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 """
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 )