"""
Handling of conversion from datetime to MAGICC's internal time conventions and back.
Internally, MAGICC effectively operates on a monthly calendar, where each year is
comprised of 12 equally sized months. This convention means that we have to be a bit
careful when moving back and forth between datetimes and MAGICC's time conventions.
However, we also want Pymagicc to be a little bit flexible and smart about how it
handles these conversions. As a result, we must interpret 'middle of the month' and
'start of the month' rather than strictly defining them. This module defines these
interpretations
"""
import datetime as dt
from calendar import monthrange
from functools import lru_cache
import numpy as np
_convert_to_decimal_required_precision = 4 * 10 ** -3
"""Maximum relative deviation between float times before they are considered unequal"""
_startmonths_magicc = np.arange(0, 1, 1 / 12)
_midmonths_magicc = _startmonths_magicc + 1 / 24
_dummy_year_start = dt.datetime(2001, 1, 1)
def _calc_seconds_from_dummy_year_start(dt_in):
return (dt_in - _dummy_year_start).total_seconds()
def _calc_start_month_year_frac(mth):
total_s = _calc_seconds_from_dummy_year_start(
dt.datetime(_dummy_year_start.year + 1, 1, 1)
)
start_month = (
_calc_seconds_from_dummy_year_start(dt.datetime(_dummy_year_start.year, mth, 1))
/ total_s
)
return start_month
def _calc_mid_month_year_frac(mth):
total_s = _calc_seconds_from_dummy_year_start(
dt.datetime(_dummy_year_start.year + 1, 1, 1)
)
_, month_days = monthrange(_dummy_year_start.year, mth)
day_decimal = month_days * 0.5
day = int(day_decimal)
hour = int(day_decimal % 1 * 24)
mid_month = (
_calc_seconds_from_dummy_year_start(
dt.datetime(_dummy_year_start.year, mth, day, hour)
)
) / total_s
return mid_month
_startmonths = np.array([_calc_start_month_year_frac(m) for m in range(1, 13)])
_midmonths = np.array([_calc_mid_month_year_frac(m) for m in range(1, 13)])
[docs]@lru_cache(maxsize=128)
def convert_to_datetime(decimal_year):
"""
Convert a decimal year from MAGICC to a datetime
Parameters
----------
decimal_year : float
Time point to convert
Returns
-------
:obj:`datetime.datetime`
Datetime representation of MAGICC's decimal year
Raises
------
ValueError
If we are not confident that the input times follow MAGICC's internal time
conventions (i.e. are not start or middle of the month).
"""
# MAGICC dates are to nearest month at most precise
year = int(decimal_year)
month_decimal = decimal_year % 1 * 12
month_fraction = month_decimal % 1
# decide if start, middle or end of month
if month_fraction > 0.9:
# MAGICC is never actually end of month, this case is just due to
# rounding errors. e.g. in MAGICC, 1000.083 is year 1000, start of
# February, but, for February, decimal_year % 1 * 12 = 0.083 * 12
# = 0.996. Hence to get the right month, i.e. February, we need to add 1
# to the month after rounding.
month = int(np.ceil(month_decimal)) + 1
day = 1
hour = 1
elif np.round(month_fraction, 1) == 0.5:
# middle of month
month = int(np.ceil(month_decimal))
_, month_days = monthrange(year, month)
day_decimal = month_days * 0.5
day = int(day_decimal)
hour = int(day_decimal % 1 * 24)
elif np.round(month_fraction, 1) == 0:
# start of month
month = int(month_decimal) + 1
day = 1
hour = 1
else:
error_msg = "Your timestamps don't appear to be middle or start of month"
raise ValueError(error_msg)
res = dt.datetime(year, month, day, hour)
return res
[docs]@lru_cache(maxsize=128)
def convert_to_decimal_year(idtime):
"""
Convert a datetime to MAGICC's expected decimal year representation
Parameters
----------
idtime : :obj:`datetime.datetime`
:obj:`datetime.datetime` instance to convert to MAGICC's internal decimal
year conventions
Returns
-------
float
MAGICC's internal decimal year representation of ``idtime``
Raises
------
ValueError
If we are not confident about how to convert the input times so that they
follow MAGICC's internal time conventions (i.e. we are not sure if
``idtime`` is start or middle of the month).
"""
year = idtime.year
month = idtime.month
year_fraction = (idtime - dt.datetime(year, 1, 1)).total_seconds() / (
dt.datetime(year + 1, 1, 1) - dt.datetime(year, 1, 1)
).total_seconds()
midmonth_decimal_bit = ((month - 1) * 2 + 1) / 24
startmonth_decimal_bit = (month - 1) / 12
if _yr_frac_close_to(year_fraction, _midmonths_magicc):
decimal_bit = midmonth_decimal_bit
elif _yr_frac_close_to(year_fraction, _midmonths):
decimal_bit = midmonth_decimal_bit
elif _yr_frac_close_to(year_fraction, _startmonths_magicc, must_be_greater=True):
decimal_bit = startmonth_decimal_bit
elif _yr_frac_close_to(year_fraction, _startmonths, must_be_greater=True):
decimal_bit = startmonth_decimal_bit
else:
error_msg = "Your timestamps don't appear to be middle or start of month"
raise ValueError(error_msg)
return np.round(year + decimal_bit, 3) # match MAGICC precision
def _yr_frac_close_to(yfrac, other, must_be_greater=False):
match_idx = np.where(
np.abs(yfrac - other) < _convert_to_decimal_required_precision
)[0]
if match_idx.size == 0:
return False
if must_be_greater and yfrac < other[match_idx]:
return False
return True
def _adjust_df_index_to_match_timeseries_type(df, ttype):
"""
Adjust a df's index to reflect the underlying timeseries type
Parameters
----------
df : :obj:`pd.DataFrame`
Dataframe to adjust
ttype : str
String indicating the kind of data in the file (look at the sample .MAG
file for explanation of the types in detail)
Returns
-------
:obj:`pd.DataFrame`
Dataframe with times adjusted to match with ``ttype``
"""
if ttype in ("POINT_START_YEAR", "AVERAGE_YEAR_START_YEAR"):
df.index = df.index.map(lambda x: dt.datetime(x, 1, 1))
return df
if ttype in ("POINT_MID_YEAR", "AVERAGE_YEAR_MID_YEAR"):
df.index = df.index.map(lambda x: dt.datetime(x, 7, 1))
return df
if ttype in ("POINT_END_YEAR", "AVERAGE_YEAR_END_YEAR"):
df.index = df.index.map(lambda x: dt.datetime(x, 12, 31))
return df
if ttype in ("MONTHLY",):
df.index = df.index.map(convert_to_datetime)
return df
raise AssertionError("Unrecognised `ttype`: {}".format(ttype)) # pragma: no cover