diff --git a/src/Model/Study.py b/src/Model/Study.py index 491a26fb..0c2f302b 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -21,7 +21,7 @@ import shutil import logging from datetime import datetime -from tools import timer, timestamp +from tools import timer, timestamp, parse_datetime from functools import reduce from Model.Tools.PamhyrListExt import PamhyrModelList @@ -32,6 +32,7 @@ from Model.Scenario import Scenario from Model.Status import StudyStatus from Model.Except import NotImplementedMethodeError from Model.River import River +from Model.TimeShift import shift_river_times, shift_database_times from Model.Geometry.Reach import Reach from Model.HydraulicStructures.HydraulicStructures import ( HydraulicStructure @@ -72,6 +73,7 @@ class Study(SQLModel): self.description = "" # Time system self._time_system = "time" + self._pending_time_offset = 0.0 self._date = datetime.fromtimestamp(0) if init_new: @@ -198,6 +200,16 @@ class Study(SQLModel): self._date = date self.status.modified() + def change_start_date(self, date, preserve_dates=False): + """Change the origin, optionally preserving calendar dates of all series.""" + new_date = parse_datetime(date) + offset = (parse_datetime(self.date) - new_date).total_seconds() + if preserve_dates and offset: + shift_river_times(self.river, offset) + self._pending_time_offset += offset + self._river_scenario_cache.clear() + self.use_date(new_date) + @property def date(self): return self._date @@ -460,6 +472,17 @@ class Study(SQLModel): ) progress() + if self._pending_time_offset: + self._db.execute('SAVEPOINT shift_time_origin') + try: + shift_database_times(self._db, self._pending_time_offset) + self._db.execute('RELEASE SAVEPOINT shift_time_origin') + except Exception: + self._db.execute('ROLLBACK TO SAVEPOINT shift_time_origin') + self._db.execute('RELEASE SAVEPOINT shift_time_origin') + raise + self._pending_time_offset = 0.0 + self._save_submodel( [self.scenarios, self._river], data=progress @@ -594,6 +617,9 @@ class Study(SQLModel): self.status.stop_loading() self.status.scenario = old_scenario + if self._pending_time_offset: + shift_river_times(river, self._pending_time_offset) + # FIXME: Disable scenario cache to save memory usage # self._river_scenario_cache[scenario] = river diff --git a/src/Model/TimeShift.py b/src/Model/TimeShift.py new file mode 100644 index 00000000..0d506824 --- /dev/null +++ b/src/Model/TimeShift.py @@ -0,0 +1,60 @@ +"""Shift temporal series when preserving calendar dates across a new origin.""" +from Model.BoundaryCondition.BoundaryCondition import BoundaryCondition +from Model.LateralContribution.LateralContribution import LateralContribution +from Model.WeatherParameters.WeatherParameters import WeatherParameters +from Model.BoundaryConditionsAdisTS.BoundaryConditionAdisTS import BoundaryConditionAdisTS +from Model.LateralContributionsAdisTS.LateralContributionAdisTS import LateralContributionAdisTS +from Model.BoundaryConditionsTemperature.BoundaryConditionTemperature import BoundaryConditionTemperature + + +def shift_river_times(river, offset): + changes = [] + seen = set() + + def collect(obj, data): + temporal = isinstance(obj, (BoundaryConditionAdisTS, + LateralContributionAdisTS, + BoundaryConditionTemperature)) + if isinstance(obj, (BoundaryCondition, LateralContribution, WeatherParameters)): + temporal = bool(obj.header) and obj.header[0] == 'time' + if temporal: + for point in obj._data: + if id(point) not in seen: + changes.append((point, float(point[0]) + offset)) + seen.add(id(point)) + + river._data_traversal(modifier=collect) + print(f"Shifting {len(changes)} temporal points by {offset} seconds.") + for point, value in changes: + print(f" {point[0]} -> {value}") + # This is a global change of origin, not a scenario-specific edit. + point[0] = value + + +def shift_database_times(connection, offset): + tables = [ + ('boundary_condition_data', 'boundary_condition', 'bc', BoundaryCondition), + ('lateral_contribution_data', 'lateral_contribution', 'lc', LateralContribution), + ('weather_parameters_data', 'weather_parameters', 'wp', WeatherParameters), + ('boundary_condition_data_adists', None, None, None), + ('lateral_contribution_data_adists', None, None, None), + ('boundary_condition_data_temperature', None, None, None), + ] + for table, parent, fk, model in tables: + if model is None: + rows = connection.execute(f'SELECT rowid, data0 FROM {table}').fetchall() + else: + rows = [] + for kind, in connection.execute(f'SELECT DISTINCT type FROM {parent}'): + headers = model._get_ctor_from_type(kind)().header + if not headers or headers[0] != 'time': + continue + rows.extend(connection.execute( + f'SELECT d.rowid, d.data0 FROM {table} d JOIN {parent} p ' + f'ON p.pamhyr_id=d.{fk} AND p.scenario=d.scenario WHERE p.type=?', + (kind,) + ).fetchall()) + connection.executemany( + f'UPDATE {table} SET data0=? WHERE rowid=?', + [(float(value) + offset, rowid) for rowid, value in rows] + ) diff --git a/src/Solver/AdisTS.py b/src/Solver/AdisTS.py index 7f2f8d09..71d62395 100644 --- a/src/Solver/AdisTS.py +++ b/src/Solver/AdisTS.py @@ -594,9 +594,7 @@ class AdisTSwc(AdisTS): f.write(f"*---------++++++++++\n") for LC_data in LC.data: - tmp = timestamp_to_old_pamhyr_date_adists( - int(self.export_time(study, LC_data[0])) - ) + tmp = timestamp_to_old_pamhyr_date_adists(int(LC_data[0])) f.write(" ".join((f"{tmp}", f"{LC_data[1]}\n"))) f.write(f"*\n") @@ -628,9 +626,7 @@ class AdisTSwc(AdisTS): f.write(f"*---------++++++++++\n") for BC_data in BC.data: - tmp = timestamp_to_old_pamhyr_date_adists( - int(self.export_time(study, BC_data[0])) - ) + tmp = timestamp_to_old_pamhyr_date_adists(int(BC_data[0])) f.write(" ".join((f"{tmp}", f"{BC_data[1]}\n"))) f.write(f"*\n") diff --git a/src/Solver/AdisTT.py b/src/Solver/AdisTT.py index a379445c..7605ed13 100644 --- a/src/Solver/AdisTT.py +++ b/src/Solver/AdisTT.py @@ -884,9 +884,7 @@ class AdisTTwc(AdisTT): f.write("*temps |temperature (degC)\n") f.write("*---------++++++++++\n") for value in boundary_condition.data: - date = timestamp_to_old_pamhyr_date_adists( - int(self.export_time(study, value[0])) - ) + date = timestamp_to_old_pamhyr_date_adists(int(value[0])) f.write(f"{date} {value[1]}\n") f.write("*\n") return filename @@ -909,9 +907,7 @@ class AdisTTwc(AdisTT): f.write("*temps |value\n") f.write("*---------++++++++++\n") for value in weather_parameter.data: - date = timestamp_to_old_pamhyr_date_adists( - int(self.export_time(study, value[0])) - ) + date = timestamp_to_old_pamhyr_date_adists(int(value[0])) f.write(f"{date} {value[1]}\n") f.write("*\n") return filename diff --git a/src/Solver/CommandLine.py b/src/Solver/CommandLine.py index 32dfaec9..c26cc797 100644 --- a/src/Solver/CommandLine.py +++ b/src/Solver/CommandLine.py @@ -45,15 +45,6 @@ logger = logging.getLogger() class CommandLineSolver(AbstractSolver): _type = "" - @staticmethod - def export_time(study, value): - """Return elapsed seconds before applying the solver file's units.""" - if study.time_system != "date": - return value - # Match the UTC timestamps produced by date entry in the tables. - start = parse_datetime(study.date).replace(tzinfo=timezone.utc) - return float(value) - start.timestamp() - def __init__(self, name): super(CommandLineSolver, self).__init__(name) diff --git a/src/Solver/Mage.py b/src/Solver/Mage.py index f82f05a1..7cc9c330 100644 --- a/src/Solver/Mage.py +++ b/src/Solver/Mage.py @@ -333,7 +333,7 @@ class Mage(CommandLineSolver): v1 = d[1] if t in ["HYD", "LIM"]: - v0 = self.export_time(study, v0) / 60 # Minutes + v0 /= 60 # Convert first column to minutes f.write(f"{v0:9} {v1:9} \n") @@ -429,7 +429,7 @@ class Mage(CommandLineSolver): if d.is_deleted(): continue - time = self.export_time(study, d[0]) + time = d[0] if lateral.lctype in ["EV"]: f.write(f"{time:10.3f}{-d[1]:10.3f}\n") else: @@ -1002,7 +1002,7 @@ class Mage8(Mage): # Data for d in bound.data: - time = self.export_time(study, d[0]) / 60 + time = d[0] / 60 f.write(f"{time:10.3f}{d[1]:10.3f}\n") return files diff --git a/src/Solver/RubarBE.py b/src/Solver/RubarBE.py index 48ea9ecd..4da1ad1f 100644 --- a/src/Solver/RubarBE.py +++ b/src/Solver/RubarBE.py @@ -550,8 +550,7 @@ class Rubar3(CommandLineSolver): for bc in bcs: f.write(f"{len(bc)}\n") for d0, d1 in bc.data: - time = self.export_time(study, d0) - f.write(f"{d1} {time}\n") + f.write(f"{d1} {d0}\n") def _export_condav(self, study, repertory, qlog, name="0"): if qlog is not None: @@ -578,8 +577,7 @@ class Rubar3(CommandLineSolver): f.write(f"{d1} {d0}\n") else: for d0, d1 in bc.data: - time = self.export_time(study, d0) - f.write(f"{time} {d1}\n") + f.write(f"{d0} {d1}\n") def read_profil(self, study, fname, results, qlog=None, name="0"): logger.info(f"read: profil.{name}") diff --git a/src/View/Study/Window.py b/src/View/Study/Window.py index 92b5b38c..55dad77e 100644 --- a/src/View/Study/Window.py +++ b/src/View/Study/Window.py @@ -155,27 +155,60 @@ All right reserved. self.find(QLabel, "label_date").setEnabled(True) self.find(QDateTimeEdit, "dateTimeEdit_date").setEnabled(True) + def _close_study_editors(self): + def close_children(parent): + for _, window in list(parent.sub_win_list): + if window is self: + continue + if not close_children(window): + return False + if not window.close(): + return False + undo = getattr(window, "_undo_stack", None) + if undo is not None: + undo.clear() + window.deleteLater() + return True + + return close_children(self.parent) + def accept(self): - # if (self._study is not None - # and self._study.time_system == "date" - # and self.get_radio_button("radioButton_date")): - # old_date = parse_datetime(self._study.date) - # new_date = parse_datetime(self.get_datetime_edit("dateTimeEdit_date")) - # if new_date != old_date: - # answer = QMessageBox.question( - # self, - # _translate("Study", "Change simulation start date?"), - # _translate( - # "Study", - # "All displayed data dates will shift with the simulation start." - # "\nTheir elapsed simulation times will remain unchanged." - # "\n\nPrevious start: {old}\nNew start: {new}" - # ).format(old=format_datetime(old_date), new=format_datetime(new_date)), - # QMessageBox.Ok | QMessageBox.Cancel, - # QMessageBox.Cancel - # ) - # if answer != QMessageBox.Ok: - # return + preserve_dates = False + if (self._study is not None + and self._study.time_system == "date" + and self.get_radio_button("radioButton_date")): + old_date = parse_datetime(self._study.date) + new_date = parse_datetime(self.get_datetime_edit("dateTimeEdit_date")) + if new_date != old_date: + dialog = QMessageBox(self) + dialog.setWindowTitle(_translate("Study", "Change simulation start date?")) + dialog.setText(_translate( + "Study", "How should the data dates change?" + )) + dialog.setInformativeText(_translate( + "Study", + "Previous start: {old}\nNew start: {new}\n\n" + "Shift dates: keep elapsed simulation times unchanged.\n" + "Keep dates: adjust elapsed times so calendar dates stay unchanged." + ).format(old=format_datetime(old_date), new=format_datetime(new_date))) + shift = dialog.addButton(_translate("Study", "Shift dates"), QMessageBox.AcceptRole) + keep = dialog.addButton(_translate("Study", "Keep dates"), QMessageBox.ActionRole) + cancel = dialog.addButton(QMessageBox.Cancel) + dialog.setDefaultButton(cancel) + dialog.exec() + if dialog.clickedButton() not in (shift, keep): + return + preserve_dates = dialog.clickedButton() is keep + + if self._study is not None: + date_mode = self.get_radio_button("radioButton_date") + mode_changed = date_mode != (self._study.time_system == "date") + date_changed = date_mode and ( + parse_datetime(self.get_datetime_edit("dateTimeEdit_date")) + != parse_datetime(self._study.date) + ) + if (mode_changed or date_changed) and not self._close_study_editors(): + return name = self.get_line_edit_text("lineEdit_name") description = self.get_plaintext_edit_text("plainTextEdit_description") @@ -193,7 +226,7 @@ All right reserved. self._study.description = description if self.get_radio_button("radioButton_date"): date = self.get_datetime_edit("dateTimeEdit_date") - self._study.use_date(date) + self._study.change_start_date(date, preserve_dates=preserve_dates) else: self._study.use_time()