Coverage for C: \ Users \ peaco \ OneDrive \ Documents \ GitHub \ mt_metadata \ mt_metadata \ common \ mttime.py: 86%

316 statements  

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

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

2""" 

3Created on Wed May 13 19:10:46 2020 

4 

5For dealing with obsy.core.UTCDatetime and an soft requirement imports 

6have a look at https://github.com/pydantic/pydantic/discussions/3673. 

7 

8@author: jpeacock 

9""" 

10import datetime 

11 

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

13# IMPORTS 

14# ============================================================================= 

15from typing import Annotated, Optional 

16 

17import numpy as np 

18import pandas as pd 

19from dateutil.parser import parse as dtparser 

20from loguru import logger 

21from pandas._libs.tslibs import OutOfBoundsDatetime 

22 

23 

24try: 

25 from obspy.core.utcdatetime import UTCDateTime # for type hinting 

26 

27 from_obspy = True 

28except ImportError: 

29 from_obspy = False 

30 

31from pydantic import ( 

32 BaseModel, 

33 ConfigDict, 

34 Field, 

35 field_validator, 

36 model_serializer, 

37 PrivateAttr, 

38 ValidationInfo, 

39) 

40 

41 

42# ============================================================================= 

43# Get leap seconds 

44# ============================================================================= 

45leap_second_dict = { 

46 0: {"min": datetime.date(1980, 1, 1), "max": datetime.date(1981, 7, 1)}, 

47 1: {"min": datetime.date(1981, 7, 1), "max": datetime.date(1982, 7, 1)}, 

48 2: {"min": datetime.date(1982, 7, 1), "max": datetime.date(1983, 7, 1)}, 

49 3: {"min": datetime.date(1983, 7, 1), "max": datetime.date(1985, 7, 1)}, 

50 4: {"min": datetime.date(1985, 7, 1), "max": datetime.date(1988, 1, 1)}, 

51 5: {"min": datetime.date(1988, 1, 1), "max": datetime.date(1990, 1, 1)}, 

52 6: {"min": datetime.date(1990, 1, 1), "max": datetime.date(1991, 1, 1)}, 

53 7: {"min": datetime.date(1991, 1, 1), "max": datetime.date(1992, 7, 1)}, 

54 8: {"min": datetime.date(1992, 7, 1), "max": datetime.date(1993, 7, 1)}, 

55 9: {"min": datetime.date(1993, 7, 1), "max": datetime.date(1994, 7, 1)}, 

56 10: {"min": datetime.date(1994, 7, 1), "max": datetime.date(1996, 1, 1)}, 

57 11: {"min": datetime.date(1996, 1, 1), "max": datetime.date(1997, 7, 1)}, 

58 12: {"min": datetime.date(1997, 7, 1), "max": datetime.date(1999, 1, 1)}, 

59 13: {"min": datetime.date(1999, 1, 1), "max": datetime.date(2006, 1, 1)}, 

60 14: {"min": datetime.date(2006, 1, 1), "max": datetime.date(2009, 1, 1)}, 

61 15: {"min": datetime.date(2009, 1, 1), "max": datetime.date(2012, 6, 30)}, 

62 16: {"min": datetime.date(2012, 7, 1), "max": datetime.date(2015, 7, 1)}, 

63 17: {"min": datetime.date(2015, 7, 1), "max": datetime.date(2016, 12, 31)}, 

64 18: {"min": datetime.date(2017, 1, 1), "max": datetime.date(2026, 7, 1)}, 

65} 

66 

67 

68def calculate_leap_seconds(year: int, month: int, day: int) -> int: 

69 """ 

70 Get the leap seconds for the given year to convert GPS time to UTC time. 

71 

72 GPS time started in 1980. GPS time is leap seconds ahead of UTC time, 

73 therefore you should subtract leap seconds from GPS time to get UTC time. 

74 

75 Parameters 

76 ---------- 

77 year : int 

78 Year of the date. 

79 month : int 

80 Month of the date (1-12). 

81 day : int 

82 Day of the date (1-31). 

83 

84 Returns 

85 ------- 

86 int 

87 Number of leap seconds for the given date. 

88 

89 Raises 

90 ------ 

91 ValueError 

92 If the date is outside the defined leap second range 

93 (1981-07-01 to 2026-07-01). 

94 

95 Notes 

96 ----- 

97 Leap seconds are defined for the following date ranges: 

98 

99 =========================== =============================================== 

100 Date Range Leap Seconds 

101 =========================== =============================================== 

102 1981-07-01 - 1982-07-01 1 

103 1982-07-01 - 1983-07-01 2 

104 1983-07-01 - 1985-07-01 3 

105 1985-07-01 - 1988-01-01 4 

106 1988-01-01 - 1990-01-01 5 

107 1990-01-01 - 1991-01-01 6 

108 1991-01-01 - 1992-07-01 7 

109 1992-07-01 - 1993-07-01 8 

110 1993-07-01 - 1994-07-01 9 

111 1994-07-01 - 1996-01-01 10 

112 1996-01-01 - 1997-07-01 11 

113 1997-07-01 - 1999-01-01 12 

114 1999-01-01 - 2006-01-01 13 

115 2006-01-01 - 2009-01-01 14 

116 2009-01-01 - 2012-07-01 15 

117 2012-07-01 - 2015-07-01 16 

118 2015-07-01 - 2017-01-01 17 

119 2017-01-01 - ????-??-?? 18 

120 =========================== =============================================== 

121 

122 """ 

123 

124 # make the date a datetime object, easier to test 

125 given_date = datetime.date(int(year), int(month), int(day)) 

126 

127 # made an executive decision that the date can be equal to the min, but 

128 # not the max, otherwise get an error. 

129 for leap_key in sorted(leap_second_dict.keys()): 

130 if ( 

131 given_date < leap_second_dict[leap_key]["max"] 

132 and given_date >= leap_second_dict[leap_key]["min"] 

133 ): 

134 return int(leap_key) 

135 

136 raise ValueError( 

137 f"Leap seconds not defined for date {year}-{month:02d}-{day:02d}. " 

138 "Leap seconds are defined from 1981-07-01 to 2026-07-01." 

139 ) 

140 

141 

142# ============================================================================= 

143# Functions for parsing time stamps 

144# ============================================================================= 

145def _localize_utc(stamp: pd.Timestamp) -> pd.Timestamp: 

146 """ 

147 Localize a timestamp to UTC timezone. 

148 

149 Forces a timestamp to have a timezone of UTC. If the timestamp is 

150 timezone-naive, it will be localized to UTC. 

151 

152 Parameters 

153 ---------- 

154 stamp : pd.Timestamp 

155 Input timestamp to be localized. 

156 

157 Returns 

158 ------- 

159 pd.Timestamp 

160 Timestamp with UTC timezone. 

161 """ 

162 

163 # check time zone and enforce UTC 

164 if stamp.tz is None: 

165 stamp = stamp.tz_localize("UTC").tz_convert("UTC") 

166 

167 return stamp 

168 

169 

170# minimum and maximum time stamps for pandas 

171# these are the minimum and maximum time stamps for pandas 

172TMIN = _localize_utc(pd.Timestamp.min) 

173TMAX = _localize_utc(pd.Timestamp.max) 

174 

175 

176def _parse_string(dt_str: str) -> datetime.datetime: 

177 """ 

178 Parse a datetime string with flexible day/month order handling. 

179 

180 Attempts to parse a datetime string using dateutil.parser. If parsing 

181 fails due to 24-hour format (T24:00:00), it converts to T23:00:00 and 

182 adds one hour. Also tries day-first parsing as a fallback. 

183 

184 Parameters 

185 ---------- 

186 dt_str : str 

187 Datetime string to parse (e.g., "2020-01-15T12:30:45"). 

188 

189 Returns 

190 ------- 

191 datetime.datetime 

192 Parsed datetime object. 

193 

194 Raises 

195 ------ 

196 ValueError 

197 If unable to parse the string using any of the attempted methods. 

198 Error message suggests proper formatting as YYYY-MM-DDThh:mm:ss.ns. 

199 """ 

200 

201 try: 

202 return dtparser(dt_str) 

203 except ValueError as ve: 

204 error_24h = "hour must be in 0..23" 

205 if error_24h in ve.args[0]: 

206 # hh=24 was supplied -- this is legal if it is midnight 

207 one_hour_earlier_dt_str = dt_str.replace("T24", "T23") 

208 one_hour_earlier_result = dtparser(one_hour_earlier_dt_str) 

209 result = one_hour_earlier_result + datetime.timedelta(hours=1) 

210 return result 

211 else: 

212 try: 

213 return dtparser(dt_str, dayfirst=True) 

214 except ValueError: 

215 msg = ( 

216 f"Could not parse string {dt_str} check formatting, " 

217 "should be YYYY-MM-DDThh:mm:ss.ns" 

218 ) 

219 logger.error(msg) 

220 raise ValueError(msg) 

221 

222 

223def _fix_out_of_bounds_time_stamp(dt): 

224 """ 

225 Fix timestamps that are outside pandas Timestamp bounds. 

226 

227 Checks if a datetime is outside the valid pandas timestamp range 

228 (approximately 1900-2200) and clamps it to the minimum or maximum 

229 allowed values. 

230 

231 Parameters 

232 ---------- 

233 dt : datetime.datetime 

234 Input datetime object to check. 

235 

236 Returns 

237 ------- 

238 stamp : pd.Timestamp 

239 Corrected timestamp within valid bounds. 

240 t_min_max : bool 

241 True if the timestamp was clamped to bounds, False otherwise. 

242 

243 Notes 

244 ----- 

245 If the year is greater than 2200, the timestamp is set to TMAX. 

246 If the year is less than 1900, the timestamp is set to TMIN. 

247 Otherwise, a UTC timezone is applied to the timestamp. 

248 """ 

249 t_min_max = False 

250 

251 if dt.year > 2200: 

252 logger.info(f"{dt} is too large setting to {TMAX}") 

253 stamp = TMAX 

254 t_min_max = True 

255 elif dt.year < 1900: 

256 logger.info(f"{dt} is too small setting to {TMIN}") 

257 stamp = TMIN 

258 t_min_max = True 

259 else: 

260 stamp = pd.Timestamp(dt, tz="UTC") 

261 

262 return stamp, t_min_max 

263 

264 

265def _check_timestamp(pd_timestamp): 

266 """ 

267 Check if timestamp is within allowed bounds and clamp if necessary. 

268 

269 Verifies that a pandas timestamp is within the minimum and maximum 

270 allowed time range. If outside bounds, clamps to the nearest boundary. 

271 

272 Parameters 

273 ---------- 

274 pd_timestamp : pd.Timestamp 

275 Input timestamp to validate. 

276 

277 Returns 

278 ------- 

279 t_min_max : bool 

280 True if the timestamp was clamped to bounds, False otherwise. 

281 pd_timestamp : pd.Timestamp 

282 Validated timestamp, potentially clamped to bounds and 

283 localized to UTC. 

284 """ 

285 

286 t_min_max = False 

287 pd_timestamp = _localize_utc(pd_timestamp) 

288 

289 if pd_timestamp <= TMIN: 

290 t_min_max = True 

291 pd_timestamp = TMIN 

292 elif pd_timestamp >= TMAX: 

293 t_min_max = True 

294 pd_timestamp = TMAX 

295 

296 return t_min_max, pd_timestamp 

297 

298 

299def parse( 

300 dt_str: Optional[ 

301 float | int | np.number | np.datetime64 | pd.Timestamp | str | dict 

302 ] = None, 

303 gps_time: bool = False, 

304) -> pd.Timestamp: 

305 """ 

306 Parse a datetime input into a pandas Timestamp with UTC timezone. 

307 

308 Accepts various input types and converts them to a standardized pandas 

309 Timestamp object. Handles special cases like GPS time conversion, 

310 nanosecond timestamps, and out-of-bounds dates. 

311 

312 Parameters 

313 ---------- 

314 dt_str : float, int, np.number, np.datetime64, pd.Timestamp, str, dict, or None, optional 

315 Input to be parsed. Can be: 

316 - None, empty string, or "none" variants: returns default 1980-01-01 

317 - float/int: interpreted as epoch seconds (or nanoseconds if large) 

318 - numpy numeric types: converted to standard Python types first 

319 - pd.Timestamp: validated and timezone-corrected 

320 - str: parsed using dateutil.parser with flexible formatting 

321 - dict: extracted time_stamp and gps_time fields 

322 Default is None. 

323 gps_time : bool, optional 

324 If True, converts GPS time to UTC by subtracting leap seconds. 

325 Default is False. 

326 

327 Returns 

328 ------- 

329 pd.Timestamp 

330 UTC-localized timestamp object. 

331 

332 Raises 

333 ------ 

334 ValueError 

335 If input is before GPS start time when gps_time=True. 

336 If string parsing fails with invalid format. 

337 

338 Notes 

339 ----- 

340 - Large numeric inputs (ratio > 1000 vs 3e8) are assumed to be nanoseconds 

341 - Timestamps outside pandas bounds are clamped to min/max values 

342 - GPS time conversion uses calculated leap seconds for the date 

343 - All outputs are forced to UTC timezone regardless of input timezone 

344 """ 

345 t_min_max = False 

346 if dt_str in [None, "", "none", "None", "NONE", "Na", {}]: 

347 logger.debug("Time string is None, setting to 1980-01-01:00:00:00") 

348 stamp = pd.Timestamp("1980-01-01T00:00:00+00:00", tz="UTC") 

349 

350 elif isinstance(dt_str, pd.Timestamp): 

351 t_min_max, stamp = _check_timestamp(dt_str) 

352 

353 elif isinstance(dt_str, (float, int, np.number)): 

354 # Convert numpy numbers to standard Python float/int for consistent handling 

355 if isinstance(dt_str, np.number): 

356 dt_str = float(dt_str) 

357 

358 # using 3E8 which is about the start of GPS time 

359 ratio = dt_str / 3e8 

360 if ratio < 1 and gps_time: 

361 raise ValueError( 

362 "Input is before GPS start time '1980/01/06', check value." 

363 ) 

364 elif ratio > 1e3: 

365 dt_str = dt_str / 1e9 

366 logger.debug( 

367 "Assuming input float/int is in nanoseconds, converting to seconds." 

368 ) 

369 # need to use utcfromtimestamp to avoid local time zone issues 

370 t_min_max, stamp = _check_timestamp(pd.Timestamp.utcfromtimestamp(dt_str)) 

371 

372 elif hasattr(dt_str, "isoformat"): 

373 try: 

374 t_min_max, stamp = _check_timestamp(pd.Timestamp(dt_str.isoformat())) 

375 except OutOfBoundsDatetime: 

376 stamp, t_min_max = _fix_out_of_bounds_time_stamp( 

377 _parse_string(dt_str.isoformat()) 

378 ) 

379 else: 

380 try: 

381 if isinstance(dt_str, dict): 

382 gps_time = dt_str.get("gps_time", gps_time) 

383 dt_str = dt_str.get("time_stamp", "1980-01-01T00:00:00+00:00") 

384 

385 t_min_max, stamp = _check_timestamp(pd.Timestamp(dt_str)) 

386 except (ValueError, TypeError, OutOfBoundsDatetime, OverflowError): 

387 dt = _parse_string(dt_str) 

388 stamp, t_min_max = _fix_out_of_bounds_time_stamp(dt) 

389 

390 if isinstance(stamp, (type(pd.NaT), type(None))): 

391 logger.debug("Time string is None, setting to 1980-01-01:00:00:00") 

392 stamp = pd.Timestamp("1980-01-01T00:00:00+00:00", tz="UTC") 

393 

394 # check time zone and enforce UTC 

395 stamp = _localize_utc(stamp) 

396 

397 # there can be a machine round off error, if it is close to 1 round to 

398 # microseconds 

399 if round(stamp.nanosecond / 1000) == 1 and not t_min_max: 

400 stamp = stamp.round(freq="us") 

401 

402 if gps_time: 

403 leap_seconds = calculate_leap_seconds(stamp.year, stamp.month, stamp.day) 

404 logger.debug("Converting GPS time to UTC with %s leap seconds", leap_seconds) 

405 stamp -= pd.Timedelta(seconds=leap_seconds) 

406 

407 return _localize_utc(stamp) 

408 

409 

410# ============================================================================== 

411# convenience date-time container 

412# ============================================================================== 

413class MTime(BaseModel): 

414 """ 

415 Date and time container based on pandas.Timestamp with UTC enforcement. 

416 

417 A flexible datetime container that accepts various input formats and 

418 converts them to a UTC-localized pandas.Timestamp object. Provides 

419 convenient access to date/time components and handles nanosecond precision. 

420 

421 Parameters 

422 ---------- 

423 time_stamp : float, int, np.number, np.datetime64, pd.Timestamp, str, or None, optional 

424 Input timestamp in various formats: 

425 - float/int: epoch seconds 

426 - np.number: numpy numeric types (converted to Python types) 

427 - np.datetime64: numpy datetime 

428 - pd.Timestamp: pandas timestamp (will be UTC-localized) 

429 - str: ISO format or parseable date string 

430 - None: defaults to 1980-01-01T00:00:00+00:00 

431 Default is None. 

432 gps_time : bool, optional 

433 If True, interprets time_stamp as GPS time and converts to UTC. 

434 Default is False. 

435 

436 Attributes 

437 ---------- 

438 time_stamp : pd.Timestamp 

439 The stored timestamp, always UTC-localized. 

440 gps_time : bool 

441 Whether GPS time conversion was applied. 

442 

443 Notes 

444 ----- 

445 The pandas.Timestamp backend allows nanosecond precision timing. 

446 

447 Input values outside pandas timestamp bounds are automatically clamped: 

448 - Values > 2200: set to pandas.Timestamp.max (2262-04-11 23:47:16.854775807) 

449 - Values < 1900: set to pandas.Timestamp.min (1677-09-21 00:12:43.145224193) 

450 

451 All timestamps are forced to UTC timezone regardless of input timezone. 

452 

453 Examples 

454 -------- 

455 Create from various input types: 

456 

457 >>> t = MTime() # Default time 

458 >>> t.isoformat() 

459 '1980-01-01T00:00:00+00:00' 

460 

461 >>> t = MTime(time_stamp="2020-01-15T12:30:45") 

462 >>> t.year 

463 2020 

464 

465 >>> t = MTime(time_stamp=1579095045.0) # Epoch seconds 

466 >>> t.isoformat() 

467 '2020-01-15T12:30:45+00:00' 

468 

469 Access and modify components: 

470 

471 >>> t.year = 2025 

472 >>> t.month = 12 

473 >>> t.day = 31 

474 >>> t.epoch_seconds 

475 1767225045.0 

476 """ 

477 

478 _default_time: str = PrivateAttr("1980-01-01T00:00:00+00:00") 

479 

480 model_config = ConfigDict( 

481 arbitrary_types_allowed=True, 

482 ) 

483 

484 gps_time: Annotated[ 

485 bool, 

486 Field( 

487 description="Defines if the time give in GPS time [True] or UTC [False]", 

488 default=False, 

489 json_schema_extra={ 

490 "units": None, 

491 "required": False, 

492 "examples": [True, False], 

493 }, 

494 ), 

495 ] = False 

496 

497 time_stamp: Annotated[ 

498 float | int | np.number | np.datetime64 | pd.Timestamp | str | None, 

499 Field( 

500 default_factory=lambda: pd.Timestamp(MTime._default_time.default), 

501 # default_factory=lambda: pd.Timestamp("1980-01-01T00:00:00+00:00"), 

502 description="Time in UTC format", 

503 examples=["1980-01-01T00:00:00+00:00"], 

504 ), 

505 ] 

506 

507 @field_validator("time_stamp", mode="before") 

508 @classmethod 

509 def validate_time_stamp( 

510 cls, 

511 field_value: float | int | np.datetime64 | pd.Timestamp | str, 

512 validation_info: ValidationInfo, 

513 ) -> pd.Timestamp: 

514 """ 

515 Validate and convert input timestamp to pandas Timestamp. 

516 

517 Pydantic field validator that processes various timestamp input formats 

518 and converts them to a standardized UTC pandas Timestamp object. 

519 

520 Parameters 

521 ---------- 

522 field_value : float, int, np.datetime64, pd.Timestamp, str, or UTCDateTime 

523 Input timestamp value in any supported format. 

524 validation_info : ValidationInfo 

525 Pydantic validation context containing model data including gps_time setting. 

526 

527 Returns 

528 ------- 

529 pd.Timestamp 

530 UTC-localized timestamp object, clamped to pandas bounds if necessary. 

531 

532 Notes 

533 ----- 

534 This method is automatically called during model instantiation. 

535 GPS time conversion is applied if gps_time=True in the model data. 

536 Out-of-bounds timestamps are automatically clamped to valid ranges. 

537 """ 

538 # Check if the time_stamp is a string and parse it 

539 return parse(field_value, gps_time=validation_info.data["gps_time"]) 

540 

541 @model_serializer 

542 def _serialize_model(self): 

543 """ 

544 Custom serializer to handle pandas.Timestamp serialization. 

545 

546 Returns 

547 ------- 

548 dict 

549 Serialized model with Timestamp as ISO format string. 

550 """ 

551 return { 

552 "time_stamp": ( 

553 self.time_stamp.isoformat() 

554 if isinstance(self.time_stamp, pd.Timestamp) 

555 else self.time_stamp 

556 ), 

557 "gps_time": self.gps_time, 

558 } 

559 

560 def __str__(self) -> str: 

561 """ 

562 Represents the object as a string in ISO format. 

563 

564 Returns 

565 ------- 

566 str 

567 ISO formatted string of the time stamp. 

568 """ 

569 return self.isoformat() 

570 

571 def __repr__(self) -> str: 

572 """ 

573 Represents the object as a string in ISO format. 

574 

575 Returns 

576 ------- 

577 str 

578 ISO formatted string of the time stamp. 

579 """ 

580 return self.isoformat() 

581 

582 def __eq__( 

583 self, 

584 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

585 ) -> bool: 

586 """ 

587 Checks if the time stamp is equal to another time stamp. 

588 This function is used to compare two time stamps and check if they are 

589 equal. 

590 

591 The input will be parsed first into a pd.Timestamp object and then 

592 compared to the current time stamp. 

593 

594 Parameters 

595 ---------- 

596 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

597 other time stamp to compare to 

598 

599 Returns 

600 ------- 

601 bool 

602 if equal return True, otherwise False 

603 """ 

604 if not isinstance(other, MTime): 

605 try: 

606 other = MTime(time_stamp=other) 

607 except Exception as e: 

608 logger.debug(f"Failed to convert {other} to MTime: {e}") 

609 return False 

610 

611 epoch_seconds = bool(self.time_stamp.value == other.time_stamp.value) 

612 

613 tz = bool(self.time_stamp.tz == other.time_stamp.tz) 

614 

615 if epoch_seconds and tz: 

616 return True 

617 elif epoch_seconds and not tz: 

618 logger.info( 

619 f"Time zones are not equal {self.time_stamp.tz} != " 

620 f"{other.time_stamp.tz}" 

621 ) 

622 return False 

623 elif not epoch_seconds: 

624 return False 

625 

626 def __ne__( 

627 self, 

628 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

629 ) -> bool: 

630 """ 

631 Checks if the time stamp is not equal to another time stamp. 

632 

633 Parameters 

634 ---------- 

635 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

636 other time stamp to compare to 

637 

638 Returns 

639 ------- 

640 bool 

641 True if not equal, otherwise False 

642 """ 

643 return not self.__eq__(other) 

644 

645 def __lt__( 

646 self, 

647 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

648 ) -> bool: 

649 """ 

650 Checks if the other is less than the current time stamp. 

651 

652 Parameters 

653 ---------- 

654 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

655 other time stamp to compare to 

656 

657 Returns 

658 ------- 

659 bool 

660 _True if other is less than the current time stamp, otherwise False 

661 """ 

662 if not isinstance(other, MTime): 

663 other = MTime(time_stamp=other) 

664 

665 return bool(self.time_stamp < other.time_stamp) 

666 

667 def __le__( 

668 self, 

669 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

670 ) -> bool: 

671 """ 

672 Checks if the other is less than or equal to the current time stamp. 

673 

674 Parameters 

675 ---------- 

676 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

677 other time stamp to compare to 

678 

679 Returns 

680 ------- 

681 _type_ 

682 True if other is less than or equal to the current time stamp, 

683 otherwise False 

684 """ 

685 if not isinstance(other, MTime): 

686 other = MTime(time_stamp=other) 

687 

688 return bool(self.time_stamp <= other.time_stamp) 

689 

690 def __gt__( 

691 self, 

692 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

693 ) -> bool: 

694 """ 

695 Checks if the other is greater than the current time stamp. 

696 

697 Parameters 

698 ---------- 

699 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

700 other time stamp to compare to 

701 

702 Returns 

703 ------- 

704 bool 

705 True if other is greater than the current time stamp, otherwise False 

706 """ 

707 return not self.__lt__(other) 

708 

709 def __ge__( 

710 self, 

711 other: float | int | np.datetime64 | pd.Timestamp | str, # | UTCDateTime 

712 ) -> bool: 

713 """ 

714 Checks if the other is greater than or equal to the current time stamp. 

715 

716 Parameters 

717 ---------- 

718 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime 

719 other time stamp to compare to 

720 

721 Returns 

722 ------- 

723 bool 

724 True if other is greater than or equal to the current time stamp, 

725 otherwise False 

726 """ 

727 if not isinstance(other, MTime): 

728 other = MTime(time_stamp=other) 

729 

730 return bool(self.time_stamp >= other.time_stamp) 

731 

732 def __add__( 

733 self, other: int | float | datetime.timedelta | np.timedelta64 

734 ) -> "MTime": 

735 """ 

736 Add time to the existing time stamp. Must be a time delta object 

737 or a number in seconds. 

738 

739 .. note:: Adding two time stamps does not make sense, use either 

740 pd.Timedelta or seconds as a float or int. 

741 

742 """ 

743 if isinstance(other, (int, float)): 

744 other = pd.Timedelta(seconds=other) 

745 logger.debug("Assuming other time is in seconds") 

746 

747 elif isinstance(other, (datetime.timedelta, np.timedelta64)): 

748 other = pd.Timedelta(other) 

749 

750 if not isinstance(other, (pd.Timedelta)): 

751 msg = ( 

752 "Adding times stamps does not make sense, use either " 

753 "pd.Timedelta or seconds as a float or int." 

754 ) 

755 logger.error(msg) 

756 raise ValueError(msg) 

757 

758 return MTime(time_stamp=self.time_stamp + other) 

759 

760 def __sub__( 

761 self, other: int | float | datetime.timedelta | np.timedelta64 

762 ) -> "MTime": 

763 """ 

764 Get the time difference between to times in seconds. 

765 

766 :param other: other time value 

767 :type other: [ str | float | int | datetime.datetime | np.datetime64 ] 

768 :return: time difference in seconds 

769 :rtype: float 

770 

771 """ 

772 

773 if isinstance(other, (int, float)): 

774 other = pd.Timedelta(seconds=other) 

775 logger.info("Assuming other time is in seconds and not epoch seconds.") 

776 

777 elif isinstance(other, (datetime.timedelta, np.timedelta64)): 

778 other = pd.Timedelta(other) 

779 

780 else: 

781 try: 

782 other = MTime(time_stamp=other) 

783 except ValueError as error: 

784 raise TypeError(error) 

785 

786 if not isinstance(other, (pd.Timedelta, MTime)): 

787 msg = "Subtracting times must be either timedelta or another time." 

788 logger.error(msg) 

789 raise ValueError(msg) 

790 

791 if isinstance(other, MTime): 

792 other = MTime(time_stamp=other) 

793 

794 return (self.time_stamp - other.time_stamp).total_seconds() 

795 

796 elif isinstance(other, pd.Timedelta): 

797 return MTime(time_stamp=self.time_stamp - other) 

798 

799 def __hash__(self) -> int: 

800 return hash(self.isoformat()) 

801 

802 def is_default(self) -> bool: 

803 """ 

804 Test if the time_stamp value is the default value 

805 """ 

806 return self.time_stamp == pd.Timestamp(self._default_time) 

807 

808 def to_dict(self, nested=False, single=False, required=True) -> str: 

809 """ 

810 Convert the time stamp to a dictionary with the ISO format string. 

811 

812 Returns 

813 ------- 

814 str 

815 The ISO format string. 

816 """ 

817 return self.isoformat() 

818 

819 def from_dict( 

820 self, 

821 value: str | int | float | np.datetime64 | pd.Timestamp, 

822 skip_none=False, 

823 ) -> None: 

824 """ 

825 This will have to accept just a single value, not a dict. 

826 This is to keep original functionality. 

827 

828 Parameters 

829 ---------- 

830 value : str | int | float | np.datetime64 | pd.Timestamp 

831 time stamp value 

832 """ 

833 self.time_stamp = value 

834 

835 @property 

836 def iso_str(self) -> str: 

837 """ 

838 

839 Returns 

840 ------- 

841 str 

842 ISO formatted string of the time stamp. 

843 """ 

844 logger.warning( 

845 "iso_str will be deprecated in the future. Use isoformat() instead" 

846 ) 

847 return self.time_stamp.isoformat() 

848 

849 @property 

850 def iso_no_tz(self) -> str: 

851 """ 

852 ISO formatted string of the time stamp without the timezone. 

853 This is useful for storing the time stamp in a database or other 

854 format where the timezone is not needed. 

855 

856 Returns 

857 ------- 

858 str 

859 ISO formatted string of the time stamp without the timezone. 

860 """ 

861 return self.time_stamp.isoformat().split("+", 1)[0] 

862 

863 @property 

864 def epoch_seconds(self) -> float: 

865 """ 

866 Epoch seconds of the time stamp. This is the number of seconds 

867 since the epoch (1970-01-01 00:00:00 UTC). 

868 

869 Returns 

870 ------- 

871 float 

872 epoch seconds of the time stamp. 

873 """ 

874 return self.time_stamp.timestamp() 

875 

876 @epoch_seconds.setter 

877 def epoch_seconds(self, seconds: float | int) -> None: 

878 """ 

879 Sets the time stamp to the given epoch seconds. This is the number of 

880 seconds since the epoch (1970-01-01 00:00:00 UTC). 

881 

882 Parameters 

883 ---------- 

884 seconds : float | int 

885 epoch seconds for the time stamp. 

886 """ 

887 logger.debug("reading time from epoch seconds, assuming UTC time zone") 

888 # has to be seconds 

889 self.time_stamp = pd.Timestamp(seconds, tz="UTC", unit="s") 

890 

891 @property 

892 def date(self) -> str: 

893 """ 

894 Date in ISO format. This is the date part of the time stamp 

895 without the time part. This is useful for storing the date in a 

896 database or other format where the time is not needed. 

897 The date is in the format YYYY-MM-DD. 

898 

899 Returns 

900 ------- 

901 str 

902 ISO formatted date string of the time stamp. 

903 """ 

904 return self.time_stamp.date().isoformat() 

905 

906 @property 

907 def year(self) -> int: 

908 """ 

909 Year of the time stamp 

910 

911 Returns 

912 ------- 

913 int 

914 year of the time stamp 

915 """ 

916 return self.time_stamp.year 

917 

918 @year.setter 

919 def year(self, value: int) -> None: 

920 """ 

921 Sets the year of the time stamp to the given value. This is the 

922 year part of the time stamp. This is useful for setting the year 

923 of the time stamp to a specific value. 

924 The year is in the format YYYY. 

925 

926 Parameters 

927 ---------- 

928 value : int 

929 New year value for the time stamp. 

930 """ 

931 self.time_stamp = self.time_stamp.replace(year=value) 

932 

933 @property 

934 def month(self) -> int: 

935 """ 

936 Month of the time stamp. This is the month part of the time stamp 

937 without the time part. This is useful for storing the month in a 

938 database or other format where the time is not needed. 

939 

940 Returns 

941 ------- 

942 int 

943 month of time stamp 

944 """ 

945 return self.time_stamp.month 

946 

947 @month.setter 

948 def month(self, value: int) -> None: 

949 """ 

950 Sets the month of the time stamp to the given value. This is the 

951 month part of the time stamp. This is useful for setting the month 

952 

953 

954 Parameters 

955 ---------- 

956 value : int 

957 new month value for the time stamp. 

958 """ 

959 self.time_stamp = self.time_stamp.replace(month=value) 

960 

961 @property 

962 def day(self) -> int: 

963 """ 

964 Day of the time stamp. This is the day part of the time stamp 

965 without the time part. 

966 

967 Returns 

968 ------- 

969 int 

970 Day of the time stamp 

971 """ 

972 return self.time_stamp.day 

973 

974 @day.setter 

975 def day(self, value: int) -> None: 

976 """ 

977 Sets the day of the time stamp to the given value. This is the 

978 day part of the time stamp. 

979 

980 Parameters 

981 ---------- 

982 value : int 

983 new day of the time stamp 

984 """ 

985 self.time_stamp = self.time_stamp.replace(day=value) 

986 

987 @property 

988 def hour(self) -> int: 

989 """ 

990 Hour of the time stamp. 

991 

992 Returns 

993 ------- 

994 int 

995 hour of the time stamp 

996 """ 

997 return self.time_stamp.hour 

998 

999 @hour.setter 

1000 def hour(self, value: int) -> None: 

1001 """ 

1002 Sets the hour of the time stamp to the given value. 

1003 

1004 Parameters 

1005 ---------- 

1006 value : int 

1007 new hour of the time stamp 

1008 """ 

1009 self.time_stamp = self.time_stamp.replace(hour=value) 

1010 

1011 @property 

1012 def minutes(self) -> int: 

1013 return self.time_stamp.minute 

1014 

1015 @minutes.setter 

1016 def minutes(self, value: int) -> None: 

1017 self.time_stamp = self.time_stamp.replace(minute=value) 

1018 

1019 @property 

1020 def seconds(self) -> int: 

1021 return self.time_stamp.second 

1022 

1023 @seconds.setter 

1024 def seconds(self, value: int) -> None: 

1025 self.time_stamp = self.time_stamp.replace(second=value) 

1026 

1027 @property 

1028 def microseconds(self) -> int: 

1029 return self.time_stamp.microsecond 

1030 

1031 @microseconds.setter 

1032 def microseconds(self, value: int) -> None: 

1033 self.time_stamp = self.time_stamp.replace(microsecond=value) 

1034 

1035 @property 

1036 def nanoseconds(self) -> int: 

1037 return self.time_stamp.nanosecond 

1038 

1039 @nanoseconds.setter 

1040 def nanoseconds(self, value: int) -> None: 

1041 self.time_stamp = self.time_stamp.replace(nanosecond=value) 

1042 

1043 def now(self) -> "MTime": 

1044 """ 

1045 The current time in UTC format. 

1046 

1047 Returns 

1048 ------- 

1049 MTime 

1050 The current time as an MTime object. 

1051 """ 

1052 self.time_stamp = pd.Timestamp.utcnow() 

1053 

1054 return self 

1055 

1056 def copy(self) -> "MTime": 

1057 """make a copy of the time""" 

1058 return self.model_copy(deep=True) 

1059 

1060 def isoformat(self) -> str: 

1061 """ 

1062 ISO formatted string of the time stamp. This is the ISO format 

1063 string of the time stamp. 

1064 

1065 formatted as: YYYY-MM-DDThh:mm:ss.ssssss+00:00 

1066 

1067 Returns 

1068 ------- 

1069 str 

1070 ISO formatted date time string 

1071 """ 

1072 return self.time_stamp.isoformat() 

1073 

1074 def isodate(self) -> str: 

1075 """ 

1076 ISO formatted date string of the time stamp. This is the ISO format 

1077 string of the date part of the time stamp. 

1078 

1079 formatted as: YYYY-MM-DD 

1080 

1081 Returns 

1082 ------- 

1083 str 

1084 _description_ 

1085 """ 

1086 return self.time_stamp.date().isoformat() 

1087 

1088 def isocalendar(self) -> str: 

1089 """ 

1090 ISO formatted calendar string of the time stamp. This is the ISO 

1091 format string of the calendar part of the time stamp. 

1092 

1093 Formatted as: YYYY-WW-D 

1094 where YYYY is the year, WW is the week number, and D is the day of 

1095 the week. 

1096 

1097 Returns 

1098 ------- 

1099 str 

1100 ISO formatted calendar string of the time stamp. 

1101 """ 

1102 return self.time_stamp.isocalendar() 

1103 

1104 

1105def get_now_utc() -> "MTime": 

1106 """ 

1107 Get the current UTC time as an MTime object. 

1108 

1109 Creates an MTime instance set to the current UTC time. 

1110 

1111 Returns 

1112 ------- 

1113 str 

1114 ISO format string of the current UTC time. 

1115 

1116 Notes 

1117 ----- 

1118 Despite the return type annotation suggesting MTime, this function 

1119 actually returns an ISO format string from the MTime object. 

1120 """ 

1121 

1122 m_obj = MTime() 

1123 m_obj.now() 

1124 return m_obj.isoformat() 

1125 

1126 

1127class MDate(MTime): 

1128 def __str__(self) -> str: 

1129 """ 

1130 Represents the object as a string in ISO format. 

1131 

1132 Returns 

1133 ------- 

1134 str 

1135 ISO formatted string of the time stamp. 

1136 """ 

1137 return self.isodate() 

1138 

1139 def __repr__(self) -> str: 

1140 """ 

1141 Represents the object as a string in ISO format. 

1142 

1143 Returns 

1144 ------- 

1145 str 

1146 ISO formatted string of the time stamp. 

1147 """ 

1148 return self.isodate() 

1149 

1150 def __add__( 

1151 self, other: int | float | datetime.timedelta | np.timedelta64 

1152 ) -> "MTime": 

1153 """ 

1154 Add time to the existing time stamp. Must be a time delta object 

1155 or a number in seconds. 

1156 

1157 .. note:: Adding two time stamps does not make sense, use either 

1158 pd.Timedelta or seconds as a float or int. 

1159 

1160 """ 

1161 if isinstance(other, (int, float)): 

1162 other = pd.Timedelta(days=other) 

1163 logger.debug("Assuming other time is in days") 

1164 

1165 elif isinstance(other, (datetime.timedelta, np.timedelta64)): 

1166 other = pd.Timedelta(other) 

1167 

1168 if not isinstance(other, (pd.Timedelta)): 

1169 msg = ( 

1170 "Adding times stamps does not make sense, use either " 

1171 "pd.Timedelta or seconds as a float or int." 

1172 ) 

1173 logger.error(msg) 

1174 raise ValueError(msg) 

1175 

1176 return MDate(time_stamp=self.time_stamp + other) 

1177 

1178 def __sub__( 

1179 self, other: int | float | datetime.timedelta | np.timedelta64 

1180 ) -> "MTime": 

1181 """ 

1182 Get the time difference between to times in seconds. 

1183 

1184 :param other: other time value 

1185 :type other: [ str | float | int | datetime.datetime | np.datetime64 ] 

1186 :return: time difference in seconds 

1187 :rtype: float 

1188 

1189 """ 

1190 

1191 if isinstance(other, (int, float)): 

1192 other = pd.Timedelta(days=other) 

1193 logger.info("Assuming other time is in seconds and not epoch seconds.") 

1194 

1195 elif isinstance(other, (datetime.timedelta, np.timedelta64)): 

1196 other = pd.Timedelta(other) 

1197 

1198 else: 

1199 try: 

1200 other = MTime(time_stamp=other) 

1201 except ValueError as error: 

1202 raise TypeError(error) 

1203 

1204 if not isinstance(other, (pd.Timedelta, MDate, MTime)): 

1205 msg = "Subtracting times must be either timedelta or another time." 

1206 logger.error(msg) 

1207 raise ValueError(msg) 

1208 

1209 if isinstance(other, (MDate, MTime)): 

1210 other = MDate(time_stamp=other) 

1211 

1212 return (self.time_stamp - other.time_stamp).total_seconds() / 86400 

1213 

1214 elif isinstance(other, pd.Timedelta): 

1215 return MDate(time_stamp=self.time_stamp - other) 

1216 

1217 def __hash__(self) -> int: 

1218 return hash(self.isodate()) 

1219 

1220 def is_default(self): 

1221 """ 

1222 Test if time_stamp is the default value 

1223 """ 

1224 return self.isoformat() == pd.Timestamp(self._default_time).date().isoformat() 

1225 

1226 def isoformat(self) -> str: 

1227 """ 

1228 ISO formatted string of the time stamp. This is the ISO format 

1229 string of the time stamp. 

1230 

1231 formatted as: YYYY-MM-DDThh:mm:ss.ssssss+00:00 

1232 

1233 Returns 

1234 ------- 

1235 str 

1236 ISO formatted date time string 

1237 """ 

1238 return self.isodate() 

1239 

1240 def to_dict(self, nested=False, single=False, required=True) -> str: 

1241 """ 

1242 Convert the time stamp to a dictionary with the ISO format string. 

1243 

1244 Returns 

1245 ------- 

1246 str 

1247 The ISO format string. 

1248 """ 

1249 return self.isodate() 

1250 

1251 def from_dict( 

1252 self, 

1253 value: str | int | float | np.datetime64 | pd.Timestamp, 

1254 skip_none=False, 

1255 ) -> None: 

1256 """ 

1257 This will have to accept just a single value, not a dict. 

1258 This is to keep original functionality. 

1259 

1260 Parameters 

1261 ---------- 

1262 value : str | int | float | np.datetime64 | pd.Timestamp 

1263 time stamp value 

1264 """ 

1265 self.time_stamp = value