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

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

2""" 

3Read an amtant.cal file provided by Zonge. 

4 

5 

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. 

8 

9 

10""" 

11# ============================================================================= 

12# Imports 

13# ============================================================================= 

14from pathlib import Path 

15from typing import Any 

16 

17import numpy as np 

18from loguru import logger 

19from mt_metadata.common.mttime import MTime 

20from mt_metadata.timeseries.filters import FrequencyResponseTableFilter 

21 

22 

23# ============================================================================= 

24# Variables 

25# ============================================================================= 

26class CoilResponse: 

27 """Read ANT4 coil calibration files from Zonge (``amtant.cal``). 

28 

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. 

32 

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. 

41 

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. 

47 

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) 

54 

55 """ 

56 

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 

75 

76 @property 

77 def calibration_file(self): 

78 return self._calibration_fn 

79 

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 

90 

91 def file_exists(self) -> bool: 

92 """ 

93 Check to make sure the file exists 

94 

95 Returns 

96 ------- 

97 bool 

98 True if the file exists, False if it does not 

99 

100 """ 

101 if self.calibration_file is None: 

102 return False 

103 return self.calibration_file.exists() 

104 

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. 

109 

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. 

114 

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

120 

121 Notes 

122 ----- 

123 Phase values in the file are expected in milliradians and are 

124 converted to radians. 

125 """ 

126 

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 ] 

138 

139 with open(self.calibration_file, "r") as fid: 

140 lines = fid.readlines() 

141 

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 

158 

159 if ant not in self.coil_calibrations: 

160 self.coil_calibrations[ant] = np.zeros( 

161 self._n_frequencies, dtype=cal_dtype 

162 ) 

163 

164 self.coil_calibrations[ant][ff] = (f * 6, amp6, phase6) 

165 self.coil_calibrations[ant][ff + 1] = (f * 8, amp8, phase8) 

166 

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. 

172 

173 

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. 

176 

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 

184 

185 Returns 

186 ------- 

187 FrequencyResponseTableFilter 

188 Frequency look up table for the specified coil number. 

189 

190 Raises 

191 ------ 

192 KeyError 

193 If the coil number is not found in the calibration file. 

194 

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

200 

201 """ 

202 

203 # ensure calibrations are loaded 

204 if not self.coil_calibrations: 

205 self.read_antenna_file(self.calibration_file) 

206 

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

220 

221 if extrapolate: 

222 return self.extrapolate(fap) 

223 return fap 

224 

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

227 

228 def extrapolate( 

229 self, fap: FrequencyResponseTableFilter 

230 ) -> FrequencyResponseTableFilter: 

231 """Extrapolate a frequency/amplitude/phase table using log-linear pads. 

232 

233 Parameters 

234 ---------- 

235 fap : FrequencyResponseTableFilter 

236 Frequency response object to extrapolate. 

237 

238 Returns 

239 ------- 

240 FrequencyResponseTableFilter 

241 A copy of ``fap`` with low- and high-frequency extrapolated 

242 values appended. 

243 """ 

244 

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 

249 

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 ) 

267 

268 return new_fap 

269 

270 def has_coil_number(self, coil_number: int | str | None) -> bool: 

271 """ 

272 

273 Test if coil number is in the antenna file 

274 

275 Parameters 

276 ---------- 

277 coil_number : int or str or None 

278 ANT4 serial number 

279 

280 Returns 

281 ------- 

282 bool 

283 True if the coil is found, False if it is not 

284 

285 """ 

286 if coil_number is None: 

287 return False 

288 if self.file_exists(): 

289 coil_number = str(int(float(coil_number))) 

290 

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