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
« 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
5@author: jpeacock
6"""
8from __future__ import annotations
10# =============================================================================
11# Imports
12# =============================================================================
13from pathlib import Path
14from typing import Optional, Union
16import dateutil
17from loguru import logger
18from mt_metadata.common import Comment, MTime
21# =============================================================================
22class NIMSError(Exception):
23 pass
26class NIMSHeader:
27 """
28 Class to hold NIMS header information.
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.
34 Parameters
35 ----------
36 fn : str or Path, optional
37 Path to the NIMS file to read, by default None
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
88 Examples
89 --------
90 A typical header looks like:
92 .. code-block::
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 """
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
148 @property
149 def fn(self) -> Optional[Path]:
150 """
151 Full path to NIMS file.
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
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
168 @property
169 def station(self) -> Optional[str]:
170 """
171 Station ID derived from run ID.
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
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]
187 @property
188 def file_size(self) -> Optional[int]:
189 """
190 Size of the NIMS file in bytes.
192 Returns
193 -------
194 int or None
195 File size in bytes, or None if no file is set
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
205 def read_header(self, fn: Optional[Union[str, Path]] = None) -> None:
206 """
207 Read header information from a NIMS file.
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.
213 Parameters
214 ----------
215 fn : str or Path, optional
216 Full path to NIMS file to read. Uses self.fn if not provided.
218 Raises
219 ------
220 NIMSError
221 If the file does not exist or cannot be read
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}")
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)
271 self.parse_header_dict()
273 def parse_header_dict(self, header_dict: Optional[dict[str, str]] = None) -> None:
274 """
275 Parse the header dictionary into individual attributes.
277 This method takes the raw header dictionary and extracts specific
278 information into class attributes for easy access.
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.
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)
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)
330 def _get_latitude(self, latitude: Union[str, float], hemisphere: str) -> float:
331 """
332 Get latitude as decimal degrees with proper sign.
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)
341 Returns
342 -------
343 float
344 Latitude in decimal degrees with proper sign
345 (positive for North, negative for South)
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
359 def _get_longitude(self, longitude: Union[str, float], hemisphere: str) -> float:
360 """
361 Get longitude as decimal degrees with proper sign.
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)
370 Returns
371 -------
372 float
373 Longitude in decimal degrees with proper sign
374 (positive for East, negative for West)
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