From 043d0f532acb24da7b45d4e571a40376870a28cf Mon Sep 17 00:00:00 2001 From: jlarsen Date: Wed, 13 Nov 2024 14:25:51 -0800 Subject: [PATCH 1/7] Update(ModelTime): refactor ModelTime and add features * refactor call signature to accept perlen, nstp, and tsmult as parameters * added methods: - `set_start_datetime()`: allows user to set/reset the start_datetime - `set_time_units()`: allows user to set/reset the time_units - `timeunits_from_user_input()`: uses fuzzy string logic to parse user supplied time units - `datetime_from_user_input()`: converts many types of datetime representation to a standard datetime object - `get_totim()`: allows user to get the totim value at the end of a stress period or stress period/time step combination - `get_datetime()`: allows user to get the datetime value at the end of a stress period or stress period/time step combination - `intersect()`: method that allows for time intersection. Method accepts datetime representations or totim and returns either the zero based stress period or the stress period and time step combination * added testing for ModelTime * updated FloPy model classes for ModelTime changes --- autotest/test_modeltime.py | 341 ++++++++++++++++++ flopy/discretization/modeltime.py | 553 ++++++++++++++++++++++++++++-- flopy/mf6/mfmodel.py | 17 +- flopy/modflow/mf.py | 17 +- flopy/mt3d/mt.py | 15 +- flopy/seawat/swt.py | 15 +- 6 files changed, 888 insertions(+), 70 deletions(-) create mode 100644 autotest/test_modeltime.py diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py new file mode 100644 index 0000000000..78791c763c --- /dev/null +++ b/autotest/test_modeltime.py @@ -0,0 +1,341 @@ +from flopy.discretization.modeltime import ModelTime +import flopy +import numpy as np +import pandas as pd +import datetime + + +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.datetime_from_user_input(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.datetime_from_user_input(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.set_time_units(new_units) + mt.set_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, kper_kstp=True) + 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, kper_kstp=True) + 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", kper_kstp=True) + 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", kper_kstp=True) + if result != (2, 0): + raise AssertionError("ModelTime intersect not working correctly") \ No newline at end of file diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index da7014931e..5721ec9d5f 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -1,4 +1,9 @@ +import datetime import numpy as np +import pandas as pd +import calendar + +from difflib import SequenceMatcher class ModelTime: @@ -7,76 +12,137 @@ 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 timestep mult infomation + time_units : int or str + string or pre-mf6 integer representation (ITMUNI) of time units + start_datetime : various objects + user supplied datetime representation. Please see the + ModelTime.datetime_from_user_input documentation for a list + of the supported representation types + steady_state : list, np.ndarray + optional list or numpy array of boolean flags that determine identify + steady-state or transient stress-periods """ - 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.datetime_from_user_input(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 @property def start_datetime(self): + """ + Returns a datetime.datetime object of the model start time + """ return self._start_datetime @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 period_data(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): - delt = [] - 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 stp in range(nstp): - if stp == 0: - if tsmult != 1.0: - dt = perlen * (tsmult - 1) / ((tsmult**nstp) - 1) - else: - dt = perlen / nstp - else: - dt = delt[-1] * tsmult - delt.append(dt) + """ + Returns a list of totim values at the end of each time-step - totim = np.add.accumulate(delt) - return totim + """ + 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 that correspond to totim + + """ + if not self._totim_dict: + self._set_totim_dict() + return list(self._totim_dict.keys()) @property def tslen(self): + """ + Method to get a list of time step lengths for the entire model period + + """ n = 0 tslen = [] totim = self.totim @@ -89,3 +155,426 @@ def tslen(self): n += 1 return np.array(tslen) + + def set_start_datetime(self, datetime_obj): + """ + Method to reset the start datetime of the ModelTime class + + Parameters + ---------- + datetime_obj : various objects + user supplied datetime representation. Please see the + ModelTime.datetime_from_user_input documentation for a list + of the supported representation types + + """ + start_dt = self.datetime_from_user_input(datetime_obj) + self._start_datetime = start_dt + + def set_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 + + 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 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: + dt = perlen * (tsmult - 1) / ((tsmult ** nstp) - 1) + else: + dt = perlen / nstp + else: + dt = delt[-1] * tsmult + delt.append(dt) + per_stp.append((per, stp)) + + totim = np.add.accumulate(delt) + self._totim_dict = {ps: totim[i] for i, ps in enumerate(per_stp)} + + @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. + + 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.datetime_from_user_input 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 datetime_from_user_input(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 timestep 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, kper_kstp=False, forgrive=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 datetime representation. Please see the + ModelTime.datetime_from_user_input documentation for a list + of the supported representation types + totim : float + optional total time elapsed from the beginning of the model + kper_kstp : bool + flag to return a tuple of zero based stress-period and time-step. + Default is False and returns the stress-period only + forgive : bool + optional flag to forgive time intersections that are outside of + the model time domain. Default is False + + Returns + ------- + int or tuple: kper or (kper, kstp) + """ + if datetime_obj is not None: + datetime_obj = self.datetime_from_user_input(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 forgrive: + 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] + + if kper_kstp: + return per, stp + + return per 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/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/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 From a80105b7844abfedf1a0f78661adeb6cffc9164b Mon Sep 17 00:00:00 2001 From: jlarsen Date: Wed, 13 Nov 2024 14:37:10 -0800 Subject: [PATCH 2/7] Linting --- autotest/test_modeltime.py | 107 ++++++++---------------------- flopy/discretization/modeltime.py | 37 ++++++----- 2 files changed, 49 insertions(+), 95 deletions(-) diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py index 78791c763c..bbc76e3146 100644 --- a/autotest/test_modeltime.py +++ b/autotest/test_modeltime.py @@ -1,8 +1,10 @@ -from flopy.discretization.modeltime import ModelTime -import flopy +import datetime + import numpy as np import pandas as pd -import datetime + +import flopy +from flopy.discretization.modeltime import ModelTime def test_date_userinput_parsing(): @@ -21,9 +23,7 @@ def test_date_userinput_parsing(): for dt_rep in formats: dt_obj = ModelTime.datetime_from_user_input(dt_rep) if dt_obj != valid: - raise AssertionError( - "datetime not properly determined from user input" - ) + raise AssertionError("datetime not properly determined from user input") def test_datetime_userinput_parsing(): @@ -42,9 +42,7 @@ def test_datetime_userinput_parsing(): for dt_rep in formats: dt_obj = ModelTime.datetime_from_user_input(dt_rep) if dt_obj != valid: - raise AssertionError( - "datetime not properly determined from user input" - ) + raise AssertionError("datetime not properly determined from user input") def test_timeunits_userinput_parsing(): @@ -54,7 +52,7 @@ def test_timeunits_userinput_parsing(): "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] + "unknown": ["unkonwn", "undefined", "u", 0], } for unit_name, checks in formats.items(): @@ -77,43 +75,26 @@ def test_set_datetime_and_units(): init_units = "unknown" new_units = "days" - mt = ModelTime( - perlen=perlen, - nstp=nstp - ) + mt = ModelTime(perlen=perlen, nstp=nstp) if mt.time_units != init_units: - raise AssertionError( - "time_units None condition not being set to unknown" - ) + 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" - ) + raise AssertionError("start_datetime None condition not being set to 1/1/1970") mt.set_time_units(new_units) mt.set_start_datetime(new_dt) if mt.time_units != new_units: - raise AssertionError( - "time_units setting not behaving properly" - ) + raise AssertionError("time_units setting not behaving properly") if mt.start_datetime != new_dt: - raise AssertionError( - "start_datetime setting not behaving properly" - ) + 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 - } + 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) @@ -123,19 +104,13 @@ def test_get_totim_from_kper_kstp(): time_unit = "days" mt = ModelTime( - perlen, - nstp, - tslen, - time_units=time_unit, - start_datetime=start_datetime + 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()" - ) + raise AssertionError("Incorrect totim calculation from get_totim()") def test_get_datetime_from_kper_kstp(): @@ -143,7 +118,7 @@ def test_get_datetime_from_kper_kstp(): (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) + (11, None): datetime.datetime(2024, 12, 28, 23, 59, 59), } nrec = 12 @@ -154,20 +129,14 @@ def test_get_datetime_from_kper_kstp(): time_unit = "days" mt = ModelTime( - perlen, - nstp, - tslen, - time_units=time_unit, - start_datetime=start_datetime + 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()" - ) + raise AssertionError("Datetime calculation incorrect for get_datetime()") def test_datetime_intersect(): @@ -175,7 +144,7 @@ def test_datetime_intersect(): (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) + (11, None): datetime.datetime(2024, 12, 28, 23, 59, 58), } nrec = 12 @@ -186,20 +155,14 @@ def test_datetime_intersect(): time_unit = "days" mt = ModelTime( - perlen, - nstp, - tslen, - time_units=time_unit, - start_datetime=start_datetime + 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" - ) + raise AssertionError("intersect() not returning correct stress-period") else: kper, kstp = mt.intersect(dt, kper_kstp=True) @@ -210,12 +173,7 @@ def test_datetime_intersect(): def test_totim_intersect(): - validate = { - (0, None): 30.2, - (1, 3): 60.4, - (4, 0): 126.23, - (11, None): 362.9 - } + 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) @@ -224,20 +182,14 @@ def test_totim_intersect(): time_unit = "days" mt = ModelTime( - perlen, - nstp, - tslen, - time_units=time_unit, - start_datetime=start_datetime + 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" - ) + raise AssertionError("intersect() not returning correct stress-period") else: kper, kstp = mt.intersect(totim=totim, kper_kstp=True) @@ -280,11 +232,10 @@ def test_mf2005_modeltime(): steady=False, itmuni=4, lenuni=2, - start_datetime=start_datetime_str + 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") @@ -316,7 +267,7 @@ def test_mf6_modeltime(): time_units="days", start_date_time=start_datetime_str, nper=nper, - perioddata=period_data + perioddata=period_data, ) ims = flopy.mf6.ModflowIms(sim) gwf = flopy.mf6.ModflowGwf(sim) @@ -329,7 +280,7 @@ def test_mf6_modeltime(): delr=delr, top=top, botm=botm, - idomain=idomain + idomain=idomain, ) modeltime = gwf.modeltime @@ -338,4 +289,4 @@ def test_mf6_modeltime(): result = modeltime.intersect("3/06/2024 23:59:59", kper_kstp=True) if result != (2, 0): - raise AssertionError("ModelTime intersect not working correctly") \ No newline at end of file + raise AssertionError("ModelTime intersect not working correctly") diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index 5721ec9d5f..e0951275b3 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -1,9 +1,9 @@ +import calendar import datetime +from difflib import SequenceMatcher + import numpy as np import pandas as pd -import calendar - -from difflib import SequenceMatcher class ModelTime: @@ -28,6 +28,7 @@ class ModelTime: optional list or numpy array of boolean flags that determine identify steady-state or transient stress-periods """ + def __init__( self, perlen, @@ -204,7 +205,7 @@ def _set_totim_dict(self): for stp in range(nstp): if stp == 0: if tsmult != 1.0: - dt = perlen * (tsmult - 1) / ((tsmult ** nstp) - 1) + dt = perlen * (tsmult - 1) / ((tsmult**nstp) - 1) else: dt = perlen / nstp else: @@ -241,7 +242,7 @@ def timeunits_from_user_input(units): 2: "minutes", 3: "hours", 4: "days", - 5: "years" + 5: "years", } valid_units_list = list(valid_units.values()) valid_unit = None @@ -269,9 +270,7 @@ def timeunits_from_user_input(units): valid_unit = valid_units_list[uidx] if valid_unit is None: - raise ValueError( - f"Could not determine time units from user input {units}" - ) + raise ValueError(f"Could not determine time units from user input {units}") return valid_unit @@ -469,7 +468,7 @@ def get_datetime(self, kper, kstp=None): self.start_datetime.day, self.start_datetime.hour, self.start_datetime.minute, - self.start_datetime.second + self.start_datetime.second, ) dt += day_td @@ -540,7 +539,7 @@ def intersect(self, datetime_obj=None, totim=None, kper_kstp=False, forgrive=Fal self.start_datetime.day, self.start_datetime.hour, self.start_datetime.minute, - self.start_datetime.second + self.start_datetime.second, ) timedelta = datetime_obj - dt_iyear @@ -560,15 +559,19 @@ def intersect(self, datetime_obj=None, totim=None, kper_kstp=False, forgrive=Fal if forgrive: return None if datetime_obj is None: - msg = f"supplied totim {totim} is outside of model's " \ - f"time domain 0 - {self.totim[-1]}" + 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}" + 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] From e77e4cbd5b378c7af28e86271eebc1de342d0996 Mon Sep 17 00:00:00 2001 From: jlarsen Date: Wed, 13 Nov 2024 15:21:38 -0800 Subject: [PATCH 3/7] Update NetCdf for ModelTime changes * add get_datetime_string() method to ModelTime to format ISO 8601 compliant date times --- flopy/discretization/modeltime.py | 15 +++++++++++++++ flopy/export/netcdf.py | 16 +++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index e0951275b3..76a8231321 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -157,6 +157,21 @@ def tslen(self): return np.array(tslen) + def get_datetime_string(self, 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.datetime_from_user_input documentation for a list + of the supported representation types + + """ + dt = self.datetime_from_user_input(datetime_obj) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + def set_start_datetime(self, datetime_obj): """ Method to reset the start datetime of the ModelTime class 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"] From 0a85cc1c0d8d186d3fb94b94d89bad7e0c19d46c Mon Sep 17 00:00:00 2001 From: jlarsen Date: Wed, 13 Nov 2024 15:43:30 -0800 Subject: [PATCH 4/7] Remove TemporalReference, replaced functionality with ModelTime --- flopy/mbase.py | 17 ++--------------- flopy/mfusg/mfusgdisu.py | 3 --- flopy/modflow/mfdis.py | 2 -- flopy/utils/__init__.py | 1 - flopy/utils/reference.py | 31 ------------------------------- 5 files changed, 2 insertions(+), 52 deletions(-) delete mode 100644 flopy/utils/reference.py 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/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/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/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] From a9ed6ec6fca5331bbe8e1d0cf1c3828fd3be7c86 Mon Sep 17 00:00:00 2001 From: jlarsen Date: Wed, 13 Nov 2024 15:58:50 -0800 Subject: [PATCH 5/7] commit test_copy and test_usg updates --- autotest/test_copy.py | 3 --- autotest/test_usg.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) 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_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 From f4624a8f694b01953ecfc5844b76906e2d315c02 Mon Sep 17 00:00:00 2001 From: jlarsen Date: Tue, 17 Dec 2024 14:25:27 -0800 Subject: [PATCH 6/7] First pass of changes to ModelTime for review comments --- autotest/test_modeltime.py | 18 ++++---- flopy/discretization/modeltime.py | 77 +++++++++++++++---------------- 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py index bbc76e3146..067913ccc0 100644 --- a/autotest/test_modeltime.py +++ b/autotest/test_modeltime.py @@ -21,7 +21,7 @@ def test_date_userinput_parsing(): valid = datetime.datetime(2024, 11, 12) for dt_rep in formats: - dt_obj = ModelTime.datetime_from_user_input(dt_rep) + dt_obj = ModelTime.parse_datetime(dt_rep) if dt_obj != valid: raise AssertionError("datetime not properly determined from user input") @@ -40,7 +40,7 @@ def test_datetime_userinput_parsing(): valid = datetime.datetime(2024, 11, 12, 14, 31, 29) for dt_rep in formats: - dt_obj = ModelTime.datetime_from_user_input(dt_rep) + dt_obj = ModelTime.parse_datetime(dt_rep) if dt_obj != valid: raise AssertionError("datetime not properly determined from user input") @@ -83,7 +83,7 @@ def test_set_datetime_and_units(): if mt.start_datetime != unix_t0: raise AssertionError("start_datetime None condition not being set to 1/1/1970") - mt.set_time_units(new_units) + mt.set_units(new_units) mt.set_start_datetime(new_dt) if mt.time_units != new_units: @@ -160,12 +160,12 @@ def test_datetime_intersect(): for (kper0, kstp0), dt in validate.items(): if kstp0 is None: - kper = mt.intersect(dt) + kper, _ = mt.intersect(dt) if kper != kper0: raise AssertionError("intersect() not returning correct stress-period") else: - kper, kstp = mt.intersect(dt, kper_kstp=True) + kper, kstp = mt.intersect(dt) if kper != kper0 or kstp != kstp0: raise AssertionError( "intersect() not returning correct stress-period and timestep" @@ -187,12 +187,12 @@ def test_totim_intersect(): for (kper0, kstp0), totim in validate.items(): if kstp0 is None: - kper = mt.intersect(totim=totim) + kper, _ = mt.intersect(totim=totim) if kper != kper0: raise AssertionError("intersect() not returning correct stress-period") else: - kper, kstp = mt.intersect(totim=totim, kper_kstp=True) + kper, kstp = mt.intersect(totim=totim) if kper != kper0 or kstp != kstp0: raise AssertionError( "intersect() not returning correct stress-period and timestep" @@ -240,7 +240,7 @@ def test_mf2005_modeltime(): if modeltime.start_datetime != start_datetime: raise AssertionError("start_datetime improperly stored") - result = modeltime.intersect("3/06/2024 23:59:59", kper_kstp=True) + result = modeltime.intersect("3/06/2024 23:59:59") if result != (2, 0): raise AssertionError("ModelTime intersect not working correctly") @@ -287,6 +287,6 @@ def test_mf6_modeltime(): if modeltime.start_datetime != start_datetime: raise AssertionError("start_datetime improperly stored") - result = modeltime.intersect("3/06/2024 23:59:59", kper_kstp=True) + result = modeltime.intersect("3/06/2024 23:59:59") if result != (2, 0): raise AssertionError("ModelTime intersect not working correctly") diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index 76a8231321..210f187fdd 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -13,20 +13,20 @@ class ModelTime: Parameters ---------- perlen : list, np.ndarray - list or numpy array of stress-period lengths + list or numpy array of stress period lengths nstp : list, np.ndarray - list or numpy array of number of time-steps per stress period + list or numpy array of number of time steps per stress period tsmult : list, np.ndarray - list or numpy array of timestep mult infomation + 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 datetime representation. Please see the - ModelTime.datetime_from_user_input documentation for a list + 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 determine identify - steady-state or transient stress-periods + optional list or numpy array of boolean flags that indicate if + stress periods are steady-state or transient """ def __init__( @@ -52,7 +52,7 @@ def __init__( self._nstp = nstp self._tsmult = tsmult self._time_units = self.timeunits_from_user_input(time_units) - self._start_datetime = self.datetime_from_user_input(start_datetime) + 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" @@ -74,7 +74,7 @@ def start_datetime(self): @property def perlen(self): """ - Returns a list or array of stress-period lengths + Returns a list or array of stress period lengths """ return self._perlen.copy() @@ -102,7 +102,7 @@ def tsmult(self): return self._tsmult.copy() @property - def period_data(self): + def perioddata(self): """ Returns a tuple of period data for the MF6 TDIS package containing records of [(perlen, nstp, tsmult), ....] for each stress period @@ -120,7 +120,7 @@ def steady_state(self): @property def totim(self): """ - Returns a list of totim values at the end of each time-step + Returns a list of totim values at the end of each time step """ if not self._totim_dict: @@ -131,7 +131,7 @@ def totim(self): @property def kper_kstp(self): """ - Returns a list of kper, kstp tuples that correspond to totim + Returns a list of kper, kstp tuples for all time steps """ if not self._totim_dict: @@ -141,7 +141,7 @@ def kper_kstp(self): @property def tslen(self): """ - Method to get a list of time step lengths for the entire model period + Method to get a list of time step lengths for all time steps """ n = 0 @@ -157,19 +157,20 @@ def tslen(self): return np.array(tslen) - def get_datetime_string(self, datetime_obj): + @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.datetime_from_user_input documentation for a list + user-supplied datetime representation. Please see the + ModelTime.parse_datetime documentation for a list of the supported representation types """ - dt = self.datetime_from_user_input(datetime_obj) + dt = ModelTime.parse_datetime(datetime_obj) return dt.strftime("%Y-%m-%dT%H:%M:%S") def set_start_datetime(self, datetime_obj): @@ -179,15 +180,15 @@ def set_start_datetime(self, datetime_obj): Parameters ---------- datetime_obj : various objects - user supplied datetime representation. Please see the - ModelTime.datetime_from_user_input documentation for a list + user-supplied datetime representation. Please see the + ModelTime.parse_datetime documentation for a list of the supported representation types """ - start_dt = self.datetime_from_user_input(datetime_obj) + start_dt = self.parse_datetime(datetime_obj) self._start_datetime = start_dt - def set_time_units(self, units): + def set_units(self, units): """ Method to reset the time units of the ModelTime class @@ -300,7 +301,7 @@ def _get_datetime_string_format(str_datetime): ---------- str_datetime : str string representation of date time. See the - ModelTime.datetime_from_user_input documentation for supported + ModelTime.parse_datetime documentation for supported formats Returns @@ -348,7 +349,7 @@ def _get_datetime_string_format(str_datetime): return str_rep @staticmethod - def datetime_from_user_input(datetime_obj): + def parse_datetime(datetime_obj): """ Method to create a datetime.datetime object from a variety of user inputs including the following: @@ -381,7 +382,7 @@ def datetime_from_user_input(datetime_obj): Parameters ---------- datetime_obj : various formats - a user supplied representation of date or datetime + a user-supplied representation of date or datetime Returns ------- @@ -412,12 +413,12 @@ def datetime_from_user_input(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 + stress period or stress period and time step combination Parameters ---------- kper : int - zero based stress-period number + zero based stress period number kstp : int or None optional zero based time-step number @@ -449,7 +450,7 @@ def get_datetime(self, kper, kstp=None): kper : int zero based modflow stress period number kstp : int - zero based timestep number + zero based time-step number Returns ------- @@ -494,33 +495,30 @@ def get_datetime(self, kper, kstp=None): return dt - def intersect(self, datetime_obj=None, totim=None, kper_kstp=False, forgrive=False): + 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 + get the model stress period and optional time-step associated with that time. Parameters ---------- datetime_obj : various objects - user supplied datetime representation. Please see the - ModelTime.datetime_from_user_input documentation for a list + 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 - kper_kstp : bool - flag to return a tuple of zero based stress-period and time-step. - Default is False and returns the stress-period only forgive : bool optional flag to forgive time intersections that are outside of the model time domain. Default is False Returns ------- - int or tuple: kper or (kper, kstp) + tuple: (kper, kstp) """ if datetime_obj is not None: - datetime_obj = self.datetime_from_user_input(datetime_obj) + datetime_obj = self.parse_datetime(datetime_obj) timedelta = datetime_obj - self.start_datetime if self.time_units == "unknown": @@ -571,7 +569,7 @@ def intersect(self, datetime_obj=None, totim=None, kper_kstp=False, forgrive=Fal ) if totim > self.totim[-1] or totim <= 0: - if forgrive: + if forgive: return None if datetime_obj is None: msg = ( @@ -592,7 +590,4 @@ def intersect(self, datetime_obj=None, totim=None, kper_kstp=False, forgrive=Fal idx = sorted(np.where(np.array(self.totim) >= totim)[0])[0] per, stp = self.kper_kstp[idx] - if kper_kstp: - return per, stp - - return per + return per, stp From b135d69bd8bd2d46a9a4d7ba1126495079571bb5 Mon Sep 17 00:00:00 2001 From: jlarsen Date: Tue, 17 Dec 2024 15:06:11 -0800 Subject: [PATCH 7/7] Refactors `set` methods to property setters and added a `datetimes` property to get a list of datetimes at the end of each time step --- autotest/test_modeltime.py | 4 +- flopy/discretization/modeltime.py | 65 ++++++++++++++++++------------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/autotest/test_modeltime.py b/autotest/test_modeltime.py index 067913ccc0..b17c239493 100644 --- a/autotest/test_modeltime.py +++ b/autotest/test_modeltime.py @@ -83,8 +83,8 @@ def test_set_datetime_and_units(): if mt.start_datetime != unix_t0: raise AssertionError("start_datetime None condition not being set to 1/1/1970") - mt.set_units(new_units) - mt.set_start_datetime(new_dt) + mt.time_units = new_units + mt.start_datetime = new_dt if mt.time_units != new_units: raise AssertionError("time_units setting not behaving properly") diff --git a/flopy/discretization/modeltime.py b/flopy/discretization/modeltime.py index 210f187fdd..d10c297eb8 100644 --- a/flopy/discretization/modeltime.py +++ b/flopy/discretization/modeltime.py @@ -64,6 +64,20 @@ def time_units(self): """ 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): """ @@ -71,6 +85,22 @@ def start_datetime(self): """ 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): """ @@ -138,6 +168,13 @@ def kper_kstp(self): 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): """ @@ -173,34 +210,6 @@ def get_datetime_string(datetime_obj): dt = ModelTime.parse_datetime(datetime_obj) return dt.strftime("%Y-%m-%dT%H:%M:%S") - def set_start_datetime(self, datetime_obj): - """ - 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 - - def set_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 - def _set_totim_dict(self): """ Method to setup a dictionary of (kper, kstp): totim that is used