Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mth5 \ mth5 \ timeseries \ channel_ts.py: 78%

753 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-27 20:09 -0800

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

2""" 

3Channel time series module for MT data. 

4 

5This module provides the `ChannelTS` class for handling magnetotelluric (MT) 

6time series data with comprehensive metadata management, calibration, 

7and signal processing capabilities. 

8 

9Notes 

10----- 

11- Time series are stored in `xarray.DataArray` for efficient operations. 

12- Metadata follows the mt_metadata standard with Survey/Station/Run/Channel hierarchy. 

13- Supports instrument response removal, resampling, merging, and Obspy integration. 

14 

15""" 

16 

17from __future__ import annotations 

18 

19# ============================================================================== 

20# Imports 

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

22import inspect 

23from typing import Any 

24 

25import mt_metadata.timeseries as metadata 

26import numpy as np 

27import pandas as pd 

28import scipy 

29import xarray as xr 

30from loguru import logger 

31from mt_metadata.common.list_dict import ListDict 

32from mt_metadata.common.mttime import MTime 

33from mt_metadata.common.units import get_unit_object 

34from mt_metadata.timeseries.filters import ChannelResponse 

35from obspy.core import Trace 

36from scipy import signal 

37 

38from mth5.timeseries.ts_filters import RemoveInstrumentResponse 

39from mth5.timeseries.ts_helpers import get_decimation_sample_rates, make_dt_coordinates 

40from mth5.utils import fdsn_tools 

41 

42 

43# ============================================================================= 

44# make a dictionary of available metadata classes 

45# ============================================================================= 

46meta_classes = dict(inspect.getmembers(metadata, inspect.isclass)) 

47 

48 

49# ============================================================================== 

50# Channel Time Series Object 

51# ============================================================================== 

52class ChannelTS: 

53 """ 

54 Time series container for a single MT channel with full metadata. 

55 

56 Stores equally-spaced time series data in an `xarray.DataArray` with 

57 a time coordinate index. Integrates comprehensive metadata from 

58 Survey/Station/Run/Channel hierarchy and supports calibration, 

59 resampling, merging, and format conversions. 

60 

61 Parameters 

62 ---------- 

63 channel_type : {'electric', 'magnetic', 'auxiliary'}, default 'auxiliary' 

64 Type of the channel. 

65 data : array-like, optional 

66 Time series data (numpy array, pandas DataFrame/Series, xarray.DataArray). 

67 channel_metadata : mt_metadata.timeseries.Electric | Magnetic | Auxiliary | dict, optional 

68 Channel-specific metadata. 

69 station_metadata : mt_metadata.timeseries.Station | dict, optional 

70 Station metadata. 

71 run_metadata : mt_metadata.timeseries.Run | dict, optional 

72 Run metadata. 

73 survey_metadata : mt_metadata.timeseries.Survey | dict, optional 

74 Survey metadata. 

75 **kwargs 

76 Additional attributes to set on the object. 

77 

78 Attributes 

79 ---------- 

80 ts : numpy.ndarray 

81 The time series data array. 

82 sample_rate : float 

83 Sample rate in samples per second. 

84 start : MTime 

85 Start time (UTC). 

86 end : MTime 

87 End time (UTC), derived from start + duration. 

88 n_samples : int 

89 Number of samples. 

90 component : str 

91 Component name (e.g., 'ex', 'hy', 'temperature'). 

92 channel_response : ChannelResponse 

93 Full instrument response filter chain. 

94 

95 Notes 

96 ----- 

97 - End time is a derived property and cannot be set directly. 

98 - Leverages xarray for efficient interpolation, resampling, and groupby operations. 

99 - Metadata follows mt_metadata standards with automatic time period updates. 

100 

101 Examples 

102 -------- 

103 Create an auxiliary channel with synthetic data:: 

104 

105 >>> from mth5.timeseries import ChannelTS 

106 >>> import numpy as np 

107 >>> ts_obj = ChannelTS('auxiliary') 

108 >>> ts_obj.sample_rate = 8 

109 >>> ts_obj.start = '2020-01-01T12:00:00+00:00' 

110 >>> ts_obj.ts = np.random.randn(4096) 

111 >>> ts_obj.station_metadata.id = 'MT001' 

112 >>> ts_obj.run_metadata.id = 'MT001a' 

113 >>> ts_obj.component = 'temperature' 

114 >>> print(ts_obj) 

115 

116 Calibrate and remove instrument response:: 

117 

118 >>> calibrated = ts_obj.remove_instrument_response() 

119 >>> calibrated.channel_metadata.units 

120 """ 

121 

122 def __init__( 

123 self, 

124 channel_type: str = "auxiliary", 

125 data: ( 

126 np.ndarray | pd.DataFrame | pd.Series | xr.DataArray | list | tuple | None 

127 ) = None, 

128 channel_metadata: ( 

129 metadata.Electric | metadata.Magnetic | metadata.Auxiliary | dict | None 

130 ) = None, 

131 station_metadata: metadata.Station | dict | None = None, 

132 run_metadata: metadata.Run | dict | None = None, 

133 survey_metadata: metadata.Survey | dict | None = None, 

134 **kwargs: Any, 

135 ) -> None: 

136 self.logger = logger 

137 

138 self._channel_type = self._validate_channel_type(channel_type) 

139 self._survey_metadata = self._initialize_metadata() 

140 

141 self.data_array = xr.DataArray([1], coords=[("time", [1])], name="ts") 

142 self._channel_response = ChannelResponse() # type: ignore 

143 

144 self.survey_metadata = survey_metadata 

145 self.station_metadata = station_metadata 

146 self.run_metadata = run_metadata 

147 self.channel_metadata = channel_metadata 

148 self._sample_rate = self.get_sample_rate_supplied_at_init(channel_metadata) 

149 # input data 

150 if data is not None: 

151 self.ts = data 

152 else: 

153 self._update_xarray_metadata() 

154 

155 for key in list(kwargs.keys()): 

156 setattr(self, key, kwargs[key]) 

157 

158 def get_sample_rate_supplied_at_init( 

159 self, 

160 channel_metadata: ( 

161 metadata.Electric | metadata.Magnetic | metadata.Auxiliary | dict | None 

162 ), 

163 ) -> float | None: 

164 """ 

165 Extract sample_rate from channel_metadata if available. 

166 

167 Parameters 

168 ---------- 

169 channel_metadata : mt_metadata.timeseries.Electric | Magnetic | Auxiliary | dict | None 

170 Metadata that may contain a sample_rate field. 

171 

172 Returns 

173 ------- 

174 float | None 

175 Sample rate if found, otherwise None. 

176 

177 Notes 

178 ----- 

179 Supports nested dict structures like ``{"electric": {"sample_rate": 8.0}}``. 

180 

181 

182 """ 

183 sr = None 

184 if channel_metadata is None: 

185 sr = None 

186 elif isinstance(channel_metadata, dict): 

187 # check first two layers for sample_rate key 

188 if "sample_rate" in channel_metadata.keys(): 

189 sr = channel_metadata["sample_rate"] 

190 else: 

191 for k, v in channel_metadata.items(): 

192 if isinstance(v, dict): 

193 if "sample_rate" in v.keys(): 

194 sr = v["sample_rate"] 

195 else: 

196 try: 

197 # if an mt_metadata.timeseries access attr 

198 sr = channel_metadata.sample_rate 

199 except AttributeError: 

200 sr = None 

201 return sr 

202 

203 def __str__(self) -> str: 

204 """ 

205 Return a summary string representation of the channel. 

206 

207 Returns 

208 ------- 

209 str 

210 Multi-line summary including survey, station, run, component, 

211 sample rate, time range, and sample count. 

212 """ 

213 lines = [ 

214 f"Survey: {self.survey_metadata.id}", 

215 f"Station: {self.station_metadata.id}", 

216 f"Run: {self.run_metadata.id}", 

217 f"Channel Type: {self.channel_type}", 

218 f"Component: {self.component}", 

219 f"Sample Rate: {self.sample_rate}", 

220 f"Start: {self.start}", 

221 f"End: {self.end}", 

222 f"N Samples: {self.n_samples}", 

223 ] 

224 

225 return "\n\t".join(["Channel Summary:"] + lines) 

226 

227 def __repr__(self) -> str: 

228 return self.__str__() 

229 

230 def __eq__(self, other: object) -> bool: 

231 """ 

232 Test equality with another ChannelTS. 

233 

234 Parameters 

235 ---------- 

236 other : object 

237 Object to compare with. 

238 

239 Returns 

240 ------- 

241 bool 

242 True if metadata and data arrays are equal. 

243 

244 Raises 

245 ------ 

246 TypeError 

247 If `other` is not a ChannelTS instance. 

248 """ 

249 if not isinstance(other, ChannelTS): 

250 raise TypeError(f"Cannot compare ChannelTS with {type(other)}.") 

251 if not other.channel_metadata == self.channel_metadata: 

252 return False 

253 if self.data_array.equals(other.data_array) is False: 

254 msg = "timeseries are not equal" 

255 self.logger.info(msg) 

256 return False 

257 return True 

258 

259 def __ne__(self, other: object) -> bool: 

260 return not self.__eq__(other) 

261 

262 def __lt__(self, other: ChannelTS) -> bool: 

263 """ 

264 Compare start times of two channels. 

265 

266 Parameters 

267 ---------- 

268 other : ChannelTS 

269 Channel to compare with. 

270 

271 Returns 

272 ------- 

273 bool 

274 True if self.start < other.start and sample rates match. 

275 

276 Raises 

277 ------ 

278 TypeError 

279 If `other` is not a ChannelTS instance. 

280 """ 

281 if not isinstance(other, ChannelTS): 

282 raise TypeError(f"Cannot compare ChannelTS with {type(other)}") 

283 self.logger.info("Only testing start time") 

284 return self.start < other.start 

285 

286 def __gt__(self, other: ChannelTS) -> bool: 

287 if not isinstance(other, ChannelTS): 

288 raise TypeError(f"Cannot compare ChannelTS with {type(other)}") 

289 return self.start > other.start 

290 

291 def __add__(self, other: ChannelTS) -> ChannelTS: 

292 """ 

293 Combine two channels with the same component. 

294 

295 Combines using `xr.combine_by_coords`, computes a monotonic time index, 

296 and reindexes with linear interpolation. 

297 

298 Parameters 

299 ---------- 

300 other : ChannelTS 

301 Channel to combine with this one. 

302 

303 Returns 

304 ------- 

305 ChannelTS 

306 Combined channel with monotonic time index. 

307 

308 Raises 

309 ------ 

310 TypeError 

311 If `other` is not a ChannelTS. 

312 ValueError 

313 If components differ. 

314 

315 Examples 

316 -------- 

317 Merge two sequential segments:: 

318 

319 >>> combined = ch1 + ch2 

320 """ 

321 if not isinstance(other, ChannelTS): 

322 raise TypeError(f"Cannot combine {type(other)} with ChannelTS.") 

323 if self.component != other.component: 

324 raise ValueError( 

325 "Cannot combine channels with different components. " 

326 f"{self.component} != {other.component}" 

327 ) 

328 if self.data_array.name != self.component: 

329 self.data_array.name = self.component 

330 if other.data_array.name != self.component: 

331 other.data_array.name = self.component 

332 # combine into a data set use override to keep attrs from original 

333 combined_ds = xr.combine_by_coords( 

334 [self.data_array, other.data_array], combine_attrs="override" 

335 ) 

336 

337 # Handle datetime.timedelta for Python 3.12+ compatibility 

338 duration = combined_ds.time.max().values - combined_ds.time.min().values 

339 if hasattr(duration, "total_seconds"): 

340 # Python datetime.timedelta 

341 duration_ns = duration.total_seconds() * 1e9 

342 elif hasattr(duration, "view"): 

343 # numpy timedelta64 - convert to nanoseconds 

344 duration_ns = float(duration / np.timedelta64(1, "ns")) 

345 else: 

346 # Already numeric 

347 duration_ns = float(duration) 

348 

349 n_samples = (self.sample_rate * duration_ns / 1e9) + 1 

350 

351 new_dt_index = make_dt_coordinates( 

352 combined_ds.time.min().values, self.sample_rate, n_samples 

353 ) 

354 

355 new_channel = ChannelTS( 

356 channel_type=self.channel_metadata.type, 

357 channel_metadata=self.channel_metadata, 

358 run_metadata=self.run_metadata, 

359 station_metadata=self.station_metadata, 

360 survey_metadata=self.survey_metadata, 

361 channel_response=self.channel_response, 

362 ) 

363 

364 new_channel.data_array = combined_ds.interp( 

365 time=new_dt_index, method="slinear" 

366 ).to_array() 

367 

368 new_channel.channel_metadata.time_period.start = new_channel.start 

369 new_channel.channel_metadata.time_period.end = new_channel.end 

370 

371 new_channel.run_metadata.update_time_period() 

372 new_channel.station_metadata.update_time_period() 

373 new_channel.survey_metadata.update_time_period() 

374 

375 new_channel._update_xarray_metadata() 

376 

377 return new_channel 

378 

379 def _initialize_metadata(self) -> metadata.Survey: 

380 """ 

381 Create a Survey metadata hierarchy with default Station/Run/Channel. 

382 

383 Returns 

384 ------- 

385 mt_metadata.timeseries.Survey 

386 Initialized survey metadata with default IDs. 

387 """ 

388 

389 survey_metadata = metadata.Survey(id="0") 

390 survey_metadata.stations.append(metadata.Station(id="0")) 

391 survey_metadata.stations[0].runs.append(metadata.Run(id="0")) 

392 

393 # Create temporary channel metadata with valid default components 

394 channel_type_lower = self.channel_type.lower() 

395 if channel_type_lower == "electric": 

396 ch_metadata = meta_classes[self.channel_type](component="ex") 

397 elif channel_type_lower == "magnetic": 

398 ch_metadata = meta_classes[self.channel_type](component="hx") 

399 elif channel_type_lower == "auxiliary": 

400 ch_metadata = meta_classes[self.channel_type](component="temperature") 

401 else: 

402 # Fallback for unknown types - try with a generic component 

403 ch_metadata = meta_classes[self.channel_type](component="temp") 

404 

405 ch_metadata.type = self.channel_type.lower() 

406 survey_metadata.stations[0].runs[0].channels.append(ch_metadata) 

407 

408 return survey_metadata 

409 

410 def _validate_channel_type(self, channel_type: str | None) -> str: 

411 """ 

412 Validate and normalize channel type. 

413 

414 Parameters 

415 ---------- 

416 channel_type : str | None 

417 Channel type string. 

418 

419 Returns 

420 ------- 

421 str 

422 Capitalized valid channel type: 'Electric', 'Magnetic', or 'Auxiliary'. 

423 

424 Raises 

425 ------ 

426 ValueError 

427 If channel type is not recognized. 

428 """ 

429 

430 if channel_type is None: 

431 channel_type = "auxiliary" 

432 if channel_type.lower() not in ["electric", "magnetic"]: 

433 channel_type = "auxiliary" 

434 if not channel_type.capitalize() in meta_classes.keys(): 

435 msg = ( 

436 "Channel type is undefined, must be [ electric | " 

437 "magnetic | auxiliary ]" 

438 ) 

439 self.logger.error(msg) 

440 raise ValueError(msg) 

441 return channel_type.capitalize() 

442 

443 def _validate_channel_metadata( 

444 self, 

445 channel_metadata: ( 

446 metadata.Electric | metadata.Magnetic | metadata.Auxiliary | dict 

447 ), 

448 ) -> metadata.Electric | metadata.Magnetic | metadata.Auxiliary: 

449 """ 

450 Validate and normalize channel metadata input. 

451 

452 Parameters 

453 ---------- 

454 channel_metadata : mt_metadata.timeseries.Electric | Magnetic | Auxiliary | dict 

455 Metadata to validate. 

456 

457 Returns 

458 ------- 

459 mt_metadata.timeseries.Electric | Magnetic | Auxiliary 

460 Validated metadata object. 

461 

462 Raises 

463 ------ 

464 TypeError 

465 If input is not an expected type. 

466 """ 

467 expected_types = ( 

468 metadata.Electric, 

469 metadata.Magnetic, 

470 metadata.Auxiliary, 

471 ) 

472 if isinstance(channel_metadata, expected_types): 

473 return channel_metadata.copy() 

474 

475 if not isinstance(channel_metadata, dict): 

476 msg = ( 

477 f"input metadata must be type {type(self.channel_metadata)}" 

478 f" or dict, not {type(channel_metadata)}" 

479 ) 

480 self.logger.error(msg) 

481 raise TypeError(msg) 

482 

483 channel_metadata_lower_keys = [x.lower() for x in channel_metadata.keys()] 

484 if self.channel_type.lower() not in channel_metadata_lower_keys: 

485 try: 

486 self.channel_type = channel_metadata["type"] 

487 except KeyError: 

488 pass 

489 channel_metadata = {self.channel_type: channel_metadata} 

490 self.channel_type = list(channel_metadata.keys())[0] 

491 # Create channel metadata with proper default component 

492 channel_type_lower = self.channel_type.lower() 

493 if channel_type_lower == "electric": 

494 ch_metadata = meta_classes[self.channel_type]() 

495 elif channel_type_lower == "magnetic": 

496 ch_metadata = meta_classes[self.channel_type]() 

497 elif channel_type_lower == "auxiliary": 

498 ch_metadata = meta_classes[self.channel_type]() 

499 else: 

500 ch_metadata = meta_classes[self.channel_type]() 

501 self.logger.debug("Loading from metadata dict") 

502 ch_metadata.from_dict(channel_metadata) 

503 return ch_metadata 

504 

505 def _validate_run_metadata(self, run_metadata: metadata.Run | dict) -> metadata.Run: 

506 """ 

507 Validate and normalize run metadata input. 

508 

509 Parameters 

510 ---------- 

511 run_metadata : mt_metadata.timeseries.Run | dict 

512 Run metadata to validate. 

513 

514 Returns 

515 ------- 

516 mt_metadata.timeseries.Run 

517 Validated run metadata object. 

518 

519 Raises 

520 ------ 

521 TypeError 

522 If input is not a Run object or dict. 

523 """ 

524 

525 if not isinstance(run_metadata, metadata.Run): 

526 if isinstance(run_metadata, dict): 

527 if "run" not in [cc.lower() for cc in run_metadata.keys()]: 

528 run_metadata = {"Run": run_metadata} 

529 r_metadata = metadata.Run() 

530 r_metadata.from_dict(run_metadata) 

531 self.logger.debug("Loading from metadata dict") 

532 return r_metadata 

533 else: 

534 msg = ( 

535 f"input metadata must be type {type(self.run_metadata)} " 

536 f"or dict, not {type(run_metadata)}" 

537 ) 

538 self.logger.error(msg) 

539 raise TypeError(msg) 

540 return run_metadata.copy() 

541 

542 def _validate_station_metadata( 

543 self, station_metadata: metadata.Station | dict 

544 ) -> metadata.Station: 

545 """ 

546 Validate and normalize station metadata input. 

547 

548 Parameters 

549 ---------- 

550 station_metadata : mt_metadata.timeseries.Station | dict 

551 Station metadata to validate. 

552 

553 Returns 

554 ------- 

555 mt_metadata.timeseries.Station 

556 Validated station metadata object. 

557 

558 Raises 

559 ------ 

560 TypeError 

561 If input is not a Station object or dict. 

562 """ 

563 if not isinstance(station_metadata, metadata.Station): 

564 if isinstance(station_metadata, dict): 

565 if "station" not in [cc.lower() for cc in station_metadata.keys()]: 

566 station_metadata = {"Station": station_metadata} 

567 st_metadata = metadata.Station() 

568 st_metadata.from_dict(station_metadata) 

569 self.logger.debug("Loading from metadata dict") 

570 return st_metadata 

571 else: 

572 msg = ( 

573 f"input metadata must be type {type(self.station_metadata)}" 

574 f" or dict, not {type(station_metadata)}" 

575 ) 

576 self.logger.error(msg) 

577 raise TypeError(msg) 

578 return station_metadata.copy() 

579 

580 def _validate_survey_metadata( 

581 self, survey_metadata: metadata.Survey | dict 

582 ) -> metadata.Survey: 

583 """ 

584 Validate and normalize survey metadata input. 

585 

586 Parameters 

587 ---------- 

588 survey_metadata : mt_metadata.timeseries.Survey | dict 

589 Survey metadata to validate. 

590 

591 Returns 

592 ------- 

593 mt_metadata.timeseries.Survey 

594 Validated survey metadata object. 

595 

596 Raises 

597 ------ 

598 TypeError 

599 If input is not a Survey object or dict. 

600 """ 

601 if not isinstance(survey_metadata, metadata.Survey): 

602 if isinstance(survey_metadata, dict): 

603 if "survey" not in [cc.lower() for cc in survey_metadata.keys()]: 

604 survey_metadata = {"Survey": survey_metadata} 

605 sv_metadata = metadata.Survey() 

606 sv_metadata.from_dict(survey_metadata) 

607 self.logger.debug("Loading from metadata dict") 

608 return sv_metadata 

609 else: 

610 msg = ( 

611 f"input metadata must be type {type(self.survey_metadata)}" 

612 f" or dict, not {type(survey_metadata)}" 

613 ) 

614 self.logger.error(msg) 

615 raise TypeError(msg) 

616 return survey_metadata.copy() 

617 

618 def copy(self, data: bool = True) -> ChannelTS: 

619 """ 

620 Create a copy of the ChannelTS object. 

621 

622 Parameters 

623 ---------- 

624 data : bool, default True 

625 Include data in the copy (True) or only metadata (False). 

626 

627 Returns 

628 ------- 

629 ChannelTS 

630 Copy of the channel. 

631 

632 Examples 

633 -------- 

634 Copy metadata structure without data:: 

635 

636 >>> ch_copy = ts_obj.copy(data=False) 

637 """ 

638 

639 if not data: 

640 return ChannelTS( 

641 channel_type=self.channel_metadata.type, 

642 channel_metadata=self.channel_metadata.copy(), 

643 run_metadata=self.run_metadata.copy(), 

644 station_metadata=self.station_metadata.copy(), 

645 survey_metadata=self.survey_metadata.copy(), 

646 channel_response=self.channel_response.copy(), 

647 ) 

648 else: 

649 return ChannelTS( 

650 channel_type=self.channel_metadata.type, 

651 data=self.ts, 

652 channel_metadata=self.channel_metadata.copy(), 

653 run_metadata=self.run_metadata.copy(), 

654 station_metadata=self.station_metadata.copy(), 

655 survey_metadata=self.survey_metadata.copy(), 

656 channel_response=self.channel_response.copy(), 

657 ) 

658 

659 ### Properties ------------------------------------------------------------ 

660 @property 

661 def survey_metadata(self) -> metadata.Survey: 

662 """ 

663 Survey metadata. 

664 

665 Returns 

666 ------- 

667 mt_metadata.timeseries.Survey 

668 Survey metadata with updated keys. 

669 """ 

670 self._survey_metadata.stations[0].runs.update_keys() 

671 self._survey_metadata.stations.update_keys() 

672 return self._survey_metadata 

673 

674 @survey_metadata.setter 

675 def survey_metadata(self, survey_metadata: metadata.Survey | dict | None) -> None: 

676 """ 

677 Set survey metadata. 

678 

679 Parameters 

680 ---------- 

681 survey_metadata : mt_metadata.timeseries.Survey | dict | None 

682 Survey metadata object or dictionary. 

683 """ 

684 

685 if survey_metadata is not None: 

686 survey_metadata = self._validate_survey_metadata(survey_metadata) 

687 self._survey_metadata.update(survey_metadata) 

688 

689 @property 

690 def station_metadata(self) -> metadata.Station: 

691 """ 

692 Station metadata. 

693 

694 Returns 

695 ------- 

696 mt_metadata.timeseries.Station 

697 Station metadata from the first station in the survey. 

698 """ 

699 self._survey_metadata.stations.update_keys() 

700 return self.survey_metadata.stations[0] 

701 

702 @station_metadata.setter 

703 def station_metadata( 

704 self, station_metadata: metadata.Station | dict | None 

705 ) -> None: 

706 """ 

707 Set station metadata. 

708 

709 Parameters 

710 ---------- 

711 station_metadata : mt_metadata.timeseries.Station | dict | None 

712 Station metadata to set. 

713 """ 

714 

715 if station_metadata is not None: 

716 station_metadata = self._validate_station_metadata(station_metadata) 

717 

718 runs = ListDict() 

719 if self.run_metadata.id not in ["0", 0, None]: 

720 runs.append(self.run_metadata.copy()) 

721 runs.extend(station_metadata.runs) 

722 if len(runs) == 0: 

723 runs[0] = metadata.Run(id="0") 

724 # be sure there is a level below 

725 if len(runs[0].channels) == 0: 

726 # Create channel metadata with proper default component 

727 channel_type_lower = self.channel_type.lower() 

728 if channel_type_lower == "electric": 

729 ch_metadata = meta_classes[self.channel_type](component="ex") 

730 elif channel_type_lower == "magnetic": 

731 ch_metadata = meta_classes[self.channel_type](component="hx") 

732 elif channel_type_lower == "auxiliary": 

733 ch_metadata = meta_classes[self.channel_type]( 

734 component="temperature" 

735 ) 

736 else: 

737 ch_metadata = meta_classes[self.channel_type]() 

738 ch_metadata.type = self.channel_type.lower() 

739 runs[0].channels.append(ch_metadata) 

740 stations = ListDict() 

741 stations.append(station_metadata) 

742 stations[0].runs = runs 

743 

744 self.survey_metadata.stations = stations 

745 

746 @property 

747 def run_metadata(self) -> metadata.Run: 

748 """ 

749 Run metadata. 

750 

751 Returns 

752 ------- 

753 mt_metadata.timeseries.Run 

754 Run metadata from the first run in the station. 

755 """ 

756 

757 self._survey_metadata.stations[0].runs.update_keys() 

758 self._survey_metadata.stations[0].runs[0].channels.update_keys() 

759 return self.survey_metadata.stations[0].runs[0] 

760 

761 @run_metadata.setter 

762 def run_metadata(self, run_metadata: metadata.Run | dict | None) -> None: 

763 """ 

764 Set run metadata. 

765 

766 Parameters 

767 ---------- 

768 run_metadata : mt_metadata.timeseries.Run | dict | None 

769 Run metadata to set. 

770 """ 

771 

772 # need to make sure the first index is the desired channel 

773 if run_metadata is not None: 

774 run_metadata = self._validate_run_metadata(run_metadata) 

775 

776 runs = ListDict() 

777 runs.append(run_metadata) 

778 channels = ListDict() 

779 if self.component is not None: 

780 key = str(self.component) 

781 

782 channels.append(self.station_metadata.runs[0].channels[key]) 

783 # add existing channels 

784 channels.extend(self.run_metadata.channels, skip_keys=[key, "0"]) 

785 # add channels from input metadata 

786 channels.extend(run_metadata.channels) 

787 

788 runs[0].channels = channels 

789 runs.extend(self.station_metadata.runs, skip_keys=[run_metadata.id, "0"]) 

790 

791 self._survey_metadata.stations[0].runs = runs 

792 

793 @property 

794 def channel_metadata( 

795 self, 

796 ) -> metadata.Electric | metadata.Magnetic | metadata.Auxiliary: 

797 """ 

798 Channel metadata. 

799 

800 Returns 

801 ------- 

802 mt_metadata.timeseries.Electric | Magnetic | Auxiliary 

803 Channel metadata from the first channel in the run. 

804 """ 

805 ch_metadata = self._survey_metadata.stations[0].runs[0].channels[0] 

806 if self.has_data(): 

807 ch_metadata.sample_rate = self.sample_rate 

808 return ch_metadata 

809 

810 @channel_metadata.setter 

811 def channel_metadata( 

812 self, 

813 channel_metadata: ( 

814 metadata.Electric | metadata.Magnetic | metadata.Auxiliary | dict | None 

815 ), 

816 ) -> None: 

817 """ 

818 Set channel metadata. 

819 

820 Parameters 

821 ---------- 

822 channel_metadata : mt_metadata.timeseries.Electric | Magnetic | Auxiliary | dict | None 

823 Channel metadata to set. 

824 

825 Raises 

826 ------ 

827 ValueError 

828 If the channel component is None. 

829 """ 

830 

831 if channel_metadata is not None: 

832 channel_metadata = self._validate_channel_metadata(channel_metadata) 

833 if channel_metadata.component is not None: 

834 channels = ListDict() 

835 if channel_metadata.component in self.run_metadata.channels.keys(): 

836 channels.append( 

837 self.run_metadata.channels[channel_metadata.component] 

838 ) 

839 channels[0].update(channel_metadata) 

840 else: 

841 channels.append(channel_metadata) 

842 channels.extend( 

843 self.run_metadata.channels, 

844 skip_keys=[channel_metadata.component, None], 

845 ) 

846 

847 self.run_metadata.channels = channels 

848 self.channel_type = self.run_metadata.channels[0].type 

849 else: 

850 raise ValueError("Channel 'component' cannot be None") 

851 

852 def _check_pd_index(self, ts_arr: pd.DataFrame | pd.Series) -> pd.DatetimeIndex: 

853 """ 

854 Check and return the time index from a pandas DataFrame or Series. 

855 

856 Parameters 

857 ---------- 

858 ts_arr : pandas.DataFrame | pandas.Series 

859 Time series data. 

860 

861 Returns 

862 ------- 

863 pandas.DatetimeIndex 

864 Time index (existing or reconstructed from start/sample_rate). 

865 """ 

866 if isinstance(ts_arr.index[0], pd._libs.tslibs.timestamps.Timestamp): 

867 return ts_arr.index 

868 else: 

869 return make_dt_coordinates(self.start, self.sample_rate, ts_arr.shape[0]) 

870 

871 def _validate_dataframe_input( 

872 self, ts_arr: pd.DataFrame 

873 ) -> tuple[pd.DataFrame, pd.DatetimeIndex]: 

874 """ 

875 Validate pandas DataFrame input. 

876 

877 Parameters 

878 ---------- 

879 ts_arr : pandas.DataFrame 

880 DataFrame containing a 'data' column. 

881 

882 Returns 

883 ------- 

884 tuple[pandas.DataFrame, pandas.DatetimeIndex] 

885 Validated DataFrame and time index. 

886 

887 Raises 

888 ------ 

889 ValueError 

890 If 'data' column is missing or has object dtype that can't convert. 

891 """ 

892 if "data" not in ts_arr.columns: 

893 msg = ( 

894 "Data frame needs to have a column named `data` " 

895 "where the time series data is stored" 

896 ) 

897 self.logger.error(msg) 

898 raise ValueError(msg) 

899 

900 if isinstance(type(ts_arr.data.dtype), type(np.object_)): 

901 try: 

902 ts_arr["data"] = ts_arr.data.astype(float) 

903 except ValueError: 

904 raise ValueError( 

905 "DataFrame dtype is 'object' and cannot convert " 

906 "data to float values, check data dtype." 

907 ) 

908 dt = self._check_pd_index(ts_arr) 

909 return ts_arr, dt 

910 

911 def _validate_series_input( 

912 self, ts_arr: pd.Series 

913 ) -> tuple[pd.Series, pd.DatetimeIndex]: 

914 """ 

915 Validate pandas Series input. 

916 

917 Parameters 

918 ---------- 

919 ts_arr : pandas.Series 

920 Series containing time series data. 

921 

922 Returns 

923 ------- 

924 tuple[pandas.Series, pandas.DatetimeIndex] 

925 Validated Series and time index. 

926 

927 Raises 

928 ------ 

929 ValueError 

930 If Series has object dtype that can't convert to float. 

931 """ 

932 

933 if isinstance(type(ts_arr.dtype), type(np.object_)): 

934 try: 

935 ts_arr = ts_arr.astype(float) 

936 except ValueError: 

937 raise ValueError( 

938 "Series dtype is 'object' and cannot convert " 

939 "data to float values, check data dtype." 

940 ) 

941 

942 dt = self._check_pd_index(ts_arr) 

943 return ts_arr, dt 

944 

945 @property 

946 def ts(self) -> np.ndarray: 

947 """ 

948 Time series data as a numpy array. 

949 

950 Returns 

951 ------- 

952 numpy.ndarray 

953 The time series data. 

954 """ 

955 return self.data_array.data 

956 

957 @ts.setter 

958 def ts( 

959 self, 

960 ts_arr: np.ndarray | list | tuple | pd.DataFrame | pd.Series | xr.DataArray, 

961 ) -> None: 

962 """ 

963 Set the time series data. 

964 

965 Parameters 

966 ---------- 

967 ts_arr : numpy.ndarray | list | tuple | pandas.DataFrame | pandas.Series | xarray.DataArray 

968 Time series data. DataFrames must have a 'data' column. 

969 

970 Raises 

971 ------ 

972 TypeError 

973 If data type is not supported. 

974 

975 Notes 

976 ----- 

977 - For pandas DataFrames/Series, time index is extracted or reconstructed. 

978 - For xarray.DataArray, metadata is extracted from attrs. 

979 """ 

980 

981 if isinstance(ts_arr, (np.ndarray, list, tuple)): 

982 if not isinstance(ts_arr, np.ndarray): 

983 ts_arr = np.array(ts_arr) 

984 # Validate an input array to make sure its 1D 

985 if len(ts_arr.shape) == 2: 

986 if 1 in ts_arr.shape: 

987 ts_arr = ts_arr.reshape(ts_arr.size) 

988 else: 

989 msg = f"Input array must be 1-D array not {ts_arr.shape}" 

990 self.logger.error(msg) 

991 raise ValueError(msg) 

992 dt = make_dt_coordinates(self.start, self.sample_rate, ts_arr.size) 

993 self.data_array = xr.DataArray( 

994 ts_arr, coords=[("time", dt)], name=self.component 

995 ) 

996 self._update_xarray_metadata() 

997 elif isinstance(ts_arr, pd.core.frame.DataFrame): 

998 ts_arr, dt = self._validate_dataframe_input(ts_arr) 

999 self.data_array = xr.DataArray( 

1000 ts_arr["data"], coords=[("time", dt)], name=self.component 

1001 ) 

1002 self._update_xarray_metadata() 

1003 

1004 elif isinstance(ts_arr, pd.core.series.Series): 

1005 ts_arr, dt = self._validate_series_input(ts_arr) 

1006 self.data_array = xr.DataArray( 

1007 ts_arr.values, coords=[("time", dt)], name=self.component 

1008 ) 

1009 self._update_xarray_metadata() 

1010 elif isinstance(ts_arr, xr.DataArray): 

1011 # TODO: need to validate the input xarray 

1012 self.data_array = ts_arr 

1013 # need to pull out the metadata as a separate dictionary 

1014 meta_dict = dict([(k, v) for k, v in ts_arr.attrs.items()]) 

1015 

1016 # need to get station and run metadata out 

1017 survey_dict = {} 

1018 station_dict = {} 

1019 run_dict = {} 

1020 

1021 for key in [k for k in meta_dict.keys() if "survey." in k]: 

1022 survey_dict[key.split("station.")[-1]] = meta_dict.pop(key) 

1023 for key in [k for k in meta_dict.keys() if "station." in k]: 

1024 station_dict[key.split("station.")[-1]] = meta_dict.pop(key) 

1025 for key in [k for k in meta_dict.keys() if "run." in k]: 

1026 run_dict[key.split("run.")[-1]] = meta_dict.pop(key) 

1027 self.channel_type = meta_dict["type"] 

1028 # Create channel metadata with proper default component 

1029 channel_type_lower = self.channel_type.lower() 

1030 if channel_type_lower == "electric": 

1031 ch_metadata = meta_classes[self.channel_type](component="ex") 

1032 elif channel_type_lower == "magnetic": 

1033 ch_metadata = meta_classes[self.channel_type](component="hx") 

1034 elif channel_type_lower == "auxiliary": 

1035 ch_metadata = meta_classes[self.channel_type](component="temperature") 

1036 else: 

1037 ch_metadata = meta_classes[self.channel_type]() 

1038 ch_metadata.from_dict({self.channel_type: meta_dict}) 

1039 

1040 self.survey_metadata.from_dict({"survey": survey_dict}) 

1041 self.station_metadata.from_dict({"station": station_dict}) 

1042 self.run_metadata.from_dict({"run": run_dict}) 

1043 self.channel_metadata = ch_metadata 

1044 # need to run this incase things are different. 

1045 self._update_xarray_metadata() 

1046 else: 

1047 msg = ( 

1048 "Data type {0} not supported".format(type(ts_arr)) 

1049 + ", ts needs to be a numpy.ndarray, pandas DataFrame, " 

1050 + "or xarray.DataArray." 

1051 ) 

1052 raise TypeError(msg) 

1053 

1054 @property 

1055 def time_index(self) -> np.ndarray: 

1056 """ 

1057 Time index as a numpy array. 

1058 

1059 Returns 

1060 ------- 

1061 numpy.ndarray 

1062 Array of datetime64[ns] timestamps. 

1063 """ 

1064 

1065 try: 

1066 return self.data_array.time.to_numpy() 

1067 except AttributeError: 

1068 return self.data_array.time.values 

1069 

1070 @property 

1071 def channel_type(self) -> str: 

1072 """ 

1073 Channel type. 

1074 

1075 Returns 

1076 ------- 

1077 str 

1078 Channel type: 'Electric', 'Magnetic', or 'Auxiliary'. 

1079 """ 

1080 return self._channel_type 

1081 

1082 @channel_type.setter 

1083 def channel_type(self, value: str) -> None: 

1084 """change channel type means changing the metadata type""" 

1085 

1086 value = self._validate_channel_type(value) 

1087 if value != self._channel_type: 

1088 m_dict = self.channel_metadata.to_dict(single=True) 

1089 

1090 msg = ( 

1091 f"Changing metadata from {self.channel_type} to {value}, " 

1092 "will translate any similar attributes." 

1093 ) 

1094 # Create channel metadata with proper default component 

1095 if value.lower() == "electric": 

1096 channel_metadata = meta_classes[value](component="ex") 

1097 elif value.lower() == "magnetic": 

1098 channel_metadata = meta_classes[value](component="hx") 

1099 elif value.lower() == "auxiliary": 

1100 channel_metadata = meta_classes[value](component="temperature") 

1101 else: 

1102 channel_metadata = meta_classes[value]() 

1103 self.logger.debug(msg) 

1104 for key in channel_metadata.to_dict(single=True).keys(): 

1105 # need to skip type otherwise it keeps the same type 

1106 if key in ["type"]: 

1107 continue 

1108 # Skip component when changing channel types to avoid validation conflicts 

1109 # The new metadata already has appropriate default component for the type 

1110 if key in ["component"] and self._channel_type != value: 

1111 continue 

1112 try: 

1113 channel_metadata.update_attribute(key, m_dict[key]) 

1114 except KeyError: 

1115 pass 

1116 self._channel_type = value 

1117 self.run_metadata.channels[0] = channel_metadata 

1118 

1119 def _update_xarray_metadata(self) -> None: 

1120 """ 

1121 Update xarray attrs with current metadata. 

1122 

1123 Notes 

1124 ----- 

1125 Synchronizes channel_metadata fields into data_array.attrs and adds 

1126 station/run IDs for convenient access. 

1127 """ 

1128 self.logger.debug("Updating xarray attributes") 

1129 

1130 self.channel_metadata.time_period.start = self.start.iso_no_tz 

1131 self.channel_metadata.time_period.end = self.end.iso_no_tz 

1132 self.channel_metadata.sample_rate = self.sample_rate 

1133 

1134 self.data_array.attrs.update( 

1135 self.channel_metadata.to_dict()[self.channel_metadata._class_name] 

1136 ) 

1137 # add station and run id's here, for now this is all we need but may need 

1138 # more metadata down the road. 

1139 self.data_array.attrs["station.id"] = self.station_metadata.id 

1140 self.data_array.attrs["run.id"] = self.run_metadata.id 

1141 self.data_array.name = self.component 

1142 

1143 @property 

1144 def component(self): 

1145 """component""" 

1146 return self.channel_metadata.component 

1147 

1148 @component.setter 

1149 def component(self, comp): 

1150 """set component in metadata and carry through""" 

1151 if self.channel_metadata.type == "electric": 

1152 if comp[0].lower() != "e": 

1153 msg = ( 

1154 "The current timeseries is an electric channel. " 

1155 "Cannot change channel type, create a new ChannelTS object." 

1156 ) 

1157 self.logger.error(msg) 

1158 raise ValueError(msg) 

1159 elif self.channel_metadata.type == "magnetic": 

1160 if comp[0].lower() not in ["h", "b"]: 

1161 msg = ( 

1162 "The current timeseries is a magnetic channel. " 

1163 "Cannot change channel type, create a new ChannelTS object." 

1164 ) 

1165 self.logger.error(msg) 

1166 raise ValueError(msg) 

1167 if self.channel_metadata.type == "auxiliary": 

1168 if comp[0].lower() in ["e", "h", "b"]: 

1169 msg = ( 

1170 "The current timeseries is an auxiliary channel. " 

1171 "Cannot change channel type, create a new ChannelTS object." 

1172 ) 

1173 self.logger.error(msg) 

1174 raise ValueError(msg) 

1175 self.channel_metadata.component = comp 

1176 

1177 # need to update the keys in the list dict 

1178 channels = ListDict() 

1179 channels.append(self.channel_metadata) 

1180 if len(self.run_metadata.channels) > 1: 

1181 for ch in self.run_metadata.channels[1:]: 

1182 channels.append(ch) 

1183 self.run_metadata.channels = channels 

1184 

1185 self._update_xarray_metadata() 

1186 

1187 # --> number of samples just to make sure there is consistency 

1188 @property 

1189 def n_samples(self): 

1190 """number of samples""" 

1191 return int(self.ts.size) 

1192 

1193 @n_samples.setter 

1194 def n_samples(self, n_samples): 

1195 """number of samples (int)""" 

1196 self.logger.warning( 

1197 "Cannot set the number of samples. Use `ChannelTS.resample` or `get_slice`" 

1198 ) 

1199 

1200 def has_data(self): 

1201 """ 

1202 check to see if there is an index in the time series 

1203 """ 

1204 if self.data_array.data.size > 1: 

1205 if isinstance( 

1206 self.data_array.indexes["time"][0], 

1207 pd._libs.tslibs.timestamps.Timestamp, 

1208 ): 

1209 return True 

1210 return False 

1211 else: 

1212 return False 

1213 

1214 def is_high_frequency(self, threshold_dt=1e-4): 

1215 """ 

1216 Quasi hard-coded condition to check if data are logged at more than 10kHz 

1217 can be parameterized in future 

1218 """ 

1219 if ( 

1220 self.data_array.coords.indexes["time"][1] 

1221 - self.data_array.coords.indexes["time"][0] 

1222 ).total_seconds() < threshold_dt: 

1223 return True 

1224 else: 

1225 return False 

1226 

1227 def compute_sample_rate(self): 

1228 """ 

1229 Two cases, high_frequency (HF) data and not HF data. 

1230 

1231 # Original comment about the HF case: 

1232 Taking the median(diff(timestamps)) is more accurate for high sample rates, the way pandas.date_range 

1233 rounds nanoseconds is not consistent between samples, therefore taking the median provides better results 

1234 if the time series is long this can be inefficient so test first 

1235 

1236 """ 

1237 if self.is_high_frequency(): 

1238 dt_array = np.diff(self.data_array.coords.indexes["time"]) 

1239 best_dt, counts = scipy.stats.mode(dt_array) 

1240 

1241 # Calculate total seconds of the best dt and calculate sample rate 

1242 best_dt_seconds = float(best_dt) / 1e9 

1243 sr = 1 / best_dt_seconds 

1244 else: 

1245 t_diff = ( 

1246 self.data_array.coords.indexes["time"][-1] 

1247 - self.data_array.coords.indexes["time"][0] 

1248 ) 

1249 sr = self.data_array.size / t_diff.total_seconds() 

1250 return np.round(sr, 0) 

1251 

1252 # --> sample rate 

1253 @property 

1254 def sample_rate(self): 

1255 """sample rate in samples/second""" 

1256 if self.has_data(): 

1257 if self._sample_rate is None: 

1258 self._sample_rate = self.compute_sample_rate() 

1259 return self._sample_rate 

1260 else: 

1261 self.logger.debug("Data has not been set yet, sample rate is from metadata") 

1262 sr = self.channel_metadata.sample_rate 

1263 if sr is None: 

1264 sr = 0.0 

1265 

1266 if sr >= 1: 

1267 return np.round(sr, 0) 

1268 else: 

1269 return np.round(sr, 6) 

1270 

1271 @sample_rate.setter 

1272 def sample_rate(self, sample_rate): 

1273 """ 

1274 sample rate in samples/second 

1275 

1276 :param sample_rate: sample rate in samples per second 

1277 :type sample_rate: float 

1278 """ 

1279 if self.has_data(): 

1280 self.logger.warning( 

1281 "Resetting sample_rate assumes same start time and " 

1282 "same number of samples, resulting in new end time. " 

1283 "If you want to downsample existing time series " 

1284 "use the method channelTS.resample()" 

1285 ) 

1286 self.logger.debug( 

1287 f"Resetting sample rate from {self.sample_rate} to {sample_rate}" 

1288 ) 

1289 new_dt = make_dt_coordinates(self.start, sample_rate, self.n_samples) 

1290 self.data_array.coords["time"] = new_dt 

1291 else: 

1292 if self.channel_metadata.sample_rate not in [0.0, None]: 

1293 self.logger.warning( 

1294 f"Resetting ChannelTS.channel_metadata.sample_rate to {sample_rate}. " 

1295 ) 

1296 self.channel_metadata.sample_rate = sample_rate 

1297 self._sample_rate = sample_rate 

1298 self._update_xarray_metadata() 

1299 

1300 @property 

1301 def sample_interval(self): 

1302 """ 

1303 Sample interval = 1 / sample_rate 

1304 

1305 :return: sample interval as time distance between time samples 

1306 :rtype: float 

1307 

1308 """ 

1309 

1310 if self.sample_rate != 0: 

1311 return 1.0 / self.sample_rate 

1312 return 0.0 

1313 

1314 ## set time and set index 

1315 @property 

1316 def start(self): 

1317 """MTime object""" 

1318 if self.has_data(): 

1319 return MTime( 

1320 time_stamp=self.data_array.coords.indexes["time"][0].isoformat() 

1321 ) 

1322 else: 

1323 self.logger.debug( 

1324 "Data not set yet, pulling start time from " 

1325 "metadata.time_period.start" 

1326 ) 

1327 return MTime(time_stamp=self.channel_metadata.time_period.start) 

1328 

1329 @start.setter 

1330 def start(self, start_time): 

1331 """ 

1332 start time of time series in UTC given in some format or a datetime 

1333 object. 

1334 

1335 Resets epoch seconds if the new value is not equivalent to previous 

1336 value. 

1337 

1338 Resets how the ts data frame is indexed, setting the starting time to 

1339 the new start time. 

1340 

1341 :param start_time: start time of time series, can be string or epoch seconds 

1342 

1343 """ 

1344 

1345 if not isinstance(start_time, MTime): 

1346 start_time = MTime(time_stamp=start_time) 

1347 self.channel_metadata.time_period.start = start_time.isoformat() 

1348 if self.has_data(): 

1349 if start_time == MTime( 

1350 time_stamp=self.data_array.coords.indexes["time"][0].isoformat() 

1351 ): 

1352 return 

1353 else: 

1354 new_dt = make_dt_coordinates( 

1355 start_time, self.sample_rate, self.n_samples 

1356 ) 

1357 self.data_array.coords["time"] = new_dt 

1358 # make a time series that the data can be indexed by 

1359 else: 

1360 self.logger.debug("No data, just updating metadata start") 

1361 self._survey_metadata.stations[0].runs[0].update_time_period() 

1362 self._survey_metadata.stations[0].update_time_period() 

1363 self._survey_metadata.update_time_period() 

1364 

1365 self._update_xarray_metadata() 

1366 

1367 @property 

1368 def end(self): 

1369 """MTime object""" 

1370 if self.has_data(): 

1371 return MTime( 

1372 time_stamp=self.data_array.coords.indexes["time"][-1].isoformat() 

1373 ) 

1374 else: 

1375 self.logger.debug( 

1376 "Data not set yet, pulling end time from metadata.time_period.end" 

1377 ) 

1378 return MTime(time_stamp=self.channel_metadata.time_period.end) 

1379 

1380 @end.setter 

1381 def end(self, end_time): 

1382 """ 

1383 end time of time series in UTC given in some format or a datetime 

1384 object. 

1385 

1386 Resets epoch seconds if the new value is not equivalent to previous 

1387 value. 

1388 

1389 Resets how the ts data frame is indexed, setting the starting time to 

1390 the new start time. 

1391 """ 

1392 self.logger.warning( 

1393 "Cannot set `end`. If you want a slice, then use get_slice method" 

1394 ) 

1395 

1396 @property 

1397 def channel_response(self): 

1398 """ 

1399 Full channel response filter 

1400 

1401 :return: full channel response filter 

1402 :rtype: :class:`mt_metadata.timeseries.filters.ChannelResponse` 

1403 

1404 """ 

1405 

1406 return self._channel_response 

1407 

1408 @channel_response.setter 

1409 def channel_response(self, value): 

1410 """ 

1411 

1412 :param value: channel response filter 

1413 :type value: :class:`mt_metadata.timeseries.filters.` 

1414 

1415 

1416 """ 

1417 if value is None: 

1418 return 

1419 if not isinstance(value, ChannelResponse): 

1420 msg = ( 

1421 "channel response must be a " 

1422 "mt_metadata.timeseries.filters.ChannelResponse object " 

1423 f"not {type(value)}." 

1424 ) 

1425 self.logger.error(msg) 

1426 raise TypeError(msg) 

1427 self._channel_response = value 

1428 

1429 # update channel metadata 

1430 if self.channel_metadata.filter_names != value.names: 

1431 for ch_filter in self._channel_response.filters_list: 

1432 if ch_filter.name in self.channel_metadata.filter_names: 

1433 # update existing filter info 

1434 existing_filter = self.channel_metadata.get_filter(ch_filter.name) 

1435 existing_filter.applied = False 

1436 existing_filter.stage = ch_filter.sequence_number 

1437 existing_filter.comments = ch_filter.comments 

1438 else: 

1439 self.channel_metadata.add_filter( 

1440 name=ch_filter.name, 

1441 applied=False, 

1442 stage=ch_filter.sequence_number, 

1443 comments=ch_filter.comments, 

1444 ) 

1445 

1446 def get_calibration_operation(self): 

1447 if self.channel_response.units_out == self.channel_metadata.unit_object.name: 

1448 calibration_operation = "divide" 

1449 elif self.channel_response.units_in == self.channel_metadata.unit_object.name: 

1450 calibration_operation = "multiply" 

1451 self.logger.warning( 

1452 "Unexpected Inverse Filter is being corrected -- something maybe wrong here " 

1453 ) 

1454 else: 

1455 msg = "cannot determine multiply or divide via units -- setting to divide" 

1456 self.logger.warning(msg) 

1457 calibration_operation = "divide" 

1458 return calibration_operation 

1459 

1460 def get_calibrated_units(self): 

1461 """ 

1462 Follows the FDSN standard which has the filter stages starting with physical units to digital counts. 

1463 

1464 The channel_response is expected to have a list of filter "stages" of which the first stage 

1465 has input units corresponding to the the physical quantity that the instrument measures, and the last is 

1466 normally counts. 

1467 

1468 channel_response can be viewed as the chaining together of all of these filters. 

1469 

1470 Thus it is normal for channel_response.units_out will be in the same units as the archived raw 

1471 time series, and for the units after the response is corrected for will be the units_in of 

1472 

1473 The units of the channel metadata are compared to the input and output units of the channel_response. 

1474 

1475 

1476 :return: tuple, calibration_operation, either "mulitply" or divide", and a string for calibrated units 

1477 :rtype: tuple (of two strings_ 

1478 """ 

1479 

1480 if self.channel_response.units_out == self.channel_metadata.unit_object.name: 

1481 calibrated_units = self.channel_response.units_in 

1482 elif ( 

1483 self.channel_response.units_in == None 

1484 and self.channel_response.units_out == None 

1485 ): 

1486 msg = "No Units are associated with the channel_response" 

1487 self.logger.warning(msg) 

1488 msg = "cannot determine multiply or divide via units -- setting to divide:/" 

1489 self.logger.warning(msg) 

1490 calibrated_units = self.channel_metadata.units 

1491 else: 

1492 logger.critical( 

1493 "channel response filter units are likely corrupt or channel_ts has no units" 

1494 ) 

1495 calibrated_units = self.channel_response.units_in 

1496 unit_object = get_unit_object(calibrated_units) 

1497 calibrated_units = unit_object.name 

1498 return calibrated_units 

1499 

1500 def remove_instrument_response( 

1501 self, include_decimation=False, include_delay=False, **kwargs 

1502 ): 

1503 """ 

1504 Remove instrument response from the given channel response filter 

1505 

1506 The order of operations is important (if applied): 

1507 

1508 1) detrend 

1509 2) zero mean 

1510 3) zero pad 

1511 4) time window 

1512 5) frequency window 

1513 6) remove response 

1514 7) undo time window 

1515 8) bandpass 

1516 

1517 :param include_decimation: Include decimation in response, 

1518 defaults to True 

1519 :type include_decimation: bool, optional 

1520 :param include_delay: include delay in complex response, 

1521 defaults to False 

1522 :type include_delay: bool, optional 

1523 

1524 **kwargs** 

1525 

1526 :param plot: to plot the calibration process [ False | True ] 

1527 :type plot: boolean, default True 

1528 :param detrend: Remove linar trend of the time series 

1529 :type detrend: boolean, default True 

1530 :param zero_mean: Remove the mean of the time series 

1531 :type zero_mean: boolean, default True 

1532 :param zero_pad: pad the time series to the next power of 2 for efficiency 

1533 :type zero_pad: boolean, default True 

1534 :param t_window: Time domain windown name see `scipy.signal.windows` for options 

1535 :type t_window: string, default None 

1536 :param t_window_params: Time domain window parameters, parameters can be 

1537 found in `scipy.signal.windows` 

1538 :type t_window_params: dictionary 

1539 :param f_window: Frequency domain windown name see `scipy.signal.windows` for options 

1540 :type f_window: string, defualt None 

1541 :param f_window_params: Frequency window parameters, parameters can be 

1542 found in `scipy.signal.windows` 

1543 :type f_window_params: dictionary 

1544 :param bandpass: bandpass freequency and order {"low":, "high":, "order":,} 

1545 :type bandpass: dictionary 

1546 

1547 """ 

1548 

1549 def bool_flip(x): 

1550 return bool(int(x) - 1) 

1551 

1552 if hasattr(self.channel_metadata, "filter"): 

1553 if self.channel_metadata.filter.applied is []: 

1554 self.logger.warning("No filters to apply to calibrate time series data") 

1555 return self.copy() 

1556 elif self.channel_metadata.filters is []: 

1557 self.logger.warning("No filters to apply to calibrate time series data") 

1558 return self.copy() 

1559 

1560 calibrated_ts = self.copy(data=False) 

1561 

1562 # Make a list of the filters whose response will be removed. 

1563 # We make the list here so that we have access to the indices to flip 

1564 indices_to_flip = self.channel_response.get_indices_of_filters_to_remove( 

1565 include_decimation=include_decimation, 

1566 include_delay=include_delay, 

1567 ) 

1568 filters_to_remove = [ 

1569 self.channel_response.filters_list[i] for i in indices_to_flip 

1570 ] 

1571 

1572 remover = RemoveInstrumentResponse( 

1573 self.ts, 

1574 self.time_index, 

1575 self.sample_interval, 

1576 self.channel_response, 

1577 **kwargs, 

1578 ) 

1579 

1580 calibration_operation = self.get_calibration_operation() 

1581 calibrated_ts.ts = remover.remove_instrument_response( 

1582 filters_to_remove=filters_to_remove, 

1583 operation=calibration_operation, 

1584 ) 

1585 

1586 # update "applied" booleans 

1587 if hasattr(calibrated_ts.channel_metadata, "filter"): 

1588 applied_filters = calibrated_ts.channel_metadata.filter.applied 

1589 for idx in indices_to_flip: 

1590 applied_filters[idx] = bool_flip(applied_filters[idx]) 

1591 calibrated_ts.channel_metadata.filter.applied = applied_filters 

1592 else: 

1593 for idx in indices_to_flip: 

1594 calibrated_ts.channel_metadata.filters[idx].applied = bool_flip( 

1595 calibrated_ts.channel_metadata.filters[idx].applied 

1596 ) 

1597 

1598 # update units 

1599 calibrated_units = self.get_calibrated_units() 

1600 calibrated_ts.data_array.attrs["units"] = calibrated_units 

1601 calibrated_ts.channel_metadata.units = calibrated_units 

1602 calibrated_ts._update_xarray_metadata() 

1603 

1604 return calibrated_ts 

1605 

1606 def get_slice(self, start, end=None, n_samples=None): 

1607 """ 

1608 Get a slice from the time series given a start and end time. 

1609 

1610 Looks for >= start & <= end 

1611 

1612 Uses loc to be exact with milliseconds 

1613 

1614 :param start: start time of the slice 

1615 :type start: string, MTime 

1616 :param end: end time of the slice 

1617 :type end: string, MTime 

1618 :param n_samples: number of sample to get after start time 

1619 :type n_samples: integer 

1620 :return: slice of the channel requested 

1621 :rtype: ChannelTS 

1622 

1623 """ 

1624 

1625 if n_samples is None and end is None: 

1626 msg = "Must input either end_time or n_samples." 

1627 self.logger.error(msg) 

1628 raise ValueError(msg) 

1629 if n_samples is not None and end is not None: 

1630 msg = "Must input either end_time or n_samples, not both." 

1631 self.logger.error(msg) 

1632 raise ValueError(msg) 

1633 if not isinstance(start, MTime): 

1634 start = MTime(time_stamp=start) 

1635 if n_samples is not None: 

1636 n_samples = int(n_samples) 

1637 end = start + ((n_samples - 1) / self.sample_rate) 

1638 if end is not None: 

1639 if not isinstance(end, MTime): 

1640 end = MTime(time_stamp=end) 

1641 chunk = self.data_array.indexes["time"].slice_indexer( 

1642 start=np.datetime64(start.iso_no_tz), 

1643 end=np.datetime64(end.iso_no_tz), 

1644 ) 

1645 new_ts = self.data_array.isel(indexers={"time": chunk}) 

1646 

1647 new_ch_ts = ChannelTS( 

1648 channel_type=self.channel_type, 

1649 data=new_ts, 

1650 survey_metadata=self.survey_metadata, 

1651 channel_response=self.channel_response, 

1652 ) 

1653 

1654 return new_ch_ts 

1655 

1656 # decimate data 

1657 def decimate(self, new_sample_rate, inplace=False, max_decimation=8): 

1658 """ 

1659 decimate the data by using scipy.signal.decimate 

1660 

1661 :param dec_factor: decimation factor 

1662 :type dec_factor: int 

1663 

1664 * refills ts.data with decimated data and replaces sample_rate 

1665 

1666 """ 

1667 

1668 sr_list = get_decimation_sample_rates( 

1669 self.sample_rate, new_sample_rate, max_decimation 

1670 ) 

1671 

1672 # need to fill nans with 0 otherwise they wipeout the decimation values 

1673 # and all becomes nan. 

1674 new_ts = self.data_array.fillna(0) 

1675 for step_sr in sr_list: 

1676 new_ts = new_ts.sps_filters.decimate(step_sr) 

1677 new_ts.attrs["sample_rate"] = new_sample_rate 

1678 self.channel_metadata.sample_rate = new_ts.attrs["sample_rate"] 

1679 

1680 if inplace: 

1681 self.ts = new_ts 

1682 else: 

1683 new_ts.attrs.update( 

1684 self.channel_metadata.to_dict()[self.channel_metadata._class_name] 

1685 ) 

1686 # return new_ts 

1687 return ChannelTS( 

1688 self.channel_metadata.type, 

1689 data=new_ts, 

1690 metadata=self.channel_metadata, 

1691 ) 

1692 

1693 def resample_poly(self, new_sample_rate, pad_type="mean", inplace=False): 

1694 """ 

1695 Use scipy.signal.resample_poly to resample data while using an FIR 

1696 filter to remove aliasing. 

1697 

1698 :param new_sample_rate: DESCRIPTION 

1699 :type new_sample_rate: TYPE 

1700 :param pad_type: DESCRIPTION, defaults to "mean" 

1701 :type pad_type: TYPE, optional 

1702 :return: DESCRIPTION 

1703 :rtype: TYPE 

1704 

1705 """ 

1706 

1707 # need to fill nans with 0 otherwise they wipeout the decimation values 

1708 # and all becomes nan. 

1709 new_ts = self.data_array.fillna(0) 

1710 new_ts = new_ts.sps_filters.resample_poly(new_sample_rate, pad_type=pad_type) 

1711 

1712 new_ts.attrs["sample_rate"] = new_sample_rate 

1713 self.channel_metadata.sample_rate = new_ts.attrs["sample_rate"] 

1714 

1715 if inplace: 

1716 self.ts = new_ts 

1717 else: 

1718 new_ts.attrs.update( 

1719 self.channel_metadata.to_dict()[self.channel_metadata._class_name] 

1720 ) 

1721 # return new_ts 

1722 return ChannelTS( 

1723 self.channel_metadata.type, 

1724 data=new_ts, 

1725 metadata=self.channel_metadata, 

1726 ) 

1727 

1728 def merge( 

1729 self, 

1730 other, 

1731 gap_method="slinear", 

1732 new_sample_rate=None, 

1733 resample_method="poly", 

1734 ): 

1735 """ 

1736 merg two channels or list of channels together in the following steps 

1737 

1738 1. xr.combine_by_coords([original, other]) 

1739 2. compute monotonic time index 

1740 3. reindex(new_time_index, method=gap_method) 

1741 

1742 If you want a different method or more control use merge 

1743 

1744 :param other: Another channel 

1745 :type other: :class:`mth5.timeseries.ChannelTS` 

1746 :raises TypeError: If input is not a ChannelTS 

1747 :raises ValueError: if the components are different 

1748 :return: Combined channel with monotonic time index and same metadata 

1749 :rtype: :class:`mth5.timeseries.ChannelTS` 

1750 

1751 """ 

1752 if new_sample_rate is not None: 

1753 merge_sample_rate = new_sample_rate 

1754 if resample_method == "decimate": 

1755 combine_list = [self.decimate(new_sample_rate).data_array] 

1756 elif resample_method == "poly": 

1757 combine_list = [self.resample_poly(new_sample_rate).data_array] 

1758 else: 

1759 merge_sample_rate = self.sample_rate 

1760 combine_list = [self.data_array] 

1761 if isinstance(other, (list, tuple)): 

1762 for ch in other: 

1763 if not isinstance(ch, ChannelTS): 

1764 raise TypeError(f"Cannot combine {type(ch)} with ChannelTS.") 

1765 if self.component != ch.component: 

1766 raise ValueError( 

1767 "Cannot combine channels with different components. " 

1768 f"{self.component} != {ch.component}" 

1769 ) 

1770 if new_sample_rate is not None: 

1771 if resample_method == "decimate": 

1772 ch = ch.decimate(new_sample_rate) 

1773 elif resample_method == "poly": 

1774 ch = ch.resample_poly(new_sample_rate) 

1775 combine_list.append(ch.data_array) 

1776 else: 

1777 if not isinstance(other, ChannelTS): 

1778 raise TypeError(f"Cannot combine {type(other)} with ChannelTS.") 

1779 if self.component != other.component: 

1780 raise ValueError( 

1781 "Cannot combine channels with different components. " 

1782 f"{self.component} != {other.component}" 

1783 ) 

1784 if new_sample_rate is not None: 

1785 if resample_method == "decimate": 

1786 other = other.decimate(new_sample_rate) 

1787 elif resample_method == "poly": 

1788 other = other.resample_poly(new_sample_rate) 

1789 combine_list.append(other.data_array) 

1790 # combine into a data set use override to keep attrs from original 

1791 

1792 combined_ds = xr.combine_by_coords(combine_list, combine_attrs="override") 

1793 

1794 # compute duration between max and min times robustly (handles 

1795 # numpy.timedelta64 and datetime.timedelta across Python versions) 

1796 time_max = combined_ds.time.max().values 

1797 time_min = combined_ds.time.min().values 

1798 delta = time_max - time_min 

1799 

1800 try: 

1801 # numpy.timedelta64 -> get seconds via division 

1802 seconds = delta / np.timedelta64(1, "s") 

1803 except Exception: 

1804 # fallback for datetime.timedelta or other types 

1805 import datetime 

1806 

1807 if isinstance(delta, datetime.timedelta): 

1808 seconds = delta.total_seconds() 

1809 else: 

1810 # as a last resort, use pandas to_timedelta which handles 

1811 # various timedelta-like representations 

1812 seconds = pd.to_timedelta(delta).total_seconds() 

1813 

1814 n_samples = int(merge_sample_rate * float(seconds)) + 1 

1815 

1816 new_dt_index = make_dt_coordinates( 

1817 combined_ds.time.min().values, merge_sample_rate, n_samples 

1818 ) 

1819 

1820 channel_metadata = self.channel_metadata.copy() 

1821 channel_metadata.sample_rate = merge_sample_rate 

1822 run_metadata = self.run_metadata.copy() 

1823 run_metadata.sample_rate = merge_sample_rate 

1824 

1825 new_channel = ChannelTS( 

1826 channel_type=self.channel_metadata.type, 

1827 channel_metadata=channel_metadata, 

1828 run_metadata=self.run_metadata, 

1829 station_metadata=self.station_metadata, 

1830 survey_metadata=self.survey_metadata, 

1831 channel_response=self.channel_response, 

1832 ) 

1833 

1834 new_channel.data_array = combined_ds.interp( 

1835 time=new_dt_index, method=gap_method 

1836 ).to_array() 

1837 

1838 new_channel.channel_metadata.time_period.start = new_channel.start 

1839 new_channel.channel_metadata.time_period.end = new_channel.end 

1840 

1841 new_channel.run_metadata.update_time_period() 

1842 new_channel.station_metadata.update_time_period() 

1843 new_channel.survey_metadata.update_time_period() 

1844 

1845 new_channel._update_xarray_metadata() 

1846 

1847 return new_channel 

1848 

1849 def to_xarray(self): 

1850 """ 

1851 Returns a :class:`xarray.DataArray` object of the channel timeseries 

1852 this way metadata from the metadata class is updated upon return. 

1853 

1854 :return: Returns a :class:`xarray.DataArray` object of the channel timeseries 

1855 this way metadata from the metadata class is updated upon return. 

1856 :rtype: :class:`xarray.DataArray` 

1857 

1858 

1859 >>> import numpy as np 

1860 >>> from mth5.timeseries import ChannelTS 

1861 >>> ex = ChannelTS("electric") 

1862 >>> ex.start = "2020-01-01T12:00:00" 

1863 >>> ex.sample_rate = 16 

1864 >>> ex.ts = np.random.rand(4096) 

1865 

1866 

1867 """ 

1868 self._update_xarray_metadata() 

1869 return self.data_array 

1870 

1871 def to_obspy_trace(self, network_code=None, encoding=None): 

1872 """ 

1873 Convert the time series to an :class:`obspy.core.trace.Trace` object. This 

1874 will be helpful for converting between data pulled from IRIS and data going 

1875 into IRIS. 

1876 

1877 :param network_code: two letter code provided by FDSN DMC 

1878 :type network_code: string 

1879 :return: DESCRIPTION 

1880 :rtype: TYPE 

1881 

1882 """ 

1883 encoding_dict = { 

1884 "INT16": np.int16, 

1885 "INT32": np.int32, 

1886 "INT64": np.int32, 

1887 "FLOAT32": np.float32, 

1888 "FLOAT64": np.float64, 

1889 } 

1890 if self.ts.dtype.type in [np.int64]: 

1891 obspy_trace = Trace(self.ts.astype(np.int32)) 

1892 

1893 if encoding: 

1894 try: 

1895 obspy_trace = Trace(self.ts.astype(encoding_dict[encoding])) 

1896 except KeyError: 

1897 raise KeyError( 

1898 f"{encoding} is not understood. Acceptable values are {list(encoding_dict.keys())}" 

1899 ) 

1900 else: 

1901 obspy_trace = Trace(self.ts) 

1902 

1903 # add metadata 

1904 obspy_trace.stats.channel = fdsn_tools.make_channel_code(self.channel_metadata) 

1905 obspy_trace.stats.starttime = self.start.isoformat() 

1906 obspy_trace.stats.sampling_rate = self.sample_rate 

1907 if self.station_metadata.fdsn.id is None: 

1908 self.station_metadata.fdsn.id = self.station_metadata.id 

1909 obspy_trace.stats.station = self.station_metadata.fdsn.id.upper() 

1910 obspy_trace.stats.network = network_code 

1911 

1912 return obspy_trace 

1913 

1914 def from_obspy_trace(self, obspy_trace): 

1915 """ 

1916 Fill data from an :class:`obspy.core.Trace` 

1917 

1918 :param obspy.core.trace obspy_trace: Obspy trace object 

1919 

1920 """ 

1921 

1922 if not isinstance(obspy_trace, Trace): 

1923 msg = f"Input must be obspy.core.Trace, not {type(obspy_trace)}" 

1924 self.logger.error(msg) 

1925 raise TypeError(msg) 

1926 if obspy_trace.stats.channel[1].lower() in ["e", "q"]: 

1927 self.channel_type = "electric" 

1928 measurement = "electric" 

1929 elif obspy_trace.stats.channel[1].lower() in ["h", "b", "f"]: 

1930 self.channel_type = "magnetic" 

1931 measurement = "magnetic" 

1932 else: 

1933 try: 

1934 measurement = fdsn_tools.measurement_code_dict_reverse[ 

1935 obspy_trace.stats.channel[1] 

1936 ] 

1937 except KeyError: 

1938 measurement = "auxiliary" 

1939 self.channel_type = "auxiliary" 

1940 mt_code = fdsn_tools.make_mt_channel( 

1941 fdsn_tools.read_channel_code(obspy_trace.stats.channel) 

1942 ) 

1943 

1944 self.channel_metadata.component = mt_code 

1945 self.channel_metadata.type = measurement 

1946 self.sample_rate = obspy_trace.stats.sampling_rate 

1947 self.start = obspy_trace.stats.starttime.isoformat() 

1948 self.station_metadata.fdsn.id = obspy_trace.stats.station 

1949 # Handle None network values 

1950 if ( 

1951 obspy_trace.stats.network is not None 

1952 and obspy_trace.stats.network != "None" 

1953 ): 

1954 self.station_metadata.fdsn.network = obspy_trace.stats.network 

1955 self.station_metadata.id = obspy_trace.stats.station 

1956 self.channel_metadata.units = "counts" 

1957 self.ts = obspy_trace.data 

1958 self.run_metadata.id = f"sr{int(self.sample_rate)}_001" 

1959 

1960 def plot(self): 

1961 """ 

1962 Simple plot of the data 

1963 

1964 :return: figure object 

1965 :rtype: matplotlib.figure 

1966 

1967 """ 

1968 

1969 return self.data_array.plot() 

1970 

1971 def welch_spectra(self, window_length=2**12, **kwargs): 

1972 """ 

1973 get welch spectra 

1974 

1975 :param window_length: DESCRIPTION 

1976 :type window_length: TYPE 

1977 :param **kwargs: DESCRIPTION 

1978 :type **kwargs: TYPE 

1979 :return: DESCRIPTION 

1980 :rtype: TYPE 

1981 

1982 """ 

1983 

1984 plot_frequency, power = signal.welch( 

1985 self.ts, fs=self.sample_rate, nperseg=window_length, **kwargs 

1986 ) 

1987 

1988 return plot_frequency, power 

1989 

1990 def plot_spectra(self, spectra_type="welch", window_length=2**12, **kwargs): 

1991 """ 

1992 

1993 :param spectra_type: spectra type, defaults to "welch" 

1994 :type spectra_type: string, optional 

1995 :param window_length: window length of the welch method should be a 

1996 power of 2, defaults to 2 ** 12 

1997 :type window_length: int, optional 

1998 :param **kwargs: DESCRIPTION 

1999 :type **kwargs: TYPE 

2000 

2001 """ 

2002 from matplotlib import pyplot as plt 

2003 

2004 if spectra_type == "welch": 

2005 plot_frequency, power = self.welch_spectra( 

2006 window_length=window_length, **kwargs 

2007 ) 

2008 fig = plt.figure() 

2009 ax = fig.add_subplot(1, 1, 1) 

2010 ax.loglog(1.0 / plot_frequency, power, lw=1.5) 

2011 ax.set_xlabel("Period (s)", fontdict={"size": 10, "weight": "bold"}) 

2012 ax.set_ylabel("Power (dB)", fontdict={"size": 10, "weight": "bold"}) 

2013 ax.axis("tight") 

2014 ax.grid(which="both") 

2015 ax2 = ax.twiny() 

2016 ax2.loglog(plot_frequency, power, lw=0) 

2017 ax2.set_xlabel("Frequency (Hz)", fontdict={"size": 10, "weight": "bold"}) 

2018 ax2.set_xlim([1 / cc for cc in ax.get_xlim()]) 

2019 plt.show()