Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mth5 \ mth5 \ io \ nims \ header.py: 99%

130 statements  

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

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

2""" 

3Created on Thu Sep 1 12:57:32 2022 

4 

5@author: jpeacock 

6""" 

7 

8from __future__ import annotations 

9 

10# ============================================================================= 

11# Imports 

12# ============================================================================= 

13from pathlib import Path 

14from typing import Optional, Union 

15 

16import dateutil 

17from loguru import logger 

18from mt_metadata.common import Comment, MTime 

19 

20 

21# ============================================================================= 

22class NIMSError(Exception): 

23 pass 

24 

25 

26class NIMSHeader: 

27 """ 

28 Class to hold NIMS header information. 

29 

30 This class parses and stores header information from NIMS DATA.BIN files. 

31 The header contains metadata about the measurement site, equipment setup, 

32 GPS coordinates, electrode configuration, and other survey parameters. 

33 

34 Parameters 

35 ---------- 

36 fn : str or Path, optional 

37 Path to the NIMS file to read, by default None 

38 

39 Attributes 

40 ---------- 

41 fn : Path or None 

42 Path to the NIMS file 

43 site_name : str or None 

44 Name of the measurement site 

45 state_province : str or None 

46 State or province of the measurement location 

47 country : str or None 

48 Country of the measurement location 

49 box_id : str or None 

50 System box identifier 

51 mag_id : str or None 

52 Magnetometer head identifier 

53 ex_length : float or None 

54 North-South electric field wire length in meters 

55 ex_azimuth : float or None 

56 North-South electric field wire heading in degrees 

57 ey_length : float or None 

58 East-West electric field wire length in meters 

59 ey_azimuth : float or None 

60 East-West electric field wire heading in degrees 

61 n_electrode_id : str or None 

62 North electrode identifier 

63 s_electrode_id : str or None 

64 South electrode identifier 

65 e_electrode_id : str or None 

66 East electrode identifier 

67 w_electrode_id : str or None 

68 West electrode identifier 

69 ground_electrode_info : str or None 

70 Ground electrode information 

71 header_gps_stamp : MTime or None 

72 GPS timestamp from header 

73 header_gps_latitude : float or None 

74 GPS latitude from header in decimal degrees 

75 header_gps_longitude : float or None 

76 GPS longitude from header in decimal degrees 

77 header_gps_elevation : float or None 

78 GPS elevation from header in meters 

79 operator : str or None 

80 Operator name 

81 comments : str or None 

82 Survey comments 

83 run_id : str or None 

84 Run identifier 

85 data_start_seek : int 

86 Byte position where data begins in file 

87 

88 Examples 

89 -------- 

90 A typical header looks like: 

91 

92 .. code-block:: 

93 

94 ''' 

95 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 

96 >>>user field>>>>>>>>>>>>>>>>>>>>>>>>>>>> 

97 SITE NAME: Budwieser Spring 

98 STATE/PROVINCE: CA 

99 COUNTRY: USA 

100 >>> The following code in double quotes is REQUIRED to start the NIMS << 

101 >>> The next 3 lines contain values required for processing <<<<<<<<<<<< 

102 >>> The lines after that are optional <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 

103 "300b" <-- 2CHAR EXPERIMENT CODE + 3 CHAR SITE CODE + RUN LETTER 

104 1105-3; 1305-3 <-- SYSTEM BOX I.D.; MAG HEAD ID (if different) 

105 106 0 <-- N-S Ex WIRE LENGTH (m); HEADING (deg E mag N) 

106 109 90 <-- E-W Ey WIRE LENGTH (m); HEADING (deg E mag N) 

107 1 <-- N ELECTRODE ID 

108 3 <-- E ELECTRODE ID 

109 2 <-- S ELECTRODE ID 

110 4 <-- W ELECTRODE ID 

111 Cu <-- GROUND ELECTRODE INFO 

112 GPS INFO: 26/09/19 18:29:29 34.7268 N 115.7350 W 939.8 

113 OPERATOR: KP 

114 COMMENT: N/S CRS: .95/.96 DCV: 3.5 ACV:1 

115 E/W CRS: .85/.86 DCV: 1.5 ACV: 1 

116 Redeployed site for run b b/c possible animal disturbance 

117 ''' 

118 """ 

119 

120 def __init__(self, fn: Optional[Union[str, Path]] = None) -> None: 

121 self.logger = logger 

122 self.fn = fn 

123 self._max_header_length = 1000 

124 self.header_dict = None 

125 self.site_name = None 

126 self.state_province = None 

127 self.country = None 

128 self.box_id = None 

129 self.mag_id = None 

130 self.ex_length = None 

131 self.ex_azimuth = None 

132 self.ey_length = None 

133 self.ey_azimuth = None 

134 self.n_electrode_id = None 

135 self.s_electrode_id = None 

136 self.e_electrode_id = None 

137 self.w_electrode_id = None 

138 self.ground_electrode_info = None 

139 self.header_gps_stamp = None 

140 self.header_gps_latitude = None 

141 self.header_gps_longitude = None 

142 self.header_gps_elevation = None 

143 self.operator = None 

144 self.comments = Comment() 

145 self.run_id = None 

146 self.data_start_seek = 0 

147 

148 @property 

149 def fn(self) -> Optional[Path]: 

150 """ 

151 Full path to NIMS file. 

152 

153 Returns 

154 ------- 

155 Path or None 

156 Path object representing the NIMS file location, 

157 or None if no file is set 

158 """ 

159 return self._fn 

160 

161 @fn.setter 

162 def fn(self, value: Optional[Union[str, Path]]) -> None: 

163 if value is not None: 

164 self._fn = Path(value) 

165 else: 

166 self._fn = None 

167 

168 @property 

169 def station(self) -> Optional[str]: 

170 """ 

171 Station ID derived from run ID. 

172 

173 Returns 

174 ------- 

175 str or None 

176 Station identifier (run ID without the last character), 

177 or None if run_id is not set 

178 

179 Notes 

180 ----- 

181 The station ID is typically the run ID with the last character 

182 (run letter) removed. 

183 """ 

184 if self.run_id is not None: 

185 return self.run_id[0:-1] 

186 

187 @property 

188 def file_size(self) -> Optional[int]: 

189 """ 

190 Size of the NIMS file in bytes. 

191 

192 Returns 

193 ------- 

194 int or None 

195 File size in bytes, or None if no file is set 

196 

197 Raises 

198 ------ 

199 FileNotFoundError 

200 If the file does not exist 

201 """ 

202 if self.fn is not None: 

203 return self.fn.stat().st_size 

204 

205 def read_header(self, fn: Optional[Union[str, Path]] = None) -> None: 

206 """ 

207 Read header information from a NIMS file. 

208 

209 This method reads and parses the header section of a NIMS DATA.BIN file, 

210 extracting metadata about the survey setup, GPS coordinates, electrode 

211 configuration, and other parameters. 

212 

213 Parameters 

214 ---------- 

215 fn : str or Path, optional 

216 Full path to NIMS file to read. Uses self.fn if not provided. 

217 

218 Raises 

219 ------ 

220 NIMSError 

221 If the file does not exist or cannot be read 

222 

223 Notes 

224 ----- 

225 The method reads up to _max_header_length bytes from the beginning 

226 of the file, parses the header information, and stores the results 

227 in the header_dict attribute and individual properties. 

228 """ 

229 if fn is not None: 

230 self.fn = fn 

231 if not self.fn.exists(): 

232 msg = f"Could not find nims file {self.fn}" 

233 self.logger.error(msg) 

234 raise NIMSError(msg) 

235 self.logger.debug(f"Reading NIMS file {self.fn}") 

236 

237 ### load in the entire file, its not too big 

238 with open(self.fn, "rb") as fid: 

239 header_str = fid.read(self._max_header_length) 

240 header_list = header_str.split(b"\r") 

241 self.header_dict = {} 

242 last_index = len(header_list) 

243 last_line = header_list[-1] 

244 for ii, line in enumerate(header_list[0:-1]): 

245 if ii == last_index: 

246 break 

247 if b"comments" in line.lower(): 

248 last_line = header_list[ii + 1] 

249 last_index = ii + 1 

250 line = line.decode() 

251 if line.find(">") == 0: 

252 continue 

253 elif line.find(":") > 0: 

254 key, value = line.split(":", 1) 

255 self.header_dict[key.strip().lower()] = value.strip() 

256 elif line.find("<--") > 0: 

257 value, key = line.split("<--") 

258 self.header_dict[key.strip().lower()] = value.strip() 

259 ### sometimes there are some spaces before the data starts 

260 if last_line.count(b" ") > 0: 

261 if last_line[0:1] == b" ": 

262 last_line = last_line.strip() 

263 else: 

264 last_line = last_line.split()[1].strip() 

265 data_start_byte = last_line[0:1] 

266 ### sometimes there are rogue $ around 

267 if data_start_byte in [b"$", b"g"]: 

268 data_start_byte = last_line[1:2] 

269 self.data_start_seek = header_str.find(data_start_byte) 

270 

271 self.parse_header_dict() 

272 

273 def parse_header_dict(self, header_dict: Optional[dict[str, str]] = None) -> None: 

274 """ 

275 Parse the header dictionary into individual attributes. 

276 

277 This method takes the raw header dictionary and extracts specific 

278 information into class attributes for easy access. 

279 

280 Parameters 

281 ---------- 

282 header_dict : dict of str, optional 

283 Dictionary containing header key-value pairs. Uses self.header_dict 

284 if not provided. 

285 

286 Notes 

287 ----- 

288 Parses various header fields including: 

289 - Wire lengths and azimuths for electric field measurements 

290 - System box and magnetometer IDs 

291 - GPS coordinates and timestamp 

292 - Run identifier 

293 - Other metadata fields 

294 """ 

295 if header_dict is not None: 

296 self.header_dict = header_dict 

297 assert isinstance(self.header_dict, dict) 

298 

299 for key, value in self.header_dict.items(): 

300 if "wire" in key: 

301 if key.find("n") == 0: 

302 self.ex_length = float(value.split()[0]) 

303 self.ex_azimuth = float(value.split()[1]) 

304 elif key.find("e") == 0: 

305 self.ey_length = float(value.split()[0]) 

306 self.ey_azimuth = float(value.split()[1]) 

307 elif "system" in key: 

308 self.box_id = value.split(";")[0].strip() 

309 self.mag_id = value.split(";")[1].strip() 

310 elif "gps" in key: 

311 gps_list = value.split() 

312 self.header_gps_stamp = MTime( 

313 time_stamp=dateutil.parser.parse( 

314 " ".join(gps_list[0:2]), dayfirst=True 

315 ).isoformat() 

316 ) 

317 self.header_gps_latitude = self._get_latitude(gps_list[2], gps_list[3]) 

318 self.header_gps_longitude = self._get_longitude( 

319 gps_list[4], gps_list[5] 

320 ) 

321 if gps_list[6] == "M": 

322 self.header_gps_elevation = 0.0 

323 else: 

324 self.header_gps_elevation = float(gps_list[6]) 

325 elif "run" in key: 

326 self.run_id = value.replace('"', "") 

327 else: 

328 setattr(self, key.replace(" ", "_").replace("/", "_"), value) 

329 

330 def _get_latitude(self, latitude: Union[str, float], hemisphere: str) -> float: 

331 """ 

332 Get latitude as decimal degrees with proper sign. 

333 

334 Parameters 

335 ---------- 

336 latitude : str or float 

337 Latitude value in decimal degrees 

338 hemisphere : str 

339 Hemisphere identifier ('N' for North, 'S' for South) 

340 

341 Returns 

342 ------- 

343 float 

344 Latitude in decimal degrees with proper sign 

345 (positive for North, negative for South) 

346 

347 Notes 

348 ----- 

349 Converts latitude to proper sign convention where North is positive 

350 and South is negative. 

351 """ 

352 if not isinstance(latitude, float): 

353 latitude = float(latitude) 

354 if hemisphere.lower() == "n": 

355 return latitude 

356 if hemisphere.lower() == "s": 

357 return -1 * latitude 

358 

359 def _get_longitude(self, longitude: Union[str, float], hemisphere: str) -> float: 

360 """ 

361 Get longitude as decimal degrees with proper sign. 

362 

363 Parameters 

364 ---------- 

365 longitude : str or float 

366 Longitude value in decimal degrees 

367 hemisphere : str 

368 Hemisphere identifier ('E' for East, 'W' for West) 

369 

370 Returns 

371 ------- 

372 float 

373 Longitude in decimal degrees with proper sign 

374 (positive for East, negative for West) 

375 

376 Notes 

377 ----- 

378 Converts longitude to proper sign convention where East is positive 

379 and West is negative. 

380 """ 

381 if not isinstance(longitude, float): 

382 longitude = float(longitude) 

383 if hemisphere.lower() == "e": 

384 return longitude 

385 if hemisphere.lower() == "w": 

386 return -1 * longitude