Time/date: preserve calendar dates on start date changes + fix solver time exports with older format

This commit is contained in:
Dylan Jeannin committed 2026-09-16 13:34:58 +02:00
1 parent d15fa684b7
commit d4c596a6fe
8 files changed
+150 -50

No files matched your search

+27 -1
View File
@@ -21,7 +21,7 @@ import shutil
import logging import logging
from datetime import datetime from datetime import datetime
from tools import timer, timestamp from tools import timer, timestamp, parse_datetime
from functools import reduce from functools import reduce
from Model.Tools.PamhyrListExt import PamhyrModelList from Model.Tools.PamhyrListExt import PamhyrModelList
@@ -32,6 +32,7 @@ from Model.Scenario import Scenario
from Model.Status import StudyStatus from Model.Status import StudyStatus
from Model.Except import NotImplementedMethodeError from Model.Except import NotImplementedMethodeError
from Model.River import River from Model.River import River
from Model.TimeShift import shift_river_times, shift_database_times
from Model.Geometry.Reach import Reach from Model.Geometry.Reach import Reach
from Model.HydraulicStructures.HydraulicStructures import ( from Model.HydraulicStructures.HydraulicStructures import (
HydraulicStructure HydraulicStructure
@@ -72,6 +73,7 @@ class Study(SQLModel):
self.description = "" self.description = ""
# Time system # Time system
self._time_system = "time" self._time_system = "time"
self._pending_time_offset = 0.0
self._date = datetime.fromtimestamp(0) self._date = datetime.fromtimestamp(0)
if init_new: if init_new:
@@ -198,6 +200,16 @@ class Study(SQLModel):
self._date = date self._date = date
self.status.modified() 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 @property
def date(self): def date(self):
return self._date return self._date
@@ -460,6 +472,17 @@ class Study(SQLModel):
) )
progress() 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._save_submodel(
[self.scenarios, self._river], [self.scenarios, self._river],
data=progress data=progress
@@ -594,6 +617,9 @@ class Study(SQLModel):
self.status.stop_loading() self.status.stop_loading()
self.status.scenario = old_scenario 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 # FIXME: Disable scenario cache to save memory usage
# self._river_scenario_cache[scenario] = river # self._river_scenario_cache[scenario] = river
+60
View File
@@ -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]
)
+2 -6
View File
@@ -594,9 +594,7 @@ class AdisTSwc(AdisTS):
f.write(f"*---------++++++++++\n") f.write(f"*---------++++++++++\n")
for LC_data in LC.data: for LC_data in LC.data:
tmp = timestamp_to_old_pamhyr_date_adists( tmp = timestamp_to_old_pamhyr_date_adists(int(LC_data[0]))
int(self.export_time(study, LC_data[0]))
)
f.write(" ".join((f"{tmp}", f.write(" ".join((f"{tmp}",
f"{LC_data[1]}\n"))) f"{LC_data[1]}\n")))
f.write(f"*\n") f.write(f"*\n")
@@ -628,9 +626,7 @@ class AdisTSwc(AdisTS):
f.write(f"*---------++++++++++\n") f.write(f"*---------++++++++++\n")
for BC_data in BC.data: for BC_data in BC.data:
tmp = timestamp_to_old_pamhyr_date_adists( tmp = timestamp_to_old_pamhyr_date_adists(int(BC_data[0]))
int(self.export_time(study, BC_data[0]))
)
f.write(" ".join((f"{tmp}", f.write(" ".join((f"{tmp}",
f"{BC_data[1]}\n"))) f"{BC_data[1]}\n")))
f.write(f"*\n") f.write(f"*\n")
+2 -6
View File
@@ -884,9 +884,7 @@ class AdisTTwc(AdisTT):
f.write("*temps |temperature (degC)\n") f.write("*temps |temperature (degC)\n")
f.write("*---------++++++++++\n") f.write("*---------++++++++++\n")
for value in boundary_condition.data: for value in boundary_condition.data:
date = timestamp_to_old_pamhyr_date_adists( date = timestamp_to_old_pamhyr_date_adists(int(value[0]))
int(self.export_time(study, value[0]))
)
f.write(f"{date} {value[1]}\n") f.write(f"{date} {value[1]}\n")
f.write("*\n") f.write("*\n")
return filename return filename
@@ -909,9 +907,7 @@ class AdisTTwc(AdisTT):
f.write("*temps |value\n") f.write("*temps |value\n")
f.write("*---------++++++++++\n") f.write("*---------++++++++++\n")
for value in weather_parameter.data: for value in weather_parameter.data:
date = timestamp_to_old_pamhyr_date_adists( date = timestamp_to_old_pamhyr_date_adists(int(value[0]))
int(self.export_time(study, value[0]))
)
f.write(f"{date} {value[1]}\n") f.write(f"{date} {value[1]}\n")
f.write("*\n") f.write("*\n")
return filename return filename
-9
View File
@@ -45,15 +45,6 @@ logger = logging.getLogger()
class CommandLineSolver(AbstractSolver): class CommandLineSolver(AbstractSolver):
_type = "" _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): def __init__(self, name):
super(CommandLineSolver, self).__init__(name) super(CommandLineSolver, self).__init__(name)
+3 -3
View File
@@ -333,7 +333,7 @@ class Mage(CommandLineSolver):
v1 = d[1] v1 = d[1]
if t in ["HYD", "LIM"]: 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") f.write(f"{v0:9} {v1:9} \n")
@@ -429,7 +429,7 @@ class Mage(CommandLineSolver):
if d.is_deleted(): if d.is_deleted():
continue continue
time = self.export_time(study, d[0]) time = d[0]
if lateral.lctype in ["EV"]: if lateral.lctype in ["EV"]:
f.write(f"{time:10.3f}{-d[1]:10.3f}\n") f.write(f"{time:10.3f}{-d[1]:10.3f}\n")
else: else:
@@ -1002,7 +1002,7 @@ class Mage8(Mage):
# Data # Data
for d in bound.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") f.write(f"{time:10.3f}{d[1]:10.3f}\n")
return files return files
+2 -4
View File
@@ -550,8 +550,7 @@ class Rubar3(CommandLineSolver):
for bc in bcs: for bc in bcs:
f.write(f"{len(bc)}\n") f.write(f"{len(bc)}\n")
for d0, d1 in bc.data: for d0, d1 in bc.data:
time = self.export_time(study, d0) f.write(f"{d1} {d0}\n")
f.write(f"{d1} {time}\n")
def _export_condav(self, study, repertory, qlog, name="0"): def _export_condav(self, study, repertory, qlog, name="0"):
if qlog is not None: if qlog is not None:
@@ -578,8 +577,7 @@ class Rubar3(CommandLineSolver):
f.write(f"{d1} {d0}\n") f.write(f"{d1} {d0}\n")
else: else:
for d0, d1 in bc.data: for d0, d1 in bc.data:
time = self.export_time(study, d0) f.write(f"{d0} {d1}\n")
f.write(f"{time} {d1}\n")
def read_profil(self, study, fname, results, qlog=None, name="0"): def read_profil(self, study, fname, results, qlog=None, name="0"):
logger.info(f"read: profil.{name}") logger.info(f"read: profil.{name}")
+54 -21
View File
@@ -155,27 +155,60 @@ All right reserved.
self.find(QLabel, "label_date").setEnabled(True) self.find(QLabel, "label_date").setEnabled(True)
self.find(QDateTimeEdit, "dateTimeEdit_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): def accept(self):
# if (self._study is not None preserve_dates = False
# and self._study.time_system == "date" if (self._study is not None
# and self.get_radio_button("radioButton_date")): and self._study.time_system == "date"
# old_date = parse_datetime(self._study.date) and self.get_radio_button("radioButton_date")):
# new_date = parse_datetime(self.get_datetime_edit("dateTimeEdit_date")) old_date = parse_datetime(self._study.date)
# if new_date != old_date: new_date = parse_datetime(self.get_datetime_edit("dateTimeEdit_date"))
# answer = QMessageBox.question( if new_date != old_date:
# self, dialog = QMessageBox(self)
# _translate("Study", "Change simulation start date?"), dialog.setWindowTitle(_translate("Study", "Change simulation start date?"))
# _translate( dialog.setText(_translate(
# "Study", "Study", "How should the data dates change?"
# "All displayed data dates will shift with the simulation start." ))
# "\nTheir elapsed simulation times will remain unchanged." dialog.setInformativeText(_translate(
# "\n\nPrevious start: {old}\nNew start: {new}" "Study",
# ).format(old=format_datetime(old_date), new=format_datetime(new_date)), "Previous start: {old}\nNew start: {new}\n\n"
# QMessageBox.Ok | QMessageBox.Cancel, "Shift dates: keep elapsed simulation times unchanged.\n"
# QMessageBox.Cancel "Keep dates: adjust elapsed times so calendar dates stay unchanged."
# ) ).format(old=format_datetime(old_date), new=format_datetime(new_date)))
# if answer != QMessageBox.Ok: shift = dialog.addButton(_translate("Study", "Shift dates"), QMessageBox.AcceptRole)
# return 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") name = self.get_line_edit_text("lineEdit_name")
description = self.get_plaintext_edit_text("plainTextEdit_description") description = self.get_plaintext_edit_text("plainTextEdit_description")
@@ -193,7 +226,7 @@ All right reserved.
self._study.description = description self._study.description = description
if self.get_radio_button("radioButton_date"): if self.get_radio_button("radioButton_date"):
date = self.get_datetime_edit("dateTimeEdit_date") date = self.get_datetime_edit("dateTimeEdit_date")
self._study.use_date(date) self._study.change_start_date(date, preserve_dates=preserve_dates)
else: else:
self._study.use_time() self._study.use_time()