diff --git a/autotest/test_copy.py b/autotest/test_copy.py index df6d9ecbca..427898aff5 100644 --- a/autotest/test_copy.py +++ b/autotest/test_copy.py @@ -13,7 +13,6 @@ from flopy.mf6.mfsimbase import MFSimulationData from flopy.mf6.modflow.mfsimulation import MFSimulation from flopy.modflow import Modflow -from flopy.utils import TemporalReference def get_package_list(model): @@ -151,8 +150,6 @@ def package_is_copy(pk1, pk2): # TODO: this may return False if there are nans elif not np.allclose(v.array, v2.array): return False - elif isinstance(v, TemporalReference): - pass elif isinstance(v, np.ndarray): if not np.allclose(v, v2): return False diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py new file mode 100644 index 0000000000..b17c239493 --- /dev/null +++ b/autotest/test_modeltime.py @@ -0,0 +1,292 @@ +import datetime + +import numpy as np +import pandas as pd + +import flopy +from flopy.discretization.modeltime import ModelTime + + +def test_date_userinput_parsing(): + formats = [ + datetime.datetime(2024, 11, 12), + np.datetime64("2024-11-12"), + pd.Timestamp("2024-11-12"), + "2024-11-12", + "2024/11/12", + "11-12-2024", + "11/12/2024", + ] + + valid = datetime.datetime(2024, 11, 12) + + for dt_rep in formats: + dt_obj = ModelTime.parse_datetime(dt_rep) + if dt_obj != valid: + raise AssertionError("datetime not properly determined from user input") + + +def test_datetime_userinput_parsing(): + formats = [ + datetime.datetime(2024, 11, 12, 14, 31, 29), + np.datetime64("2024-11-12T14:31:29"), + pd.Timestamp("2024-11-12T14:31:29"), + "2024-11-12T14:31:29", + "2024/11/12T14:31:29", + "11-12-2024 14:31:29", + "11/12/2024 14:31:29", + ] + + valid = datetime.datetime(2024, 11, 12, 14, 31, 29) + + for dt_rep in formats: + dt_obj = ModelTime.parse_datetime(dt_rep) + if dt_obj != valid: + raise AssertionError("datetime not properly determined from user input") + + +def test_timeunits_userinput_parsing(): + formats = { + "years": ["years", "YeaR", "yaEr", "ayer", "y", "yr", 5], + "days": ["days", "Day", "dyAs", "dysa", "d", 4], + "hours": ["hours", "Hour", "huors", "h", "hrs", 3], + "minutes": ["minutes", "MinUte", "minte", "m", "min", 2], + "seconds": ["seconds", "Second", "sedcon", "s", "sec", 1], + "unknown": ["unkonwn", "undefined", "u", 0], + } + + for unit_name, checks in formats.items(): + for check in checks: + mt_unit = ModelTime.timeunits_from_user_input(check) + if mt_unit != unit_name: + raise AssertionError( + "Units are unable to be determined from user input" + ) + + +def test_set_datetime_and_units(): + nrec = 2 + perlen = np.full((nrec,), 10) + nstp = np.full((nrec,), 2, dtype=int) + + unix_t0 = datetime.datetime(1970, 1, 1) + new_dt = datetime.datetime(2024, 11, 12) + + init_units = "unknown" + new_units = "days" + + mt = ModelTime(perlen=perlen, nstp=nstp) + + if mt.time_units != init_units: + raise AssertionError("time_units None condition not being set to unknown") + + if mt.start_datetime != unix_t0: + raise AssertionError("start_datetime None condition not being set to 1/1/1970") + + mt.time_units = new_units + mt.start_datetime = new_dt + + if mt.time_units != new_units: + raise AssertionError("time_units setting not behaving properly") + + if mt.start_datetime != new_dt: + raise AssertionError("start_datetime setting not behaving properly") + + +def test_get_totim_from_kper_kstp(): + validate = {(0, None): 30.25, (1, 3): 60.5, (4, 0): 126.246, (11, None): 363.00} + + nrec = 12 + perlen = np.full((nrec,), 30.25) + nstp = np.full((nrec,), 4, dtype=int) + tslen = np.full((nrec,), 1.25) + start_datetime = "2023-12-31t23:59:59" + time_unit = "days" + + mt = ModelTime( + perlen, nstp, tslen, time_units=time_unit, start_datetime=start_datetime + ) + + for (kper, kstp), totim0 in validate.items(): + totim = mt.get_totim(kper, kstp=kstp) + if np.abs(totim - totim0) > 0.01: + raise AssertionError("Incorrect totim calculation from get_totim()") + + +def test_get_datetime_from_kper_kstp(): + validate = { + (0, None): datetime.datetime(2024, 1, 31, 5, 59, 59), + (1, 3): datetime.datetime(2024, 3, 1, 11, 59, 59), + (4, 0): datetime.datetime(2024, 5, 6, 5, 55, 6), + (11, None): datetime.datetime(2024, 12, 28, 23, 59, 59), + } + + nrec = 12 + perlen = np.full((nrec,), 30.25) + nstp = np.full((nrec,), 4, dtype=int) + tslen = np.full((nrec,), 1.25) + start_datetime = "2023-12-31t23:59:59" + time_unit = "days" + + mt = ModelTime( + perlen, nstp, tslen, time_units=time_unit, start_datetime=start_datetime + ) + + for (kper, kstp), dt0 in validate.items(): + dt = mt.get_datetime(kper, kstp=kstp) + td = dt - dt0 + if np.abs(td.seconds) > 2: + raise AssertionError("Datetime calculation incorrect for get_datetime()") + + +def test_datetime_intersect(): + validate = { + (0, None): datetime.datetime(2024, 1, 31, 5, 59, 58), + (1, 3): datetime.datetime(2024, 3, 1, 11, 59, 58), + (4, 0): datetime.datetime(2024, 5, 6, 5, 55, 5), + (11, None): datetime.datetime(2024, 12, 28, 23, 59, 58), + } + + nrec = 12 + perlen = np.full((nrec,), 30.25) + nstp = np.full((nrec,), 4, dtype=int) + tslen = np.full((nrec,), 1.25) + start_datetime = "2023-12-31t23:59:59" + time_unit = "days" + + mt = ModelTime( + perlen, nstp, tslen, time_units=time_unit, start_datetime=start_datetime + ) + + for (kper0, kstp0), dt in validate.items(): + if kstp0 is None: + kper, _ = mt.intersect(dt) + if kper != kper0: + raise AssertionError("intersect() not returning correct stress-period") + + else: + kper, kstp = mt.intersect(dt) + if kper != kper0 or kstp != kstp0: + raise AssertionError( + "intersect() not returning correct stress-period and timestep" + ) + + +def test_totim_intersect(): + validate = {(0, None): 30.2, (1, 3): 60.4, (4, 0): 126.23, (11, None): 362.9} + nrec = 12 + perlen = np.full((nrec,), 30.25) + nstp = np.full((nrec,), 4, dtype=int) + tslen = np.full((nrec,), 1.25) + start_datetime = "2023-12-31t23:59:59" + time_unit = "days" + + mt = ModelTime( + perlen, nstp, tslen, time_units=time_unit, start_datetime=start_datetime + ) + + for (kper0, kstp0), totim in validate.items(): + if kstp0 is None: + kper, _ = mt.intersect(totim=totim) + if kper != kper0: + raise AssertionError("intersect() not returning correct stress-period") + + else: + kper, kstp = mt.intersect(totim=totim) + if kper != kper0 or kstp != kstp0: + raise AssertionError( + "intersect() not returning correct stress-period and timestep" + ) + + +def test_mf2005_modeltime(): + nlay = 1 + nrow = 9 + ncol = 9 + delc = 10 + delr = 10 + top = np.full((nrow, ncol), 100) + botm = np.zeros((nlay, nrow, ncol)) + idomain = np.ones(botm.shape, dtype=int) + strt = np.full(botm.shape, np.max(top) - 5) + nper = 5 + nstp = [5, 4, 5, 5, 5] + perlen = [31, 28, 31, 30, 31] + start_datetime = datetime.datetime(2024, 1, 1) + start_datetime_str = "1/1/2024" + + ml = flopy.modflow.Modflow(modelname="dev_time", model_ws="dev_time") + + dis = flopy.modflow.ModflowDis( + ml, + nlay=nlay, + nrow=nrow, + ncol=ncol, + nper=nper, + delc=delc, + delr=delr, + top=top, + botm=botm, + perlen=perlen, + nstp=nstp, + steady=False, + itmuni=4, + lenuni=2, + start_datetime=start_datetime_str, + ) + bas = flopy.modflow.ModflowBas(ml, ibound=idomain, strt=strt) + + modeltime = ml.modeltime + if modeltime.start_datetime != start_datetime: + raise AssertionError("start_datetime improperly stored") + + result = modeltime.intersect("3/06/2024 23:59:59") + if result != (2, 0): + raise AssertionError("ModelTime intersect not working correctly") + + +def test_mf6_modeltime(): + nlay = 1 + nrow = 9 + ncol = 9 + delc = 10 + delr = 10 + top = np.full((nrow, ncol), 100) + botm = np.zeros((nlay, nrow, ncol)) + idomain = np.ones(botm.shape, dtype=int) + nper = 5 + nstp = [5, 4, 5, 5, 5] + perlen = [31, 28, 31, 30, 31] + period_data = [(p, nstp[ix], 1) for ix, p in enumerate(perlen)] + start_datetime = datetime.datetime(2024, 1, 1) + start_datetime_str = "2024-1-1t00:00:00" + + sim = flopy.mf6.MFSimulation() + tdis = flopy.mf6.ModflowTdis( + sim, + time_units="days", + start_date_time=start_datetime_str, + nper=nper, + perioddata=period_data, + ) + ims = flopy.mf6.ModflowIms(sim) + gwf = flopy.mf6.ModflowGwf(sim) + dis = flopy.mf6.ModflowGwfdis( + gwf, + nlay=nlay, + nrow=nrow, + ncol=ncol, + delc=delc, + delr=delr, + top=top, + botm=botm, + idomain=idomain, + ) + + modeltime = gwf.modeltime + if modeltime.start_datetime != start_datetime: + raise AssertionError("start_datetime improperly stored") + + result = modeltime.intersect("3/06/2024 23:59:59") + if result != (2, 0): + raise AssertionError("ModelTime intersect not working correctly") diff --git a/autotest/test_usg.py b/autotest/test_usg.py index 33817d2204..f5d770fcaa 100644 --- a/autotest/test_usg.py +++ b/autotest/test_usg.py @@ -15,7 +15,7 @@ ModflowGhb, ModflowOc, ) -from flopy.utils import TemporalReference, Util2d, Util3d +from flopy.utils import Util2d, Util3d @pytest.fixture @@ -62,7 +62,7 @@ def test_usg_disu_load(function_tmpdir, mfusg_01A_nestedgrid_nognc_model_path): assert np.array_equal(value1.array, value2.array) elif isinstance(value1, list): # this is for the jagged _get_neighbours list assert np.all([np.all(v1 == v2) for v1, v2 in zip(value1, value2)]) - elif not isinstance(value1, TemporalReference): + else: assert value1 == value2 diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index da7014931e..d10c297eb8 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -1,4 +1,9 @@ +import calendar +import datetime +from difflib import SequenceMatcher + import numpy as np +import pandas as pd class ModelTime: @@ -7,61 +12,221 @@ class ModelTime: Parameters ---------- - stress_periods : pandas dataframe - headings are: perlen, nstp, tsmult - temporal_reference : TemporalReference - contains start time and time units information + perlen : list, np.ndarray + list or numpy array of stress period lengths + nstp : list, np.ndarray + list or numpy array of number of time steps per stress period + tsmult : list, np.ndarray + list or numpy array of time-step mult infomation + time_units : int or str + string or pre-mf6 integer representation (ITMUNI) of time units + start_datetime : various objects + user-supplied starting datetime representation. Please see the + ModelTime.parse_datetime documentation for a list + of the supported representation types + steady_state : list, np.ndarray + optional list or numpy array of boolean flags that indicate if + stress periods are steady-state or transient """ def __init__( self, - period_data=None, - time_units="days", + perlen, + nstp, + tsmult=None, + time_units=None, start_datetime=None, steady_state=None, ): - self._period_data = period_data - self._time_units = time_units - self._start_datetime = start_datetime + nrecs = len(perlen) + if tsmult is None: + tsmult = np.full((nrecs,), 1) + + if nrecs != len(nstp): + raise AssertionError() + + if len(tsmult) != len(nstp): + raise AssertionError + + self._perlen = perlen + self._nstp = nstp + self._tsmult = tsmult + self._time_units = self.timeunits_from_user_input(time_units) + self._start_datetime = self.parse_datetime(start_datetime) self._steady_state = steady_state + self._totim_dict = {} + self.__str_format = "%Y-%m-%dt%H:%M:%S" @property def time_units(self): + """ + Returns a normalized string representation of the time units + """ return self._time_units + @time_units.setter + def time_units(self, units): + """ + Method to reset the time units of the ModelTime class + + Parameters + ---------- + units : str or int + string or pre-mf6 integer representation (ITMUNI) of time units + + """ + units = self.timeunits_from_user_input(units) + self._time_units = units + @property def start_datetime(self): + """ + Returns a datetime.datetime object of the model start time + """ return self._start_datetime + @start_datetime.setter + def start_datetime(self, datetime_obj): + """ + Property setter method to reset the start datetime of the ModelTime class + + Parameters + ---------- + datetime_obj : various objects + user-supplied datetime representation. Please see the + ModelTime.parse_datetime documentation for a list + of the supported representation types + + """ + start_dt = self.parse_datetime(datetime_obj) + self._start_datetime = start_dt + @property def perlen(self): - return self._period_data["perlen"] + """ + Returns a list or array of stress period lengths + + """ + return self._perlen.copy() @property def nper(self): - return len(self._period_data["perlen"]) + """ + Returns the number of stress periods + """ + return len(self._perlen) @property def nstp(self): - return self._period_data["nstp"] + """ + Returns a list or array of number of time steps per stress period + """ + return self._nstp.copy() @property def tsmult(self): - return self._period_data["tsmult"] + """ + Returns the time step multiplier value for each stress period + + """ + return self._tsmult.copy() + + @property + def perioddata(self): + """ + Returns a tuple of period data for the MF6 TDIS package containing records + of [(perlen, nstp, tsmult), ....] for each stress period + """ + return [(per, self.nstp[ix], self.tsmult[ix]) for ix, per in self.perlen] @property def steady_state(self): + """ + Returns a boolean that indicates either steady-state or transient stress period + + """ return self._steady_state @property def totim(self): + """ + Returns a list of totim values at the end of each time step + + """ + if not self._totim_dict: + self._set_totim_dict() + + return list(self._totim_dict.values()) + + @property + def kper_kstp(self): + """ + Returns a list of kper, kstp tuples for all time steps + + """ + if not self._totim_dict: + self._set_totim_dict() + return list(self._totim_dict.keys()) + + @property + def datetimes(self): + """ + Returns a list of datetime objects for all time steps + """ + return [self.get_datetime(per, stp) for per, stp in self.kper_kstp] + + @property + def tslen(self): + """ + Method to get a list of time step lengths for all time steps + + """ + n = 0 + tslen = [] + totim = self.totim + for ix, stp in enumerate(self.nstp): + for i in range(stp): + if not tslen: + tslen = [totim[n]] + else: + tslen.append(totim[n] - totim[n - 1]) + n += 1 + + return np.array(tslen) + + @staticmethod + def get_datetime_string(datetime_obj): + """ + Method to get a standarized ISO 8601 compliant datetime string + + Parameters + ---------- + datetime_obj : various objects + user-supplied datetime representation. Please see the + ModelTime.parse_datetime documentation for a list + of the supported representation types + + """ + dt = ModelTime.parse_datetime(datetime_obj) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + + def _set_totim_dict(self): + """ + Method to setup a dictionary of (kper, kstp): totim that is used + by multiple methods + + Returns + ------- + None + """ delt = [] + per_stp = [] perlen_array = self.perlen nstp_array = self.nstp tsmult_array = self.tsmult - for ix, nstp in enumerate(nstp_array): - perlen = perlen_array[ix] - tsmult = tsmult_array[ix] + for per, nstp in enumerate(nstp_array): + perlen = perlen_array[per] + tsmult = tsmult_array[per] for stp in range(nstp): if stp == 0: if tsmult != 1.0: @@ -71,21 +236,367 @@ def totim(self): else: dt = delt[-1] * tsmult delt.append(dt) + per_stp.append((per, stp)) totim = np.add.accumulate(delt) - return totim + self._totim_dict = {ps: totim[i] for i, ps in enumerate(per_stp)} - @property - def tslen(self): - n = 0 - tslen = [] - totim = self.totim - for ix, stp in enumerate(self.nstp): - for i in range(stp): - if not tslen: - tslen = [totim[n]] - else: - tslen.append(totim[n] - totim[n - 1]) - n += 1 + @staticmethod + def timeunits_from_user_input(units): + """ + Method to get a normalized time unit string from user input. User + input can be either a string representation or ITMUNI integer. String + representations use "sequence scoring" to fuzzy match to the normalized + time unit. - return np.array(tslen) + Parameters + ---------- + units: str or int + string or pre-mf6 integer representation (ITMUNI) of time units + + Returns + ------- + str: standardized unit string + """ + if units is None: + units = 0 + + valid_units = { + 0: "unknown", + 1: "seconds", + 2: "minutes", + 3: "hours", + 4: "days", + 5: "years", + } + valid_units_list = list(valid_units.values()) + valid_unit = None + + if isinstance(units, int): + # map to pre-mf6 conventions + if 0 <= units <= 5: + valid_unit = valid_units[units] + else: + raise ValueError("Integer units should be between 0 - 5") + else: + units = units.lower() + if len(units) == 1: + for vu in valid_units_list: + if vu.startswith(units): + valid_unit = vu + break + else: + scores = [] + for vu in valid_units_list: + score = SequenceMatcher(None, vu, units).ratio() + scores.append(score) + + uidx = scores.index(max(scores)) + valid_unit = valid_units_list[uidx] + + if valid_unit is None: + raise ValueError(f"Could not determine time units from user input {units}") + + return valid_unit + + @staticmethod + def _get_datetime_string_format(str_datetime): + """ + Method to parse a limited number string representations of datetime + formats. Currently supported string formats for date time combinations + are.... + + Parameters + ---------- + str_datetime : str + string representation of date time. See the + ModelTime.parse_datetime documentation for supported + formats + + Returns + ------- + datetime.datetime object + """ + str_datetime = str_datetime.strip().lower() + if "/" in str_datetime: + dsep = "/" + elif "-" in str_datetime: + dsep = "-" + else: + raise ValueError( + "Seperator type for date part of date time representation " + "not recognized, supported date seperator types include '/' " + "and '-'" + ) + + # check for time component + if "t" in str_datetime: + dtsep = "t" + elif " " in str_datetime: + dtsep = " " + else: + dtsep = None + + # check if year first (yr, month, day) combo... + year_first = False + tmp = str_datetime.split(dsep)[0] + if len(tmp) == 4: + year_first = True + + if dtsep is not None: + if year_first: + str_rep = f"%Y{dsep}%m{dsep}%d{dtsep}%H:%M:%S" + else: + str_rep = f"%m{dsep}%d{dsep}%Y{dtsep}%H:%M:%S" + + else: + if year_first: + str_rep = f"%Y{dsep}%m{dsep}%d" + else: + str_rep = f"%m{dsep}%d{dsep}%Y" + + return str_rep + + @staticmethod + def parse_datetime(datetime_obj): + """ + Method to create a datetime.datetime object from a variety of user + inputs including the following: + + datetime.datetime objects + numpy.datetime64 objects + pandas.Timestamp objects + string objects + + Supported formats for string objects representing November 12th, 2024 + are as follows: + + '11/12/2024' + '11-12-2024' + '2024/11/12' + '2024-11-12' + + Time can also be represented in the string object. Example formats + representing 2:31 pm on November 12th, 2024 are as follows: + + '2024-11-12T14:31:00' + '2024/11/12T14:31:00' + '11-12-2024t14:31:00' + '11/12/2024t14:31:00' + '2024-11-12 14:31:00' + '2024/11/12 14:31:00' + '11-12-2024 14:31:00' + '11/12/2024 14:31:00' + + Parameters + ---------- + datetime_obj : various formats + a user-supplied representation of date or datetime + + Returns + ------- + datetime.datetime object + """ + if datetime_obj is None: + datetime_obj = datetime.datetime(1970, 1, 1) # unix time zero + elif isinstance(datetime_obj, np.datetime64): + unix_time_0 = datetime.datetime(1970, 1, 1) + ts = (datetime_obj - np.datetime64(unix_time_0)) / np.timedelta64(1, "s") + datetime_obj = datetime.datetime.utcfromtimestamp(ts) + elif isinstance(datetime_obj, pd.Timestamp): + datetime_obj = datetime_obj.to_pydatetime() + elif isinstance(datetime_obj, datetime.datetime): + pass + elif isinstance(datetime_obj, str): + str_rep = ModelTime._get_datetime_string_format(datetime_obj) + datetime_obj = datetime.datetime.strptime(datetime_obj, str_rep) + + else: + raise NotImplementedError( + f"{type(datetime_obj)} date representations " + f"are not currently supported" + ) + + return datetime_obj + + def get_totim(self, kper, kstp=None): + """ + Method to get the total simulation time at the end of a given + stress period or stress period and time step combination + + Parameters + ---------- + kper : int + zero based stress period number + kstp : int or None + optional zero based time-step number + + Returns + ------- + totim : float + """ + if kstp is None: + kstp = self.nstp[kper] - 1 + + if not self._totim_dict: + self._set_totim_dict() + + if (kper, kstp) not in self._totim_dict: + raise KeyError( + f"(kper, kstp): ({kper} {kstp}) not a valid combination of " + f"stress period and time step" + ) + + return self._totim_dict[(kper, kstp)] + + def get_datetime(self, kper, kstp=None): + """ + Method to get the datetime at the end of a given stress period or + stress period and time step combination + + Parameters + ---------- + kper : int + zero based modflow stress period number + kstp : int + zero based time-step number + + Returns + ------- + datetime.datetime object + """ + if self.time_units == "unknown": + raise AssertionError( + "time units must be set in order to calculate datetime" + ) + + totim = self.get_totim(kper=kper, kstp=kstp) + + if self.time_units == "years": + ndays = 365 + years = np.floor(totim) + year = self.start_datetime.year + years + if self.start_datetime.month > 2: + isleap = calendar.isleap(year + 1) + else: + isleap = calendar.isleap(year) + + if isleap: + ndays = 366 + + days = ndays * (totim - years) + day_td = datetime.timedelta(days=days) + + dt = datetime.datetime( + year, + self.start_datetime.month, + self.start_datetime.day, + self.start_datetime.hour, + self.start_datetime.minute, + self.start_datetime.second, + ) + + dt += day_td + + else: + kwargs = {self.time_units: totim} + dt = self.start_datetime + datetime.timedelta(**kwargs) + + return dt + + def intersect(self, datetime_obj=None, totim=None, forgive=False): + """ + Method to intersect a datetime or totim value with the model and + get the model stress period and optional time-step associated with that + time. + + Parameters + ---------- + datetime_obj : various objects + user-supplied starting datetime representation. Please see the + ModelTime.parse_datetime documentation for a list + of the supported representation types + totim : float + optional total time elapsed from the beginning of the model + forgive : bool + optional flag to forgive time intersections that are outside of + the model time domain. Default is False + + Returns + ------- + tuple: (kper, kstp) + """ + if datetime_obj is not None: + datetime_obj = self.parse_datetime(datetime_obj) + timedelta = datetime_obj - self.start_datetime + + if self.time_units == "unknown": + raise AssertionError( + "time units must be set in order to intersect datetime " + "objects, set time units or use totim for intersection" + ) + + elif self.time_units == "days": + totim = timedelta.days + + elif self.time_units in ("hours", "minutes", "seconds"): + totim = timedelta.total_seconds() + if self.time_units == "minutes": + totim /= 60 + elif self.time_units == "hours": + totim /= 3600 + + else: + # years condition + totim = datetime_obj.year - self.start_datetime.year + + # get the remainder for the current year + ndays = 365 + if calendar.isleap(datetime_obj.year): + ndays = 365 + + dt_iyear = datetime.datetime( + datetime_obj.year, + self.start_datetime.month, + self.start_datetime.day, + self.start_datetime.hour, + self.start_datetime.minute, + self.start_datetime.second, + ) + + timedelta = datetime_obj - dt_iyear + days = timedelta.days + yr_frac = days / ndays + totim += yr_frac + + elif totim is not None: + pass + + else: + raise AssertionError( + "A date-time representation or totim needs to be provided" + ) + + if totim > self.totim[-1] or totim <= 0: + if forgive: + return None + if datetime_obj is None: + msg = ( + f"supplied totim {totim} is outside of model's " + f"time domain 0 - {self.totim[-1]}" + ) + else: + end_dt = self.get_datetime(self.nper - 1, self.nstp[-1] - 1) + msg = ( + f"supplied datetime" + f" {datetime_obj.strftime(self.__str_format)} is " + f"outside of the model's time domain " + f"{self.start_datetime.strftime(self.__str_format)} - " + f"{end_dt}" + ) + raise ValueError(msg) + + idx = sorted(np.where(np.array(self.totim) >= totim)[0])[0] + per, stp = self.kper_kstp[idx] + + return per, stp diff --git a/flopy/export/netcdf.py b/flopy/export/netcdf.py index 54a168478d..0e8a9759ee 100644 --- a/flopy/export/netcdf.py +++ b/flopy/export/netcdf.py @@ -181,7 +181,7 @@ def __init__( self.model_grid = model.modelgrid if "modelgrid" in kwargs: self.model_grid = kwargs.pop("modelgrid") - self.model_time = model.modeltime + self.modeltime = model.modeltime if prj is not None: self.model_grid.proj4 = prj if self.model_grid.grid_type == "structured": @@ -191,10 +191,8 @@ def __init__( raise Exception(f"Grid type {self.model_grid.grid_type} not supported.") self.shape = self.model_grid.shape - parser = import_optional_dependency("dateutil.parser") - - dt = parser.parse(self.model_time.start_datetime) - self.start_datetime = dt.strftime("%Y-%m-%dT%H:%M:%SZ") + dt = self.modeltime.start_datetime + self.start_datetime = self.modeltime.get_datetime_string(dt) self.logger.log(f"start datetime:{self.start_datetime}") crs = get_authority_crs(self.model_grid.crs) @@ -210,7 +208,7 @@ def __init__( "unsupported length units: " + self.grid_units ) - self.time_units = self.model_time.time_units + self.time_units = self.modeltime.time_units self.log("initializing attributes") self.nc_crs_str = "epsg:4326" @@ -243,7 +241,7 @@ def __init__( } for n, v in spatial_attribs.items(): self.global_attributes["flopy_sr_" + n] = v - self.global_attributes["start_datetime"] = self.model_time.start_datetime + self.global_attributes["start_datetime"] = self.start_datetime self.fillvalue = FILLVALUE @@ -729,7 +727,7 @@ def initialize_file(self, time_values=None): self.log("creating dimensions") # time if time_values is None: - time_values = np.cumsum(self.model_time.perlen) + time_values = np.cumsum(self.modeltime.perlen) self.nc.createDimension("time", len(time_values)) for name, length in zip(self.dimension_names, self.shape): self.nc.createDimension(name, length) @@ -954,7 +952,7 @@ def initialize_group( for dim in dimensions: if dim == "time": if "time" not in dimension_data: - time_values = np.cumsum(self.model_time.perlen) + time_values = np.cumsum(self.modeltime.perlen) else: time_values = dimension_data["time"] diff --git a/flopy/mbase.py b/flopy/mbase.py index f0bfbdaac9..203512b7d5 100644 --- a/flopy/mbase.py +++ b/flopy/mbase.py @@ -684,8 +684,7 @@ def __getattr__(self, item): Returns ------- object, str, int or None - Package object of type :class:`flopy.pakbase.Package`, - :class:`flopy.utils.reference.TemporalReference`, str, int or None. + Package object of type :class:`flopy.pakbase.Package`, str, int, or None Raises ------ @@ -701,12 +700,6 @@ def __getattr__(self, item): if item == "output_packages" or not hasattr(self, "output_packages"): raise AttributeError(item) - if item == "tr": - if self.dis is not None: - return self.dis.tr - else: - return None - if item == "nper": # most subclasses have a nper property, but ModflowAg needs this if self.dis is not None: @@ -1346,16 +1339,10 @@ def __setattr__(self, key, value): self._set_name(value) elif key == "model_ws": self.change_model_ws(value) - elif key == "tr": - assert isinstance(value, discretization.reference.TemporalReference) - if self.dis is not None: - self.dis.tr = value - else: - raise Exception("cannot set TemporalReference - ModflowDis not found") elif key == "start_datetime": if self.dis is not None: self.dis.start_datetime = value - self.tr.start_datetime = value + self.modeltime.set_start_datetime(value) else: raise Exception("cannot set start_datetime - ModflowDis not found") else: diff --git a/flopy/mf6/mfmodel.py b/flopy/mf6/mfmodel.py index 76429bdec0..263a72521e 100644 --- a/flopy/mf6/mfmodel.py +++ b/flopy/mf6/mfmodel.py @@ -371,17 +371,14 @@ def modeltime(self): # build model time itmuni = tdis.time_units.get_data() start_date_time = tdis.start_date_time.get_data() - if itmuni is None: - itmuni = 0 - if start_date_time is None: - start_date_time = "01-01-1970" - data_frame = { - "perlen": period_data["perlen"], - "nstp": period_data["nstp"], - "tsmult": period_data["tsmult"], - } + self._model_time = ModelTime( - data_frame, itmuni, start_date_time, steady + perlen=period_data["perlen"], + nstp=period_data["nstp"], + tsmult=period_data["tsmult"], + time_units=itmuni, + start_datetime=start_date_time, + steady_state=steady ) return self._model_time diff --git a/flopy/mfusg/mfusgdisu.py b/flopy/mfusg/mfusgdisu.py index 0c284387d2..b95f2f3597 100644 --- a/flopy/mfusg/mfusgdisu.py +++ b/flopy/mfusg/mfusgdisu.py @@ -8,7 +8,6 @@ from ..discretization.unstructuredgrid import UnstructuredGrid from ..pakbase import Package from ..utils import Util2d, Util3d, read1d -from ..utils.reference import TemporalReference from .mfusg import MfUsg ITMUNI = {"u": 0, "s": 1, "m": 2, "h": 3, "d": 4, "y": 5} @@ -443,8 +442,6 @@ def __init__( lenuni=self.lenuni, ) - self.tr = TemporalReference(itmuni=self.itmuni, start_datetime=start_datetime) - self.start_datetime = start_datetime # get neighboring nodes diff --git a/flopy/modflow/mf.py b/flopy/modflow/mf.py index f4c3836e85..f4b901de44 100644 --- a/flopy/modflow/mf.py +++ b/flopy/modflow/mf.py @@ -239,17 +239,14 @@ def modeltime(self): dis = self.disu else: dis = self.dis - # build model time - data_frame = { - "perlen": dis.perlen.array, - "nstp": dis.nstp.array, - "tsmult": dis.tsmult.array, - } + self._model_time = ModelTime( - data_frame, - dis.itmuni_dict[dis.itmuni], - dis.start_datetime, - dis.steady.array, + perlen=dis.perlen.array, + nstp=dis.nstp.array, + tsmult=dis.tsmult.array, + time_units=dis.itmuni, + start_datetime=dis.start_datetime, + steady_state=dis.steady.array, ) return self._model_time diff --git a/flopy/modflow/mfdis.py b/flopy/modflow/mfdis.py index cb1c103178..20cf8d806e 100644 --- a/flopy/modflow/mfdis.py +++ b/flopy/modflow/mfdis.py @@ -16,7 +16,6 @@ from ..utils import Util2d, Util3d from ..utils.crs import get_crs from ..utils.flopy_io import line_parse -from ..utils.reference import TemporalReference ITMUNI = {"u": 0, "s": 1, "m": 2, "h": 3, "d": 4, "y": 5} LENUNI = {"u": 0, "f": 1, "m": 2, "c": 3} @@ -273,7 +272,6 @@ def __init__( if start_datetime is None: start_datetime = model._start_datetime - self.tr = TemporalReference(itmuni=self.itmuni, start_datetime=start_datetime) self.start_datetime = start_datetime self._totim = None diff --git a/flopy/mt3d/mt.py b/flopy/mt3d/mt.py index 522a6190ca..1b1de8c123 100644 --- a/flopy/mt3d/mt.py +++ b/flopy/mt3d/mt.py @@ -227,16 +227,13 @@ def __repr__(self): @property def modeltime(self): # build model time - data_frame = { - "perlen": self.mf.dis.perlen.array, - "nstp": self.mf.dis.nstp.array, - "tsmult": self.mf.dis.tsmult.array, - } self._model_time = ModelTime( - data_frame, - self.mf.dis.itmuni_dict[self.mf.dis.itmuni], - self.dis.start_datetime, - self.dis.steady.array, + perlen=self.mf.dis.perlen.array, + nstp=self.mf.dis.nstp.array, + tsmult=self.mf.dis.tsmult.array, + time_units=self.mf.dis.itmuni_dict, + start_datetime=self.dis.start_datetime, + steady_state=self.dis.steady.array, ) return self._model_time diff --git a/flopy/seawat/swt.py b/flopy/seawat/swt.py index 80835e5224..360ded94c8 100644 --- a/flopy/seawat/swt.py +++ b/flopy/seawat/swt.py @@ -166,16 +166,13 @@ def __init__( @property def modeltime(self): # build model time - data_frame = { - "perlen": self.dis.perlen.array, - "nstp": self.dis.nstp.array, - "tsmult": self.dis.tsmult.array, - } self._model_time = ModelTime( - data_frame, - self.dis.itmuni_dict[self.dis.itmuni], - self.dis.start_datetime, - self.dis.steady.array, + perlen=self.dis.perlen.array, + nstp=self.dis.nstp.array, + tsmult=self.dis.tsmult.array, + time_units=self.dis.itmuni_dict, + start_datetime=self.dis.start_datetime, + steady_state=self.dis.steady.array, ) return self._model_time diff --git a/flopy/utils/__init__.py b/flopy/utils/__init__.py index 87e0b6e5c6..ece1430ce9 100644 --- a/flopy/utils/__init__.py +++ b/flopy/utils/__init__.py @@ -51,7 +51,6 @@ from .postprocessing import get_specific_discharge, get_transmissivities from .rasters import Raster from .recarray_utils import create_empty_recarray, ra_slice, recarray -from .reference import TemporalReference from .sfroutputfile import SfrFile from .swroutputfile import ( SwrBudget, diff --git a/flopy/utils/reference.py b/flopy/utils/reference.py deleted file mode 100644 index ea962b5913..0000000000 --- a/flopy/utils/reference.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Temporal referencing for flopy model objects.""" - -__all__ = ["TemporalReference"] - - -class TemporalReference: - """ - For now, just a container to hold start time and time units files - outside of DIS package. - """ - - defaults = {"itmuni": 4, "start_datetime": "01-01-1970"} - - itmuni_values = { - "undefined": 0, - "seconds": 1, - "minutes": 2, - "hours": 3, - "days": 4, - "years": 5, - } - - itmuni_text = {v: k for k, v in itmuni_values.items()} - - def __init__(self, itmuni=4, start_datetime=None): - self.itmuni = itmuni - self.start_datetime = start_datetime - - @property - def model_time_units(self): - return self.itmuni_text[self.itmuni]