Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mth5 \ mth5 \ io \ zen \ coil_response.py: 95%
100 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-27 20:09 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-27 20:09 -0800
1# -*- coding: utf-8 -*-
2"""
3Read an amtant.cal file provided by Zonge.
6Apparently, the file includes the 6th and 8th harmonic of the given frequency, which
7is a fancy way of saying f x 6 and f x 8.
10"""
11# =============================================================================
12# Imports
13# =============================================================================
14from pathlib import Path
15from typing import Any
17import numpy as np
18from loguru import logger
19from mt_metadata.common.mttime import MTime
20from mt_metadata.timeseries.filters import FrequencyResponseTableFilter
23# =============================================================================
24# Variables
25# =============================================================================
26class CoilResponse:
27 """Read ANT4 coil calibration files from Zonge (``amtant.cal``).
29 This class parses a Zonge antenna calibration file and exposes a
30 :class:`mt_metadata.timeseries.filters.FrequencyResponseTableFilter` for a
31 specified coil number.
33 Parameters
34 ----------
35 calibration_file : str | Path | None, optional
36 Path to the antenna calibration file. If provided the file will be
37 read during initialization, by default None.
38 angular_frequency : bool, optional
39 If True, reported frequencies will be converted to angular frequency
40 (rad/s), by default False.
42 Attributes
43 ----------
44 coil_calibrations : dict[str, numpy.ndarray]
45 Mapping of coil serial numbers to a structured numpy array containing
46 frequency, amplitude, and phase columns.
48 Examples
49 --------
50 >>> from mth5.mth5.io.zen.coil_response import CoilResponse
51 >>> cr = CoilResponse('amtant.cal')
52 >>> fap = cr.get_coil_response_fap(1234)
53 >>> print(fap.name)
55 """
57 def __init__(
58 self,
59 calibration_file: str | Path | None = None,
60 angular_frequency: bool = False,
61 ) -> None:
62 self.logger = logger
63 self.coil_calibrations: dict[str, np.ndarray] = {}
64 self._n_frequencies: int = 48
65 self.calibration_file = calibration_file
66 self.angular_frequency: bool = angular_frequency
67 if calibration_file:
68 # defer to read_antenna_file which handles path coercion
69 self.read_antenna_file()
70 self._extrapolate_values: dict[str, dict[str, Any]] = {
71 "low": {"frequency": 1e-10, "amplitude": 1e-8, "phase": np.pi / 2},
72 "high": {"frequency": 1e5, "amplitude": 1e-4, "phase": np.pi / 6},
73 }
74 self._low_frequency_cutoff: int = 250
76 @property
77 def calibration_file(self):
78 return self._calibration_fn
80 @calibration_file.setter
81 def calibration_file(self, fn: str | Path | None):
82 if fn is not None:
83 try:
84 self._calibration_fn = Path(fn)
85 except Exception as e:
86 self.logger.error(f"Cannot set calibration file path with: {e}")
87 self._calibration_fn = None
88 else:
89 self._calibration_fn = None
91 def file_exists(self) -> bool:
92 """
93 Check to make sure the file exists
95 Returns
96 -------
97 bool
98 True if the file exists, False if it does not
100 """
101 if self.calibration_file is None:
102 return False
103 return self.calibration_file.exists()
105 def read_antenna_file(
106 self, antenna_calibration_file: str | Path | None = None
107 ) -> None:
108 """Read a Zonge antenna calibration file and parse coil responses.
110 The expected file format contains blocks starting with an "antenna"
111 header line that provides the base frequency followed by lines with
112 coil serial number and amplitude/phase values for the 6th and 8th
113 harmonics.
115 Parameters
116 ----------
117 antenna_calibration_file : str | Path | None, optional
118 Optional path to the antenna calibration file. If provided, it
119 overrides the instance ``calibration_file``.
121 Notes
122 -----
123 Phase values in the file are expected in milliradians and are
124 converted to radians.
125 """
127 self.coil_calibrations = {}
128 if antenna_calibration_file is not None:
129 self.calibration_file = antenna_calibration_file
130 if self.calibration_file is None:
131 self.logger.error("No calibration file provided")
132 return
133 cal_dtype = [
134 ("frequency", float),
135 ("amplitude", float),
136 ("phase", float),
137 ]
139 with open(self.calibration_file, "r") as fid:
140 lines = fid.readlines()
142 ff = -2
143 for line in lines:
144 if "antenna" in line.lower():
145 f = float(line.split()[2].strip())
146 if self.angular_frequency:
147 f = 2 * np.pi * f
148 ff += 2
149 elif len(line.strip().split()) == 0:
150 continue
151 else:
152 line_list = line.strip().split()
153 ant = line_list[0]
154 amp6 = float(line_list[1])
155 phase6 = float(line_list[2]) / 1000.0
156 amp8 = float(line_list[3])
157 phase8 = float(line_list[4]) / 1000.0
159 if ant not in self.coil_calibrations:
160 self.coil_calibrations[ant] = np.zeros(
161 self._n_frequencies, dtype=cal_dtype
162 )
164 self.coil_calibrations[ant][ff] = (f * 6, amp6, phase6)
165 self.coil_calibrations[ant][ff + 1] = (f * 8, amp8, phase8)
167 def get_coil_response_fap(
168 self, coil_number: int | str, extrapolate: bool = True
169 ) -> FrequencyResponseTableFilter:
170 """
171 Read an amtant.cal file provided by Zonge.
174 Apparently, the file includes the 6th and 8th harmonic of the given frequency, which
175 is a fancy way of saying f * 6 and f * 8.
177 Parameters
178 ----------
179 coil_number : int or str
180 ANT4 4 digit serial number
181 extrapolate : bool, optional
182 If True, extrapolate the frequency response to low and high frequencies,
183 by default True
185 Returns
186 -------
187 FrequencyResponseTableFilter
188 Frequency look up table for the specified coil number.
190 Raises
191 ------
192 KeyError
193 If the coil number is not found in the calibration file.
195 Notes
196 -----
197 Ensure that the antenna calibration file has been read prior to calling
198 this method. This can be done by providing the calibration file during
199 initialization or by calling :meth:`read_antenna_file`.
201 """
203 # ensure calibrations are loaded
204 if not self.coil_calibrations:
205 self.read_antenna_file(self.calibration_file)
207 if self.has_coil_number(coil_number):
208 cal = self.coil_calibrations[str(int(coil_number))]
209 fap = FrequencyResponseTableFilter()
210 fap.frequencies = cal["frequency"]
211 fap.amplitudes = cal["amplitude"]
212 fap.phases = cal["phase"]
213 fap.units_out = "milliVolt"
214 fap.units_in = "nanoTesla"
215 fap.name = f"ant4_{coil_number}_response"
216 fap.instrument_type = "ANT4 induction coil"
217 fap.calibration_date = MTime(
218 time_stamp=self.calibration_file.stat().st_mtime
219 ).isoformat()
221 if extrapolate:
222 return self.extrapolate(fap)
223 return fap
225 self.logger.error(f"Could not find {coil_number} in {self.calibration_file}")
226 raise KeyError(f"Could not find {coil_number} in {self.calibration_file}")
228 def extrapolate(
229 self, fap: FrequencyResponseTableFilter
230 ) -> FrequencyResponseTableFilter:
231 """Extrapolate a frequency/amplitude/phase table using log-linear pads.
233 Parameters
234 ----------
235 fap : FrequencyResponseTableFilter
236 Frequency response object to extrapolate.
238 Returns
239 -------
240 FrequencyResponseTableFilter
241 A copy of ``fap`` with low- and high-frequency extrapolated
242 values appended.
243 """
245 if self._low_frequency_cutoff is not None:
246 index = np.where(fap.frequencies < 1.0 / self._low_frequency_cutoff)[0][-1]
247 else:
248 index = 0
250 new_fap = fap.copy()
251 new_fap.frequencies = np.append(
252 np.append(
253 [self._extrapolate_values["low"]["frequency"]], fap.frequencies[index:]
254 ),
255 self._extrapolate_values["high"]["frequency"],
256 )
257 new_fap.amplitudes = np.append(
258 np.append(
259 [self._extrapolate_values["low"]["amplitude"]], fap.amplitudes[index:]
260 ),
261 self._extrapolate_values["high"]["amplitude"],
262 )
263 new_fap.phases = np.append(
264 np.append([self._extrapolate_values["low"]["phase"]], fap.phases[index:]),
265 self._extrapolate_values["high"]["phase"],
266 )
268 return new_fap
270 def has_coil_number(self, coil_number: int | str | None) -> bool:
271 """
273 Test if coil number is in the antenna file
275 Parameters
276 ----------
277 coil_number : int or str or None
278 ANT4 serial number
280 Returns
281 -------
282 bool
283 True if the coil is found, False if it is not
285 """
286 if coil_number is None:
287 return False
288 if self.file_exists():
289 coil_number = str(int(float(coil_number)))
291 if coil_number in self.coil_calibrations.keys():
292 return True
293 self.logger.debug(
294 f"Could not find {coil_number} in {self.calibration_file}"
295 )
296 return False
297 return False