"""
Time/date utilities.
Highlights:
- :func:`:func:`sc.tic() <tic>` / :func:`sc.toc() <toc>` / :class:`sc.timer() <timer>`: simple methods for timing durations
- :func:`sc.readdate() <readdate>`: convert strings to dates using common formats
- :func:`sc.daterange() <daterange>`: create a list of dates
- :func:`sc.datedelta() <datedelta>`: perform calculations on date strings
"""
import time as pytime
import warnings
import numpy as np
import pandas as pd
import datetime as dt
import dateutil as du
import matplotlib as mpl
import matplotlib.pyplot as plt
import sciris as sc
import sciris.sc_utils as scu
###############################################################################
#%% Date functions
###############################################################################
__all__ = ['time', 'now', 'getdate', 'readdate', 'date', 'day', 'daydiff', 'daterange', 'datedelta', 'datetoyear']
[docs]
def time():
"""
Get current time in seconds -- alias to time.time()
See also :func:`sc.now() <now>` to return a datetime object, and :func:`sc.getdate() <getdate>` to
return a string.
*New in version 3.0.0.*
"""
return pytime.time()
[docs]
def now(astype='dateobj', timezone=None, utc=False, tostring=False, dateformat=None):
"""
Get the current time as a datetime object, optionally in UTC time.
:func:`sc.now() <now>` is similar to :func:`sc.getdate() <getdate>`, but :func:`sc.now() <now>` returns a datetime
object by default, while :func:`sc.getdate() <getdate>` returns a string by default.
Args:
astype (str) : what to return; choices are "dateobj", "str", "float"; see :func:`sc.getdate() <getdate>` for more
timezone (str) : the timezone to set the itme to
utc (bool) : whether the time is specified in UTC time
dateformat (str) : if ``astype`` is ``'str'``, use this output format
**Examples**::
sc.now() # Return current local time, e.g. 2019-03-14 15:09:26
sc.now(timezone='US/Pacific') # Return the time now in a specific timezone
sc.now(utc=True) # Return the time in UTC
sc.now(astype='str') # Return the current time as a string instead of a date object; use 'int' for seconds
sc.now(tostring=True) # Backwards-compatible alias for astype='str'
sc.now(dateformat='%Y-%b-%d') # Return a different date format
*New in version 1.3.0:* made "astype" the first argument; removed "tostring" argument
"""
if isinstance(utc, str): timezone = utc # Assume it's a timezone
if timezone is not None: tzinfo = du.tz.gettz(timezone) # Timezone is a string
elif utc: tzinfo = du.tz.tzutc() # UTC has been specified
else: tzinfo = None # Otherwise, do nothing
if tostring: # pragma: no cover
warnmsg = 'sc.now() argument "tostring" is deprecated; use astype="str" instead'
warnings.warn(warnmsg, category=FutureWarning, stacklevel=2)
astype='str'
timenow = dt.datetime.now(tzinfo)
output = getdate(timenow, astype=astype, dateformat=dateformat)
return output
[docs]
def getdate(obj=None, astype='str', dateformat=None):
"""
Alias for converting a date object to a formatted string.
See also :func:`sc.now() <now>`.
Args:
obj (datetime): the datetime object to convert
astype (str): what to return; choices are "str" (default), "dateobj", "float" (full timestamp), "int" (timestamp to second precision)
dateformat (str): if ``astype`` is ``'str'``, use this output format
**Examples**::
sc.getdate() # Returns a string for the current date
sc.getdate(astype='float') # Convert today's time to a timestamp
"""
if obj is None:
obj = now()
if dateformat is None:
dateformat = '%Y-%b-%d %H:%M:%S'
else:
astype = 'str' # If dateformat is specified, assume type is a string
try:
if sc.isstring(obj): # pragma: no cover
return obj # Return directly if it's a string
obj.timetuple() # Try something that will only work if it's a date object
dateobj = obj # Test passed: it's a date object
except Exception as E: # pragma: no cover # It's not a date object
errormsg = f'Getting date failed; date must be a string or a date object: {repr(E)}'
raise TypeError(errormsg)
timestamp = obj.timestamp()
if astype == 'str': output = dateobj.strftime(dateformat)
elif astype == 'int': output = int(timestamp)
elif astype == 'dateobj': output = dateobj
elif astype in ['float', 'number', 'timestamp']: # pragma: no cover
output = timestamp
else: # pragma: no cover
errormsg = f'"astype={astype}" not understood; must be "str" or "int"'
raise ValueError(errormsg)
return output
[docs]
def readdate(datestr=None, *args, dateformat=None, return_defaults=False, verbose=False):
"""
Convenience function for loading a date from a string. If dateformat is None,
this function tries a list of standard date types. Note: in most cases :func:`sc.date() <date>`
should be used instead.
By default, a numeric date is treated as a POSIX (Unix) timestamp. This can be changed
with the ``dateformat`` argument, specifically:
- 'posix'/None: treat as a POSIX timestamp, in seconds from 1970
- 'ordinal'/'matplotlib': treat as an ordinal number of days from 1970 (Matplotlib default)
Args:
datestr (int, float, str or list): the string containing the date, or the timestamp (in seconds), or a list of either
args (list): additional dates to convert
dateformat (str or list): the format for the date, if known; if 'dmy' or 'mdy', try as day-month-year or month-day-year formats; can also be a list of options
return_defaults (bool): don't convert the date, just return the defaults
verbose (bool): return detailed error messages
Returns:
dateobj (datetime): a datetime object
**Examples**::
dateobj = sc.readdate('2020-03-03') # Standard format, so works
dateobj = sc.readdate('04-03-2020', dateformat='dmy') # Date is ambiguous, so need to specify day-month-year order
dateobj = sc.readdate(1611661666) # Can read timestamps as well
dateobj = sc.readdate(16166, dateformat='ordinal') # Or ordinal numbers of days, as used by Matplotlib
dateobjs = sc.readdate(['2020-06', '2020-07'], dateformat='%Y-%m') # Can read custom date formats
dateobjs = sc.readdate('20200321', 1611661666) # Can mix and match formats
"""
# Define default formats
formats_to_try = {
'date': '%Y-%m-%d', # 2020-03-21
'date-slash': '%Y/%m/%d', # 2020/03/21
'date-dot': '%Y.%m.%d', # 2020.03.21
'date-space': '%Y %m %d', # 2020 03 21
'date-alpha': '%Y-%b-%d', # 2020-Mar-21
'date-alpha-rev': '%d-%b-%Y', # 21-Mar-2020
'date-alpha-sp': '%d %b %Y', # 21 Mar 2020
'date-Alpha': '%Y-%B-%d', # 2020-March-21
'date-Alpha-rev': '%d-%B-%Y', # 21-March-2020
'date-Alpha-sp': '%d %B %Y', # 21 March 2020
'date-numeric': '%Y%m%d', # 20200321
'datetime': '%Y-%m-%d %H:%M:%S', # 2020-03-21 14:35:21
'datetime-alpha': '%Y-%b-%d %H:%M:%S', # 2020-Mar-21 14:35:21
'default': '%Y-%m-%d %H:%M:%S.%f', # 2020-03-21 14:35:21.23483
'default2': '%Y-%m-%dT%H:%M:%S.%f', # 2020-03-21T14:35:21.23483
'ctime': '%a %b %d %H:%M:%S %Y', # Sat Mar 21 23:09:29 2020
}
# Define day-month-year formats
dmy_formats = {
'date': '%d-%m-%Y', # 21-03-2020
'date-slash': '%d/%m/%Y', # 21/03/2020
'date-dot': '%d.%m.%Y', # 21.03.2020
'date-space': '%d %m %Y', # 21 03 2020
}
# Define month-day-year formats
mdy_formats = {
'date': '%m-%d-%Y', # 03-21-2020
'date-slash': '%m/%d/%Y', # 03/21/2020
'date-dot': '%m.%d.%Y', # 03.21.2020
'date-space': '%m %d %Y', # 03 21 2020
}
# To get the available formats
if return_defaults:
return formats_to_try
# Handle date formats
format_list = sc.tolist(dateformat, keepnone=True) # Keep none which signifies default
if dateformat is not None:
if dateformat == 'dmy':
formats_to_try = dmy_formats
elif dateformat == 'mdy':
formats_to_try = mdy_formats
else:
formats_to_try = {}
for f,fmt in enumerate(format_list):
formats_to_try[f'User supplied {f}'] = fmt
# Ensure everything is in a consistent format
datestrs, is_list, is_array = scu._sanitize_iterables(datestr, *args)
# Actually process the dates
dateobjs = []
for datestr in datestrs: # Iterate over them
dateobj = None
exceptions = {}
if isinstance(datestr, dt.datetime):
dateobj = datestr # Nothing to do
elif sc.isnumber(datestr): # pragma: no cover
if 'posix' in format_list or None in format_list:
dateobj = dt.datetime.fromtimestamp(datestr)
elif 'ordinal' in format_list or 'matplotlib' in format_list:
dateobj = mpl.dates.num2date(datestr)
else:
errormsg = f'Could not convert numeric date {datestr} using available formats {sc.strjoin(format_list)}; must be "posix" or "ordinal"'
raise ValueError(errormsg)
else:
for key,fmt in formats_to_try.items():
try:
dateobj = dt.datetime.strptime(datestr, fmt)
break # If we find one that works, we can stop
except Exception as E:
exceptions[key] = str(E)
if dateobj is None:
formatstr = sc.newlinejoin([f'{item[1]}' for item in formats_to_try.items()])
errormsg = f'Was unable to convert "{datestr}" to a date using the formats:\n{formatstr}'
if dateformat not in ['dmy', 'mdy']:
errormsg += '\n\nNote: to read day-month-year or month-day-year dates, use dateformat="dmy" or "mdy" respectively.'
if verbose: # pragma: no cover
for key,val in exceptions.items():
errormsg += f'\n {key}: {val}'
raise ValueError(errormsg)
dateobjs.append(dateobj)
# If only a single date was supplied, return just that; else return the list/array
output = scu._sanitize_output(dateobjs, is_list, is_array, dtype=object)
return output
[docs]
def date(obj=None, *args, start_date=None, readformat=None, to='date', as_date=None, outformat=None, **kwargs):
"""
Convert any reasonable object -- a string, integer, or datetime object, or
list/array of any of those -- to a date object (or string, pandas, or numpy
date).
If the object is an integer, this is interpreted as follows:
- With readformat='posix': treat as a POSIX timestamp, in seconds from 1970
- With readformat='ordinal'/'matplotlib': treat as an ordinal number of days from 1970 (Matplotlib default)
- With start_date provided: treat as a number of days from this date
Note: in this and other date functions, arguments work either with or without
underscores (e.g. ``start_date`` or ``startdate``)
Args:
obj (str/int/date/datetime/list/array): the object to convert; if None, return current date
args (str/int/date/datetime): additional objects to convert
start_date (str/date/datetime): the starting date, if an integer is supplied
readformat (str/list): the format to read the date in; passed to :func:`sc.readdate() <readdate>` (NB: can also use "format" instead of "readformat")
to (str): the output format: 'date' (default), 'datetime', 'str' (or 'string'), 'pandas', or 'numpy'
as_date (bool): alternate method of choosing between output format of 'date' (True) or 'str' (False); if None, use "to" instead
outformat (str): the format to output the date in, if returning a string
kwargs (dict): only used for deprecated argument aliases
Returns:
dates (date or list): either a single date object, or a list of them (matching input data type where possible)
**Examples**::
sc.date('2020-04-05') # Returns datetime.date(2020, 4, 5)
sc.date([35,36,37], start_date='2020-01-01', to='str') # Returns ['2020-02-05', '2020-02-06', '2020-02-07']
sc.date(1923288822, readformat='posix') # Interpret as a POSIX timestamp
| *New in version 1.0.0.*
| *New in version 1.2.2:* "readformat" argument; renamed "dateformat" to "outformat"
| *New in version 2.0.0:* support for :obj:`np.datetime64 <numpy.datetime64>` objects
| *New in version 3.0.0:* added "to" argument, and support for :obj:`pd.Timestamp <pandas.Timestamp>` and :obj:`np.datetime64 <numpy.datetime64>` output; allow None
| *New in version 3.1.0:* allow "datetime" output
"""
# Handle deprecation
start_date = kwargs.pop('startdate', start_date) # Handle with or without underscore
as_date = kwargs.pop('asdate', as_date) # Handle with or without underscore
readformat = kwargs.pop('format', readformat) # Handle either name
dateformat = kwargs.pop('dateformat', None)
if dateformat is not None: # pragma: no cover
outformat = dateformat
warnmsg = 'sc.date() argument "dateformat" has been deprecated as of v1.2.2; use "outformat" instead'
warnings.warn(warnmsg, category=FutureWarning, stacklevel=2)
if as_date is not None: # pragma: no cover
to = 'date' if as_date else 'str' # Legacy support for as_date boolean
def dateify(obj):
""" Handle dates vs datetimes """
if to == 'date' and hasattr(obj, 'date'):
return obj.date()
else:
return obj
# Convert to list and handle other inputs
if obj is None:
obj = dt.datetime.now()
if outformat is None:
outformat = '%Y-%m-%d'
obj, is_list, is_array = scu._sanitize_iterables(obj, *args)
dates = []
for d in obj:
if d is None: # pragma: no cover
dates.append(d)
continue
try:
if type(d) == dt.date:
if to == 'datetime': # Do not use isinstance, since must be the exact type
d = dt.datetime(d.year, d.month, d.day)
else:
pass
elif isinstance(d, dt.datetime): # This includes pd.Timestamp
pass
elif sc.isstring(d):
d = readdate(d, dateformat=readformat)
elif isinstance(d, np.datetime64):
d = pd.Timestamp(d)
elif sc.isnumber(d):
if readformat is not None:
d = readdate(d, dateformat=readformat)
else:
if start_date is None:
errormsg = f'To convert the number {d} to a date, you must either specify "posix" or "ordinal" read format, or supply start_date'
raise ValueError(errormsg)
d = date(start_date) + dt.timedelta(days=int(d))
if to == 'datetime':
d = dt.datetime(d.year, d.month, d.day)
else: # pragma: no cover
errormsg = f'Cannot interpret {type(d)} as a date, must be date, datetime, or string'
raise TypeError(errormsg)
# Handle output
if to == 'date': # Convert from datetime to a date
out = dateify(d)
elif to in [str, 'str', 'string']:
out = d.strftime(outformat)
elif to == 'pandas':
out = pd.Timestamp(d)
elif to == 'numpy':
out = np.datetime64(d)
else:
errormsg = f'Could not understand to="{to}": must be "date", "str", "pandas", or "numpy"'
raise ValueError(errormsg)
dates.append(out)
except Exception as E:
errormsg = f'Conversion of "{d}" to a date failed'
raise ValueError(errormsg) from E
# Return a scalar rather than a list if only one provided
output = scu._sanitize_output(dates, is_list, is_array, dtype=object)
return output
[docs]
def day(obj, *args, start_date=None, **kwargs):
"""
Convert a string, date/datetime object, or int to a day (int), the number of
days since the start day. See also :func:`sc.date() <date>` and :func:`sc.daydiff() <daydiff>``. If a start day
is not supplied, it returns the number of days into the current year.
Args:
obj (str, date, int, list, array): convert any of these objects to a day relative to the start day
args (list): additional days
start_date (str or date): the start day; if none is supplied, return days since (supplied year)-01-01.
Returns:
days (int or list): the day(s) in simulation time (matching input data type where possible)
**Examples**::
sc.day(sc.now()) # Returns how many days into the year we are
sc.day(['2021-01-21', '2024-04-04'], start_date='2022-02-22') # Days can be positive or negative
| *New in version 1.0.0.*
| *New in version 1.2.2:* renamed "start_day" to "start_date"
"""
# Handle deprecation
start_date = kwargs.pop('startdate', start_date) # Handle with or without underscore
start_day = kwargs.pop('start_day', None)
if start_day is not None: # pragma: no cover
start_date = start_day
warnmsg = 'sc.day() argument "start_day" has been deprecated as of v1.2.2; use "start_date" instead'
warnings.warn(warnmsg, category=FutureWarning, stacklevel=2)
# Do not process a day if it's not supplied, and ensure it's a list
if obj is None:
return
obj, is_list, is_array = scu._sanitize_iterables(obj, *args)
days = []
for d in obj:
if d is None:
days.append(d)
elif sc.isnumber(d):
days.append(int(d)) # Just convert to an integer
else:
try:
if sc.isstring(d):
d = readdate(d).date()
elif isinstance(d, dt.datetime):
d = d.date()
if start_date:
start_date = date(start_date)
else:
start_date = date(f'{d.year}-01-01')
d_day = (d - start_date).days # Heavy lifting -- actually compute the day
days.append(d_day)
except Exception as E: # pragma: no cover
errormsg = f'Could not interpret "{d}" as a date: {str(E)}'
raise ValueError(errormsg)
# Return an integer rather than a list if only one provided
output = scu._sanitize_output(days, is_list, is_array)
return output
[docs]
def daydiff(*args):
"""
Convenience function to find the difference between two or more days. With
only one argument, calculate days since Jan. 1st.
**Examples**::
diff = sc.daydiff('2020-03-20', '2020-04-05') # Returns 16
diffs = sc.daydiff('2020-03-20', '2020-04-05', '2020-05-01') # Returns [16, 26]
doy = sc.daydiff('2022-03-20') # Returns 79, the number of days since 2022-01-01
| *New in version 1.0.0.*
| *New in version 3.0.0:* Calculated relative days with one argument
"""
days = [date(day) for day in args]
if len(days) == 1:
days.insert(0, date(f'{days[0].year}-01-01')) # With one date, return days since Jan. 1st
output = []
for i in range(len(days)-1):
diff = (days[i+1] - days[i]).days
output.append(diff)
if len(output) == 1:
output = output[0]
return output
[docs]
def daterange(start_date=None, end_date=None, interval=None, inclusive=True, as_date=None,
readformat=None, outformat=None, **kwargs):
"""
Return a list of dates from the start date to the end date. To convert a list
of days (as integers) to dates, use :func:`sc.date() <date>` instead.
Note: instead of an end date, can also pass one or more of days, months, weeks,
or years, which will be added on to the start date via :func:`sc.datedelta() <datedelta>`.
Args:
start_date (int/str/date) : the starting date, in any format
end_date (int/str/date) : the end date, in any format (see also kwargs below)
interval (int/str/dict) : if an int, the number of days; if 'week', 'month', or 'year', one of those; if a dict, passed to ``dt.relativedelta()``
inclusive (bool) : if True (default), return to end_date inclusive; otherwise, stop the day before
as_date (bool) : if True, return a list of ``datetime.date`` objects; else, as input type (e.g. strings; note: you can also use "asdate" instead of "as_date")
readformat (str) : passed to :func:`sc.date() <date>`
outformat (str) : passed to :func:`sc.date() <date>`
kwargs (dict) : optionally, use any valid argument to :func:`sc.datedelta() <datedelta>` to create the end_date
**Examples**::
dates1 = sc.daterange('2020-03-01', '2020-04-04')
dates2 = sc.daterange('2020-03-01', '2022-05-01', interval=dict(months=2), asdate=True)
dates3 = sc.daterange('2020-03-01', weeks=5)
| *New in version 1.0.0.*
| *New in version 1.3.0:* "interval" argument
| *New in version 2.0.0:* :func:`sc.datedelta() <datedelta>` arguments
| *New in version 3.0.0:* preserve input type
"""
# Handle inputs
start_date = kwargs.pop('startdate', start_date) # Handle with or without underscore
end_date = kwargs.pop('enddate', end_date) # Handle with or without underscore
as_date = kwargs.pop('asdate', as_date) # Handle with or without underscore
if as_date is None: # Typical case, return the same format as the input
as_date = False if isinstance(start_date, str) else True
if len(kwargs):
end_date = datedelta(start_date, **kwargs)
start_date = date(start_date, readformat=readformat)
end_date = date(end_date, readformat=readformat)
if interval in [None, 'day']: interval = dict(days=1)
elif interval == 'week': interval = dict(weeks=1)
elif interval == 'month': interval = dict(months=1)
elif interval == 'year': interval = dict(years=1)
if inclusive:
end_date += datedelta(days=1)
# Calculate dates
dates = []
curr_date = start_date
delta = datedelta(**interval)
while curr_date < end_date:
dates.append(curr_date)
curr_date += delta
# Convert to final format
dates = date(dates, start_date=start_date, as_date=as_date, outformat=outformat)
return dates
[docs]
def datedelta(datestr=None, days=0, months=0, years=0, weeks=0, dt1=None, dt2=None, as_date=None, **kwargs):
"""
Perform calculations on a date string (or date object), returning a string (or a date).
Wrapper to ``dateutil.relativedelta.relativedelta()``.
If ``datestr`` is ``None``, then return the delta object rather than the new date.
Args:
datestr (None/str/date/list): the starting date (typically a string); if None, return the relative delta
days (int): the number of days (positive or negative) to increment
months (int): as above
years (int/float): as above; if a float, converted to days (NB: fractional months and weeks are not supported)
weeks (int): as above
dt1, dt2 (dates): if both provided, compute the difference between them
as_date (bool): if True, return a date object; otherwise, return as input type
kwargs (dict): passed to :func:`sc.date() <readdate>`
**Examples**::
sc.datedelta('2021-07-07', 3) # Add 3 days
sc.datedelta('2021-07-07', days=-4) # Subtract 4 days
sc.datedelta('2021-07-07', weeks=4, months=-1, as_date=True) # Add 4 weeks but subtract a month, and return a dateobj
sc.datedelta(days=3) # Alias to du.relativedelta.relativedelta(days=3)
sc.datedelta(['2021-07-07', '2022-07-07'], months=1) # Increment multiple dates
sc.datedelta('2020-06-01', years=0.25) # Use a fractional number of years (to the nearest day)
| *New in version 3.0.0:* operate on list of dates
| *New in version 3.1.0:* handle all date input formats
| *New in version 3.2.0:* handle fractional years
"""
# Handle keywords
as_date = kwargs.pop('asdate', as_date) # Handle with or without underscore
kw = dict(days=days, months=months, years=years, weeks=weeks, dt1=dt1, dt2=dt2)
# Check if the year is fractional
fractional_year = not float(years).is_integer()
def years_to_days(days, years, start_year=None):
""" Convert fractional years to days """
int_years = int(years)
frac_year = years - int_years
if start_year is None:
days_per_year = 365
else:
last_year = start_year + int_years
days_per_year = (dt.date(last_year+1,1,1) - dt.date(last_year,1,1)).days
days = int(round(frac_year*days_per_year))
# Modify keywords in place; the function arguments remain the ground truth
kw['days'], kw['years'] = days, int_years
return
# If we're not using a fractional year, we can precompute this
if not fractional_year:
delta = du.relativedelta.relativedelta(**kw)
# Calculate the time delta, and return immediately if no date is provided
if datestr is None:
if fractional_year:
years_to_days(days, years) # Approximate since we don't know the start year, so may be a day off in leap years
delta = du.relativedelta.relativedelta(**kw)
return delta
# Otherwise, process each argument
else:
datelist = sc.tolist(datestr)
newdates = []
for datestr in datelist:
if as_date is None: # Typical case, return the same format as the input
as_date = False if isinstance(datestr, str) else True
dateobj = date(datestr, **kwargs)
if fractional_year:
years_to_days(days, years, start_year=dateobj.year) # We do know the start year, so can calculate exactly
delta = du.relativedelta.relativedelta(**kw)
newdate = dateobj + delta
newdate = date(newdate, as_date=as_date)
newdates.append(newdate)
if not isinstance(datestr, list) and len(newdates) == 1: # Convert back to string/date
newdates = newdates[0]
return newdates
[docs]
def datetoyear(dateobj, dateformat=None, reverse=None, as_date=True):
"""
Convert a DateTime instance to decimal year.
Args:
dateobj (date, str): The datetime instance to convert
dateformat (str): If dateobj is a string, the optional date conversion format to use
reverse (bool): If True, convert a year to a date (assumed True if dateobj is a float)
Returns:
Equivalent decimal year from date, or date from decial year
**Example**::
sc.datetoyear('2010-07-01') # Returns approximately 2010.5
sc.datetoyear(2010.5) # Returns datetime.date(2010, 7, 2)
By Luke Davis from https://stackoverflow.com/a/42424261, adapted by Romesh Abeysuriya.
| *New in version 1.0.0.*
| *New in version 3.2.0:* "reverse" argument
"""
def get_year_length(year):
""" Get the length of the year: 365 or 366 days """
return dt.date(year=year+1, month=1, day=1) - dt.date(year=year, month=1, day=1)
# Handle strings and numbers
if sc.isstring(dateobj):
dateobj = date(dateobj, dateformat=dateformat)
elif sc.isnumber(dateobj):
reverse = True
# If reverse
if reverse:
year = int(dateobj)
remainder = dateobj - year
year_days = get_year_length(year).days
days = int(np.round(remainder*year_days))
base = dt.date(year=year, month=1, day=1)
out = datedelta(base, days=days)
if not as_date:
out = str(out)
# Main use case
else:
year_part = dateobj - dt.date(year=dateobj.year, month=1, day=1)
year_length = get_year_length(dateobj.year)
out = dateobj.year + year_part / year_length
return out
###############################################################################
#%% Timing functions
###############################################################################
__all__+= ['tic', 'toc', 'toctic', 'timer', 'Timer']
[docs]
def tic():
"""
With :func:`sc.toc() <toc>`, a little pair of functions to calculate a time difference:
**Examples**::
sc.tic()
slow_func()
sc.toc()
T = sc.tic()
slow_func2()
sc.toc(T, label='slow_func2')
See also :class:`sc.timer() <timer>`.
"""
global _tictime # The saved time is stored in this global
_tictime = pytime.time() # Store the present time in the global
return _tictime # Return the same stored number
def _convert_time_unit(unit, elapsed=None):
""" Convert between different units of time; not for the user """
# Shortcut for speed
if unit == 's':
return 1, 's'
# Standard use case
else:
# Define the mapping -- in order of expected usage frequency for speed
mapping = {
's' : dict(factor= 1, aliases=[None, 'default', 's', 'sec', 'secs', 'second', 'seconds']),
'ms' : dict(factor=1e-3, aliases=['ms', 'milisecond', 'miliseconds']),
'μs' : dict(factor=1e-6, aliases=['us', 'μs', 'microsecond', 'microseconds']),
'ns' : dict(factor=1e-9, aliases=['ns', 'nanosecond', 'nanoseconds']),
'min': dict(factor= 60, aliases=['m', 'min', 'mins', 'minute', 'minutes']),
'hr' : dict(factor=3600, aliases=['h', 'hr', 'hrs', 'hour', 'hours']),
}
# Handle 'auto'
if unit == 'auto':
if elapsed is None: unit = 's'
elif elapsed < 1e-7: unit = 'ns'
elif elapsed < 1e-4: unit = 'μs'
elif elapsed < 1e-1: unit = 'ms'
else: unit = 's'
# Perform the mapping
factor = None
for label,entry in mapping.items():
if unit in [label, entry['factor']] + entry['aliases']:
factor = entry['factor']
break
if factor is None:
errormsg = f'Could not understand "{unit}"; all possible values are:\n{mapping}'
raise ValueError(errormsg)
return factor, label
[docs]
def toc(start=None, label=None, baselabel=None, sigfigs=None, reset=False, unit='s',
output=False, doprint=None, elapsed=None):
"""
With :func:`sc.tic() <tic>`, a little pair of functions to calculate a time difference. See
also :class:`sc.timer() <timer>`.
By default, output is displayed in seconds. You can change this with the ``unit``
argument, which can be a string or a float:
- 'hr' or 3600
- 'min' or 60
- 's' or 1 (default)
- 'ms' or 1e-3
- 'us' or 1e-6
- 'ns' or 1e-9
- 'auto' to choose an appropriate unit
Args:
start (float): the starting time, as returned by e.g. :func:`sc.tic() <tic>`
label (str): optional label to add
baselabel (str): optional base label; default is "Elapsed time: "
sigfigs (int): number of significant figures for time estimate
reset (bool): reset the time; like calling :func:`sc.toctic() <toctic>` or :func:`sc.tic() <tic>` again
unit (str/float): the unit of time to display; see options above
output (bool): whether to return the output (otherwise print); if output='message', then return the message string; if output='both', then return both
doprint (bool): whether to print (true by default)
elapsed (float): use a pre-calculated elapsed time instead of recalculating (not recommneded)
**Examples**::
sc.tic()
slow_func()
sc.toc()
T = sc.tic()
slow_func2()
sc.toc(T, label='slow_func2')
| *New in version 1.3.0:* new arguments
| *New in version 3.0.0:* "unit" argument
"""
now = pytime.time() # Get the time as quickly as possible
global _tictime # The saved time is stored in this global
# Set defaults
if sigfigs is None: sigfigs = 3
# If no start value is passed in, try to grab the global _tictime
if isinstance(start, str): # Start and label are probably swapped # pragma: no cover
start,label = label,start
if start is None:
try: start = _tictime
except: start = 0 # This doesn't exist, so just leave start at 0.
# Calculate the elapsed time in seconds
if elapsed is None:
elapsed = now - start
# Create the message giving the elapsed time
if label is None:
if baselabel is None:
base = 'Elapsed time: '
else: # pragma: no cover
base = baselabel
else:
if baselabel is None:
if label:
base = f'{label}: '
else: # Handles case toc(label='') # pragma: no cover
base = ''
else:
base = f'{baselabel}{label}: '
factor, unitlabel = _convert_time_unit(unit, elapsed=elapsed)
logmessage = f'{base}{sc.sigfig(elapsed/factor, sigfigs=sigfigs)} {unitlabel}'
# Print if asked, or if no other output
if doprint or ((doprint is None) and (not output)):
print(logmessage)
# Optionally reset the counter
if reset:
_tictime = pytime.time() # Store the present time in the global
# Return elapsed if desired
if output: # pragma: no cover
if output == 'message':
return logmessage
elif output == 'both':
return (elapsed, logmessage)
else:
return elapsed
else:
return
[docs]
def toctic(returntic=False, returntoc=False, *args, **kwargs):
"""
A convenience fuction for multiple timings. Can return the default output of
either :func:`sc.tic() <tic>` or :func:`sc.toc() <toc>` (default neither). Arguments are passed to :func:`sc.toc() <toc>`.
Equivalent to :func:`sc.toc(reset=True) <toc>`.
**Example**::
sc.tic()
slow_operation_1()
sc.toctic()
slow_operation_2()
sc.toc()
*New in version 1.0.0.*
"""
tocout = toc(*args, **kwargs)
ticout = tic()
if returntic: return ticout
elif returntoc: return tocout
else: return
[docs]
class timer:
"""
Simple timer class. Note: :class:`sc.timer() <timer>` and :class:`sc.Timer() <Timer>` are aliases.
This wraps :func:`sc.tic() <tic>` and :func:`sc.toc() <toc>` with the formatting arguments and the start time
(at construction).
Use this in a ``with`` block to automatically print elapsed time when
the block finishes.
By default, output is displayed in seconds. You can change this with the ``unit``
argument, which can be a string or a float:
- 'hr' or 3600
- 'min' or 60
- 's' or 1 (default)
- 'ms' or 1e-3
- 'us' or 1e-6
- 'ns' or 1e-9
- 'auto' to choose an appropriate unit
Args:
label (str): label identifying this timer
auto (bool): whether to automatically increment the label
start (bool): whether to start timing from object creation (else, call :meth:`timer.tic()` explicitly)
unit (str/float): the unit of time to display; see options above
verbose (bool): whether to print output on each timing
kwargs (dict): passed to :func:`sc.toc() <toc>` when invoked
Example making repeated calls to the same timer, using ``auto`` to keep track::
>>> T = sc.timer(auto=True)
>>> T.toc()
(0): 2.63 s
>>> T.toc()
(1): 5.00 s
Example wrapping code using with-as::
>>> with sc.timer('mylabel'):
>>> sc.timedsleep(0.5)
Example using a timer to collect data, using :meth:`timer.tt() <timer.tt>` as an alias for :func:`sc.toctic() <toctic>`
to reset the time::
T = sc.timer(doprint=False)
for key in 'abcde':
sc.timedsleep(np.random.rand())
T.tt(key)
print(T.timings)
Implementation based on https://preshing.com/20110924/timing-your-code-using-pythons-with-statement/
| *New in version 1.3.0:* :class:`sc.timer() <timer>` alias, and allowing the label as first argument
| *New in version 1.3.2:* ``toc()`` passes label correctly; ``tt()`` method; ``auto`` argument
| *New in version 2.0.0:* ``plot()`` method; ``total()`` method; ``indivtimings`` and ``cumtimings`` properties
| *New in version 2.1.0:* ``total`` as property instead of method; updated repr; added disp() method
| *New in version 3.0.0:* ``unit`` argument; ``verbose`` argument; ``sum, min, max, mean, std`` methods; ``rawtimings`` property
| *New in version 3.1.0:* Timers can be combined by addition, including ``sum()``
| *New in version 3.1.5:* ``T.timings`` is now an :class:`sc.objdict() <sc_odict.objdict>` instead of an :class:`sc.odict() <sc_odict.odict>`
"""
def __init__(self, label=None, auto=False, start=True, unit='auto', verbose=None, **kwargs):
self.kwargs = kwargs # Store kwargs to pass to toc() at the end of the block
self.kwargs['label'] = label
self.auto = auto
self.unit = unit
self.verbose = verbose
self._start = None
self._tics = []
self._tocs = []
self.elapsed = None
self.message = None
self.count = 0
self.timings = sc.objdict()
if start:
self.tic() # Start counting
return
def __enter__(self):
""" Reset start time when entering with-as block """
self.tic()
return self
def __exit__(self, *args):
""" Print elapsed time when leaving a with-as block """
self.toc()
return
def __repr__(self):
""" Display a brief representation of the object """
string = sc.objectid(self)
string += 'Timings:\n'
string += str(self.timings)
string += f'\nTotal time: {self.total:n} s'
return string
def __len__(self):
""" Count the number of timings """
return len(self._tocs)
def __iadd__(self, T2):
""" Allow multiple timer objects to be combined """
self._tics += T2._tics
self._tocs += T2._tocs
for k,v in T2.timings.items():
if k in self.timings.keys(): # Add the current position of the key if duplicates are found
key = f'({len(self.timings)}) ' + k
else:
key = k
self.timings[key] = v
self.count += 1
return self
[docs]
def __add__(self, T2):
""" Ditto """
T1 = sc.dcp(self)
return T1.__iadd__(T2)
def __radd__(self, T2):
""" For sum() """
if not T2: return self # Skips the 0 in sum(..., start=0)
else: return T2.__add__(self)
[docs]
def disp(self):
""" Display the full representation of the object """
return sc.pr(self)
[docs]
def tic(self):
""" Set start time """
now = pytime.time() # Store the present time locally
self._start = now
self._tics.append(now) # Store when this tic was invoked
return
[docs]
def toc(self, label=None, **kwargs):
""" Print elapsed time; see :func:`sc.toc() <toc>` for keyword arguments """
# Get the time
self.elapsed, self.message = toc(start=self._start, output='both', doprint=False) # Get time as quickly as possible
self._tocs.append(pytime.time()) # Store when this toc was invoked
# Update the kwargs, including the label
if label is not None:
kwargs['label'] = label
for k,v in self.kwargs.items():
if k not in kwargs:
kwargs[k] = v
# Handle the count and labels
countstr= f'({self.count:d})'
if kwargs['label']:
labelstr = kwargs['label']
sep = ' '
else:
labelstr = ''
sep = ''
countlabel = f'{countstr}{sep}{labelstr}'
timingslabel = countlabel if (self.auto or not(labelstr) or (labelstr in self.timings)) else labelstr # Use labelstr if it's a valid key, else include count information
self.timings[timingslabel] = self.elapsed
self.count += 1
if self.auto:
kwargs['label'] = countlabel
# Call again to get the correct output
doprint = kwargs.pop('doprint', self.verbose)
output = toc(elapsed=self.elapsed, unit=self.unit, doprint=doprint, **kwargs)
# If reset was used, apply it
if kwargs.get('reset'):
self.tic()
return output
@property
def total(self):
""" Calculate total time """
# If the timer hasn't been started, return 0
if not len(self._tics): # pragma: no cover
return 0
else:
start = self._tics[0]
# If the timer hasn't been finished, use the current time; else the latest
if not len(self._tocs): # pragma: no cover
end = pytime.time()
else:
end = self._tocs[-1]
elapsed = end - start
return elapsed
# Alias/shortcut methods
[docs]
def start(self):
""" Alias for :func:`sc.tic() <tic>` """
return self.tic()
[docs]
def stop(self, *args, **kwargs):
""" Alias for :func:`sc.toc() <toc>` """
return self.toc(*args, **kwargs)
[docs]
def tocout(self, label=None, output=True, **kwargs):
""" Alias for :func:`sc.toc() <toc>` with output=True """
return self.toc(label=label, output=output, **kwargs)
[docs]
def toctic(self, *args, reset=True, **kwargs):
""" Like toc, but reset time between timings """
return self.toc(*args, reset=reset, **kwargs)
[docs]
def tt(self, *args, **kwargs):
""" Alias for :func:`sc.toctic() <toctic>` """
return self.toctic(*args, **kwargs)
[docs]
def tto(self, *args, output=True, **kwargs):
""" Alias for :func:`sc.toctic() <toctic>` with output=True """
return self.toctic(*args, output=output, **kwargs)
@property
def rawtimings(self):
""" Return an array of timings """
return self.timings[:]
@property
def indivtimings(self):
""" Compute the individual time between each timing """
vals = np.diff(sc.cat(self._tics[0], self._tocs))
output = sc.odict(zip(self.timings.keys(), vals))
return output
@property
def cumtimings(self):
""" Compute the cumulative time for each timing """
vals = np.array(self._tocs) - self._tics[0]
output = sc.odict(zip(self.timings.keys(), vals))
return output
[docs]
def sum(self):
"""
Sum of timings; similar to :obj:`timer.total <timer.total>`
*New in version 3.0.0.*
"""
return self.rawtimings.sum()
[docs]
def min(self):
"""
Minimum of timings
*New in version 3.0.0.*
"""
return self.rawtimings.min()
[docs]
def max(self):
"""
Maximum of timings
*New in version 3.0.0.*
"""
return self.rawtimings.max()
[docs]
def mean(self):
"""
Mean of timings
*New in version 3.0.0.*
"""
return self.rawtimings.mean()
[docs]
def std(self):
"""
Standard deviation of timings
*New in version 3.0.0.*
"""
return self.rawtimings.std()
[docs]
def plot(self, fig=None, figkwargs=None, grid=True, **kwargs):
"""
Create a plot of Timer.timings
Arguments:
cumulative (bool): how the timings will be presented, individual or cumulative
fig (fig): an existing figure to draw the plot in
figkwargs (dict): passed to :func:`plt.figure() <matplotlib.pyplot.figure>`
grid (bool): whether to show a grid
kwargs (dict): passed to :func:`plt.bar() <matplotlib.pyplot.bar>`
*New in version 2.0.0.*
"""
figkwargs = sc.mergedicts(figkwargs)
# Handle the figure
if fig is None:
fig = plt.figure(**figkwargs) # It's necessary to have an open figure or else the commands won't work
# Plot times
if len(self.timings) > 0:
keys = self.timings.keys()
vals = self.indivtimings[:]
factor, label = _convert_time_unit(self.unit, elapsed=vals.sum())
vals /= factor
ax1 = plt.subplot(2,1,1)
plt.barh(keys, vals, **kwargs)
plt.title('Individual timings')
plt.xlabel(f'Elapsed time ({label})')
ax2 = plt.subplot(2,1,2)
plt.barh(keys, np.cumsum(vals), **kwargs)
plt.title('Cumulative timings')
plt.xlabel(f'Elapsed time ({label})')
for ax in [ax1, ax2]:
ax.invert_yaxis()
ax.grid(grid)
sc.figlayout()
else: # pragma: no cover
errormsg = "Looks like nothing has been timed. Forgot to do T.start() and T.stop()??'"
raise RuntimeWarning(errormsg)
return fig
Timer = timer # Alias
###############################################################################
#%% Other functions
###############################################################################
__all__ += ['elapsedtimestr', 'timedsleep', 'randsleep']
[docs]
def elapsedtimestr(pasttime, maxdays=5, minseconds=10, shortmonths=True):
"""
Accepts a datetime object or a string in ISO 8601 format and returns a
human-readable string explaining when this time was.
The rules are as follows:
* If a time is within the last hour, return 'XX minutes'
* If a time is within the last 24 hours, return 'XX hours'
* If within the last 5 days, return 'XX days'
* If in the same year, print the date without the year
* If in a different year, print the date with the whole year
These can be configured as options.
**Examples**::
yesterday = sc.datedelta(sc.now(), days=-1)
sc.elapsedtimestr(yesterday)
"""
# Elapsed time function by Alex Chan: https://gist.github.com/alexwlchan/73933442112f5ae431cc
def print_date(date, includeyear=True, shortmonths=True):
"""
Prints a datetime object as a full date, stripping off any leading
zeroes from the day (strftime() gives the day of the month as a zero-padded
decimal number).
"""
# %b/%B are the tokens for abbreviated/full names of months to strftime()
if shortmonths:
month_token = '%b'
else: # pragma: no cover
month_token = '%B'
# Get a string from strftime()
if includeyear:
date_str = date.strftime('%d ' + month_token + ' %Y')
else: # pragma: no cover
date_str = date.strftime('%d ' + month_token)
# There will only ever be at most one leading zero, so check for this and
# remove if necessary
if date_str[0] == '0':
date_str = date_str[1:]
return date_str
now_time = dt.datetime.now()
# If the user passes in a string, try to turn it into a datetime object before continuing
if isinstance(pasttime, str): # pragma: no cover
try:
pasttime = readdate(pasttime)
except ValueError as E: # pragma: no cover
errormsg = f"User supplied string {pasttime} is not in a readable format."
raise ValueError(errormsg) from E
elif isinstance(pasttime, dt.datetime):
pass
else: # pragma: no cover
errormsg = f"User-supplied value {pasttime} is neither a datetime object nor an ISO 8601 string."
raise TypeError(errormsg)
# It doesn't make sense to measure time elapsed between now and a future date, so we'll just print the date
if pasttime > now_time:
includeyear = (pasttime.year != now_time.year)
time_str = print_date(pasttime, includeyear=includeyear, shortmonths=shortmonths)
# Otherwise, start by getting the elapsed time as a datetime object
else: # pragma: no cover
elapsed_time = now_time - pasttime
# Check if the time is within the last minute
if elapsed_time < dt.timedelta(seconds=60):
if elapsed_time.seconds <= minseconds:
time_str = "just now"
else:
time_str = f"{elapsed_time.seconds} secs ago"
# Check if the time is within the last hour
elif elapsed_time < dt.timedelta(seconds=60 * 60):
# We know that seconds > 60, so we can safely round down
minutes = int(elapsed_time.seconds / 60)
if minutes == 1:
time_str = "a minute ago"
else:
time_str = f"{minutes} mins ago"
# Check if the time is within the last day
elif elapsed_time < dt.timedelta(seconds=60 * 60 * 24 - 1):
# We know that it's at least an hour, so we can safely round down
hours = int(elapsed_time.seconds / (60 * 60))
if hours == 1:
time_str = "1 hour ago"
else:
time_str = f"{hours} hours ago"
# Check if it's within the last N days, where N is a user-supplied argument
elif elapsed_time < dt.timedelta(days=maxdays):
if elapsed_time.days == 1:
time_str = "yesterday"
else:
time_str = f"{elapsed_time.days} days ago"
# If it's not within the last N days, then we're just going to print the date
else:
includeyear = (pasttime.year != now_time.year)
time_str = print_date(pasttime, includeyear=includeyear, shortmonths=shortmonths)
return time_str
# Most robust results just from hard-coding this, despite variability between machines
_sleep_overhead = 5e-5 # Amount of time taken for a "zero" delay
[docs]
def timedsleep(delay=None, start=None, verbose=False):
"""
Pause for the specified amount of time, taking into account how long other
operations take.
This function is usually used in a loop; it works like ``time.sleep()``, but
subtracts time taken by the other operations in the loop so that each loop
iteration takes exactly ``delay`` amount of time. Note: since ``time.sleep()``
has a minimum overhead (about 2e-4 seconds), below this duration, no pause
will occur.
Args:
delay (float): time, in seconds, to wait for
start (float): if provided, the start time
verbose (bool): whether to print details
**Examples**::
# Example for a long(ish) computation
import numpy as np
for i in range(10):
sc.timedsleep('start') # Initialize
n = int(2*np.random.rand()*1e6) # Variable computation time
for j in range(n):
tmp = np.random.rand()
sc.timedsleep(1, verbose=True) # Wait for one second per iteration including computation time
# Example illustrating more accurate timing
import time
n = 1000
with sc.timer():
for i in range(n):
sc.timedsleep(1/n)
# Elapsed time: 1.01 s
with sc.timer():
for i in range(n):
time.sleep(1/n)
# Elapsed time: 1.21 s
*New in version 3.0.0:* "verbose" False by default; more accurate overhead calculation
"""
global _delaytime
if delay is None or delay=='start':
_delaytime = pytime.time() # Store the present time in the global.
return _delaytime # Return the same stored number.
else:
if start is None:
try: start = _delaytime
except: start = pytime.time()
elapsed = pytime.time() - start
remaining = max(1e-12, delay - elapsed - _sleep_overhead)
if remaining > 0 and verbose:
print(f'Pausing for {remaining:n} s')
elif verbose: # pragma: no cover
print(f'Warning, delay less than elapsed time ({delay:n} vs. {elapsed:n})')
pytime.sleep(remaining)
try: del _delaytime # After it's been used, we can't use it again
except: pass
return
[docs]
def randsleep(delay=1.0, var=1.0, low=None, high=None, seed=None):
"""
Sleep for a nondeterminate period of time (useful for desynchronizing tasks)
Args:
delay (float/list): average duration in seconds to sleep for; if a pair of values, treat as low and high
var (float): how much variability to have (default, 1.0, i.e. from 0 to 2*interval)
low (float): optionally define lower bound of sleep
high (float): optionally define upper bound of sleep
seed (int): if provided, reset the random seed
**Examples**::
sc.randsleep(1) # Sleep for 0-2 s (average 1.0)
sc.randsleep(2, 0.1) # Sleep for 1.8-2.2 s (average 2.0)
sc.randsleep([0.5, 1.5]) # Sleep for 0.5-1.5 s
sc.randsleeep(low=0.5, high=1.5) # Ditto
*New in version 2.0.0.*
*New in version 3.0.0:* "seed" argument
"""
if low is None or high is None:
if sc.isnumber(delay):
low = delay*(1-var)
high = delay*(1+var)
else:
low, high = delay[0], delay[1]
rng = np.random.default_rng(seed)
dur = rng.uniform(low, high)
pytime.sleep(dur)
return dur