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
« 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
5For dealing with obsy.core.UTCDatetime and an soft requirement imports
6have a look at https://github.com/pydantic/pydantic/discussions/3673.
8@author: jpeacock
9"""
10import datetime
12# =============================================================================
13# IMPORTS
14# =============================================================================
15from typing import Annotated, Optional
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
24try:
25 from obspy.core.utcdatetime import UTCDateTime # for type hinting
27 from_obspy = True
28except ImportError:
29 from_obspy = False
31from pydantic import (
32 BaseModel,
33 ConfigDict,
34 Field,
35 field_validator,
36 model_serializer,
37 PrivateAttr,
38 ValidationInfo,
39)
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}
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.
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.
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).
84 Returns
85 -------
86 int
87 Number of leap seconds for the given date.
89 Raises
90 ------
91 ValueError
92 If the date is outside the defined leap second range
93 (1981-07-01 to 2026-07-01).
95 Notes
96 -----
97 Leap seconds are defined for the following date ranges:
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 =========================== ===============================================
122 """
124 # make the date a datetime object, easier to test
125 given_date = datetime.date(int(year), int(month), int(day))
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)
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 )
142# =============================================================================
143# Functions for parsing time stamps
144# =============================================================================
145def _localize_utc(stamp: pd.Timestamp) -> pd.Timestamp:
146 """
147 Localize a timestamp to UTC timezone.
149 Forces a timestamp to have a timezone of UTC. If the timestamp is
150 timezone-naive, it will be localized to UTC.
152 Parameters
153 ----------
154 stamp : pd.Timestamp
155 Input timestamp to be localized.
157 Returns
158 -------
159 pd.Timestamp
160 Timestamp with UTC timezone.
161 """
163 # check time zone and enforce UTC
164 if stamp.tz is None:
165 stamp = stamp.tz_localize("UTC").tz_convert("UTC")
167 return stamp
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)
176def _parse_string(dt_str: str) -> datetime.datetime:
177 """
178 Parse a datetime string with flexible day/month order handling.
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.
184 Parameters
185 ----------
186 dt_str : str
187 Datetime string to parse (e.g., "2020-01-15T12:30:45").
189 Returns
190 -------
191 datetime.datetime
192 Parsed datetime object.
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 """
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)
223def _fix_out_of_bounds_time_stamp(dt):
224 """
225 Fix timestamps that are outside pandas Timestamp bounds.
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.
231 Parameters
232 ----------
233 dt : datetime.datetime
234 Input datetime object to check.
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.
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
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")
262 return stamp, t_min_max
265def _check_timestamp(pd_timestamp):
266 """
267 Check if timestamp is within allowed bounds and clamp if necessary.
269 Verifies that a pandas timestamp is within the minimum and maximum
270 allowed time range. If outside bounds, clamps to the nearest boundary.
272 Parameters
273 ----------
274 pd_timestamp : pd.Timestamp
275 Input timestamp to validate.
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 """
286 t_min_max = False
287 pd_timestamp = _localize_utc(pd_timestamp)
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
296 return t_min_max, pd_timestamp
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.
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.
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.
327 Returns
328 -------
329 pd.Timestamp
330 UTC-localized timestamp object.
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.
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")
350 elif isinstance(dt_str, pd.Timestamp):
351 t_min_max, stamp = _check_timestamp(dt_str)
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)
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))
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")
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)
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")
394 # check time zone and enforce UTC
395 stamp = _localize_utc(stamp)
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")
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)
407 return _localize_utc(stamp)
410# ==============================================================================
411# convenience date-time container
412# ==============================================================================
413class MTime(BaseModel):
414 """
415 Date and time container based on pandas.Timestamp with UTC enforcement.
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.
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.
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.
443 Notes
444 -----
445 The pandas.Timestamp backend allows nanosecond precision timing.
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)
451 All timestamps are forced to UTC timezone regardless of input timezone.
453 Examples
454 --------
455 Create from various input types:
457 >>> t = MTime() # Default time
458 >>> t.isoformat()
459 '1980-01-01T00:00:00+00:00'
461 >>> t = MTime(time_stamp="2020-01-15T12:30:45")
462 >>> t.year
463 2020
465 >>> t = MTime(time_stamp=1579095045.0) # Epoch seconds
466 >>> t.isoformat()
467 '2020-01-15T12:30:45+00:00'
469 Access and modify components:
471 >>> t.year = 2025
472 >>> t.month = 12
473 >>> t.day = 31
474 >>> t.epoch_seconds
475 1767225045.0
476 """
478 _default_time: str = PrivateAttr("1980-01-01T00:00:00+00:00")
480 model_config = ConfigDict(
481 arbitrary_types_allowed=True,
482 )
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
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 ]
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.
517 Pydantic field validator that processes various timestamp input formats
518 and converts them to a standardized UTC pandas Timestamp object.
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.
527 Returns
528 -------
529 pd.Timestamp
530 UTC-localized timestamp object, clamped to pandas bounds if necessary.
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"])
541 @model_serializer
542 def _serialize_model(self):
543 """
544 Custom serializer to handle pandas.Timestamp serialization.
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 }
560 def __str__(self) -> str:
561 """
562 Represents the object as a string in ISO format.
564 Returns
565 -------
566 str
567 ISO formatted string of the time stamp.
568 """
569 return self.isoformat()
571 def __repr__(self) -> str:
572 """
573 Represents the object as a string in ISO format.
575 Returns
576 -------
577 str
578 ISO formatted string of the time stamp.
579 """
580 return self.isoformat()
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.
591 The input will be parsed first into a pd.Timestamp object and then
592 compared to the current time stamp.
594 Parameters
595 ----------
596 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
597 other time stamp to compare to
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
611 epoch_seconds = bool(self.time_stamp.value == other.time_stamp.value)
613 tz = bool(self.time_stamp.tz == other.time_stamp.tz)
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
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.
633 Parameters
634 ----------
635 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
636 other time stamp to compare to
638 Returns
639 -------
640 bool
641 True if not equal, otherwise False
642 """
643 return not self.__eq__(other)
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.
652 Parameters
653 ----------
654 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
655 other time stamp to compare to
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)
665 return bool(self.time_stamp < other.time_stamp)
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.
674 Parameters
675 ----------
676 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
677 other time stamp to compare to
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)
688 return bool(self.time_stamp <= other.time_stamp)
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.
697 Parameters
698 ----------
699 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
700 other time stamp to compare to
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)
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.
716 Parameters
717 ----------
718 other : float | int | np.datetime64 | pd.Timestamp | str | UTCDateTime
719 other time stamp to compare to
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)
730 return bool(self.time_stamp >= other.time_stamp)
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.
739 .. note:: Adding two time stamps does not make sense, use either
740 pd.Timedelta or seconds as a float or int.
742 """
743 if isinstance(other, (int, float)):
744 other = pd.Timedelta(seconds=other)
745 logger.debug("Assuming other time is in seconds")
747 elif isinstance(other, (datetime.timedelta, np.timedelta64)):
748 other = pd.Timedelta(other)
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)
758 return MTime(time_stamp=self.time_stamp + other)
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.
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
771 """
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.")
777 elif isinstance(other, (datetime.timedelta, np.timedelta64)):
778 other = pd.Timedelta(other)
780 else:
781 try:
782 other = MTime(time_stamp=other)
783 except ValueError as error:
784 raise TypeError(error)
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)
791 if isinstance(other, MTime):
792 other = MTime(time_stamp=other)
794 return (self.time_stamp - other.time_stamp).total_seconds()
796 elif isinstance(other, pd.Timedelta):
797 return MTime(time_stamp=self.time_stamp - other)
799 def __hash__(self) -> int:
800 return hash(self.isoformat())
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)
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.
812 Returns
813 -------
814 str
815 The ISO format string.
816 """
817 return self.isoformat()
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.
828 Parameters
829 ----------
830 value : str | int | float | np.datetime64 | pd.Timestamp
831 time stamp value
832 """
833 self.time_stamp = value
835 @property
836 def iso_str(self) -> str:
837 """
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()
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.
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]
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).
869 Returns
870 -------
871 float
872 epoch seconds of the time stamp.
873 """
874 return self.time_stamp.timestamp()
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).
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")
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.
899 Returns
900 -------
901 str
902 ISO formatted date string of the time stamp.
903 """
904 return self.time_stamp.date().isoformat()
906 @property
907 def year(self) -> int:
908 """
909 Year of the time stamp
911 Returns
912 -------
913 int
914 year of the time stamp
915 """
916 return self.time_stamp.year
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.
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)
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.
940 Returns
941 -------
942 int
943 month of time stamp
944 """
945 return self.time_stamp.month
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
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)
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.
967 Returns
968 -------
969 int
970 Day of the time stamp
971 """
972 return self.time_stamp.day
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.
980 Parameters
981 ----------
982 value : int
983 new day of the time stamp
984 """
985 self.time_stamp = self.time_stamp.replace(day=value)
987 @property
988 def hour(self) -> int:
989 """
990 Hour of the time stamp.
992 Returns
993 -------
994 int
995 hour of the time stamp
996 """
997 return self.time_stamp.hour
999 @hour.setter
1000 def hour(self, value: int) -> None:
1001 """
1002 Sets the hour of the time stamp to the given value.
1004 Parameters
1005 ----------
1006 value : int
1007 new hour of the time stamp
1008 """
1009 self.time_stamp = self.time_stamp.replace(hour=value)
1011 @property
1012 def minutes(self) -> int:
1013 return self.time_stamp.minute
1015 @minutes.setter
1016 def minutes(self, value: int) -> None:
1017 self.time_stamp = self.time_stamp.replace(minute=value)
1019 @property
1020 def seconds(self) -> int:
1021 return self.time_stamp.second
1023 @seconds.setter
1024 def seconds(self, value: int) -> None:
1025 self.time_stamp = self.time_stamp.replace(second=value)
1027 @property
1028 def microseconds(self) -> int:
1029 return self.time_stamp.microsecond
1031 @microseconds.setter
1032 def microseconds(self, value: int) -> None:
1033 self.time_stamp = self.time_stamp.replace(microsecond=value)
1035 @property
1036 def nanoseconds(self) -> int:
1037 return self.time_stamp.nanosecond
1039 @nanoseconds.setter
1040 def nanoseconds(self, value: int) -> None:
1041 self.time_stamp = self.time_stamp.replace(nanosecond=value)
1043 def now(self) -> "MTime":
1044 """
1045 The current time in UTC format.
1047 Returns
1048 -------
1049 MTime
1050 The current time as an MTime object.
1051 """
1052 self.time_stamp = pd.Timestamp.utcnow()
1054 return self
1056 def copy(self) -> "MTime":
1057 """make a copy of the time"""
1058 return self.model_copy(deep=True)
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.
1065 formatted as: YYYY-MM-DDThh:mm:ss.ssssss+00:00
1067 Returns
1068 -------
1069 str
1070 ISO formatted date time string
1071 """
1072 return self.time_stamp.isoformat()
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.
1079 formatted as: YYYY-MM-DD
1081 Returns
1082 -------
1083 str
1084 _description_
1085 """
1086 return self.time_stamp.date().isoformat()
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.
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.
1097 Returns
1098 -------
1099 str
1100 ISO formatted calendar string of the time stamp.
1101 """
1102 return self.time_stamp.isocalendar()
1105def get_now_utc() -> "MTime":
1106 """
1107 Get the current UTC time as an MTime object.
1109 Creates an MTime instance set to the current UTC time.
1111 Returns
1112 -------
1113 str
1114 ISO format string of the current UTC time.
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 """
1122 m_obj = MTime()
1123 m_obj.now()
1124 return m_obj.isoformat()
1127class MDate(MTime):
1128 def __str__(self) -> str:
1129 """
1130 Represents the object as a string in ISO format.
1132 Returns
1133 -------
1134 str
1135 ISO formatted string of the time stamp.
1136 """
1137 return self.isodate()
1139 def __repr__(self) -> str:
1140 """
1141 Represents the object as a string in ISO format.
1143 Returns
1144 -------
1145 str
1146 ISO formatted string of the time stamp.
1147 """
1148 return self.isodate()
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.
1157 .. note:: Adding two time stamps does not make sense, use either
1158 pd.Timedelta or seconds as a float or int.
1160 """
1161 if isinstance(other, (int, float)):
1162 other = pd.Timedelta(days=other)
1163 logger.debug("Assuming other time is in days")
1165 elif isinstance(other, (datetime.timedelta, np.timedelta64)):
1166 other = pd.Timedelta(other)
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)
1176 return MDate(time_stamp=self.time_stamp + other)
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.
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
1189 """
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.")
1195 elif isinstance(other, (datetime.timedelta, np.timedelta64)):
1196 other = pd.Timedelta(other)
1198 else:
1199 try:
1200 other = MTime(time_stamp=other)
1201 except ValueError as error:
1202 raise TypeError(error)
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)
1209 if isinstance(other, (MDate, MTime)):
1210 other = MDate(time_stamp=other)
1212 return (self.time_stamp - other.time_stamp).total_seconds() / 86400
1214 elif isinstance(other, pd.Timedelta):
1215 return MDate(time_stamp=self.time_stamp - other)
1217 def __hash__(self) -> int:
1218 return hash(self.isodate())
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()
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.
1231 formatted as: YYYY-MM-DDThh:mm:ss.ssssss+00:00
1233 Returns
1234 -------
1235 str
1236 ISO formatted date time string
1237 """
1238 return self.isodate()
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.
1244 Returns
1245 -------
1246 str
1247 The ISO format string.
1248 """
1249 return self.isodate()
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.
1260 Parameters
1261 ----------
1262 value : str | int | float | np.datetime64 | pd.Timestamp
1263 time stamp value
1264 """
1265 self.time_stamp = value