From 7ca6e695262724a028ae768a8d4ef8fdb3a3847e Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 31 Oct 2025 14:43:01 +0100 Subject: [PATCH 01/40] Pamhyr2: Add save action (ctrl+s) in each pamhyr window. --- src/View/MainWindow.py | 11 +++++++++-- src/View/Tools/PamhyrWindow.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index e63954ec..cf2c4add 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -637,7 +637,10 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): logger.info(f"Open Study - {self._study.name}") self.set_title() - def save_study(self): + def _save(self, source): + self.save_study(progress_parent=source) + + def save_study(self, progress_parent=None): """Save current study Save current study, if study as no associate file, open a @@ -667,11 +670,15 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self._backup_timer.blockSignals(True) self._save_mutex.lock() + parent = self + if progress_parent is not None: + parent = progress_parent + sql_request_count = self._study.sql_save_request_count() progress = QProgressDialog( "Saving...", None, 0, sql_request_count, - parent=self + parent=parent ) progress.setWindowModality(Qt.WindowModal) progress.setValue(0) diff --git a/src/View/Tools/PamhyrWindow.py b/src/View/Tools/PamhyrWindow.py index 321c2864..ffa0e90f 100644 --- a/src/View/Tools/PamhyrWindow.py +++ b/src/View/Tools/PamhyrWindow.py @@ -182,6 +182,7 @@ class PamhyrWindow(ASubMainWindow, ListedSubWindow, PamhyrWindowTools): self._set_title() self._set_icon() + self._setup_save_sc() def _set_title(self): title = self._title @@ -196,12 +197,25 @@ class PamhyrWindow(ASubMainWindow, ListedSubWindow, PamhyrWindowTools): self.ui.setWindowTitle(title) + def _setup_save_sc(self): + if self._parent is None: + return + + self._save_sc = QShortcut(QKeySequence("Ctrl+S"), self) + self._save_sc.activated.connect(lambda: self._save(self)) + def closeEvent(self, event): self._close_sub_window() self._propagate_update(Modules.WINDOW_LIST) super(PamhyrWindow, self).closeEvent(event) + def _save(self, source): + if self._parent is None: + return + + return self._parent._save(source) + class PamhyrDialog(ASubWindow, ListedSubWindow, PamhyrWindowTools): _pamhyr_ui = "dummy" From 16ee5a90e4725d6fef39f83f71b0fc3de56f45e4 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 31 Oct 2025 15:05:23 +0100 Subject: [PATCH 02/40] Results: Fix update table creation. --- src/Model/Results/River/River.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Model/Results/River/River.py b/src/Model/Results/River/River.py index d111bf9f..bed5fd45 100644 --- a/src/Model/Results/River/River.py +++ b/src/Model/Results/River/River.py @@ -105,16 +105,6 @@ class Profile(SQLSubModel): @classmethod def _db_update(cls, execute, version, data=None): major, minor, release = version.strip().split(".") - create = False - - if major == "0" and int(minor) < 2: - cls._db_create(execute) - create = True - - if major == "0" and int(minor) == 2: - if int(release) < 1 and not create: - cls._db_create(execute) - create = True return cls._update_submodel(execute, version, data) From a5a64f2080ff5d7b270cdf1a1c6d3f048441c30c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 31 Oct 2025 15:41:25 +0100 Subject: [PATCH 03/40] MainWindow: Minor change and fix. --- src/View/MainWindow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index cf2c4add..c7a303fe 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -276,7 +276,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): "action_menu_new": self.open_new_study, "action_menu_edit": self.open_edit_study, "action_menu_open": self.open_model, - "action_menu_save": self.save_study, + "action_menu_save": lambda: self.save_study(), "action_menu_save_as": self.save_as_study, "action_menu_numerical_parameter": self.open_solver_parameters, "action_menu_edit_scenarios": self.open_scenarios, @@ -691,6 +691,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): progress=lambda: progress.setValue(progress.value() + 1) ) + progress.close() status += " Done" logger.info(status) self.statusbar.showMessage(status, 3000) @@ -765,6 +766,8 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): progress.setValue(progress.value() + 1) + progress.close() + def save_as_study_single_scenario(self, sid=-1): sql_request_count = self._study.sql_save_request_count() @@ -789,6 +792,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) status += " Done" + progress.close() logger.info(status) self.statusbar.showMessage(status, 3000) From 44a8be6be382efc6b8d28d91d932a9a3e050d966 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 31 Oct 2025 17:27:47 +0100 Subject: [PATCH 04/40] WaitingDialog: Minor change. --- src/View/WaitingDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/WaitingDialog.py b/src/View/WaitingDialog.py index 283fdc30..b61228e6 100644 --- a/src/View/WaitingDialog.py +++ b/src/View/WaitingDialog.py @@ -63,7 +63,7 @@ class WaitingDialog(PamhyrDialog): ". ", ". ", ".. ", ".. ", "...", "..."], - ["o ", " o ", " o ", " o", " o ", " o "], + ["o ", " o ", " o ", " o", " o ", " o "], ["█▓▒░", "▓█▓▒", "▒▓█▓", "░▒▓█", "▒▓█▓", "▓█▓▒"], "▖▘▝▗", "αβγδεζηθικλμνξοπρστυφχψω", From c63c77698913765642d8269938f100d3932983d5 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 3 Nov 2025 09:46:40 +0100 Subject: [PATCH 05/40] MainWindow: Add message box at results opening error. --- src/View/MainWindow.py | 16 ++++++++++++++++ src/View/Translate.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index c7a303fe..7ce68594 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -1569,10 +1569,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # No results available if results is None: + self.msg_open_results_no_results() return # results does not have values, for example if geometry missmatch if not results.is_valid: + self.msg_open_results_invalid_results() return if results.get('study_revision') != self._study.status.version: @@ -1598,6 +1600,20 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) res.show() + def msg_open_results_no_results(self): + self.message_box( + window_title=self._trad["Warning"], + text=self._trad["mb_open_results_title"], + informative_text=self._trad["mb_open_results_no_results_msg"] + ) + + def msg_open_results_invalid_results(self): + self.message_box( + window_title=self._trad["Error"], + text=self._trad["mb_open_results_title"], + informative_text=self._trad["mb_open_results_invalid_results_msg"] + ) + def open_solver_results_adists(self, solver, results=None): def reading_fn(): self._tmp_results = results diff --git a/src/View/Translate.py b/src/View/Translate.py index 30434d4f..6bd9018b 100644 --- a/src/View/Translate.py +++ b/src/View/Translate.py @@ -180,6 +180,9 @@ class MainTranslate(UnitTranslate): ) # Message box + self._dict["Error"] = _translate( + "MainWindow", "Error" + ) self._dict["Warning"] = _translate( "MainWindow", "Warning" ) @@ -234,6 +237,17 @@ class MainTranslate(UnitTranslate): self._dict["mb_diff_results_param_msg"] = _translate( "MainWindow", "Results comparison parameters is invalid" ) + + self._dict["mb_open_results_title"] = _translate( + "MainWindow", "Open results" + ) + self._dict["mb_open_results_no_results_msg"] = _translate( + "MainWindow", "No results found" + ) + self._dict["mb_open_results_invalid_results_msg"] = _translate( + "MainWindow", "Failed to read results" + ) + self._dict["mb_diff_results_compatibility_msg"] = _translate( "MainWindow", "Results comparison with two " From 4e1acfecdcce7fd732bb65be2b34df3a9f299e76 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 3 Nov 2025 14:49:12 +0100 Subject: [PATCH 06/40] Results: Refacto read csv. --- src/View/Results/Window.py | 74 ++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 0004152c..a5b2d89d 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -1231,7 +1231,6 @@ class ResultsWindow(PamhyrWindow): return def import_data(self): - file_types = [ self._trad["file_csv"], self._trad["file_all"], @@ -1248,29 +1247,38 @@ class ResultsWindow(PamhyrWindow): if filename == "": return - sep = " " + x, y = self.read_csv_file_data(filename) + data = self.read_csv_file_format(x, y) + self.read_csv_file_update_plot(data) - def is_float(string): - if string.replace(".", "").isnumeric(): - return True - else: - return False + def read_csv_file_data(self, filename): + sep = "," + x = [] + y = [] with open(filename, 'r', newline='') as f: lines = f.readlines() - x = [] - y = [] for line in lines: if line[0] != "*" and line[0] != "#" and line[0] != "$": row = line.split(sep) - if len(row) >= 2: - if is_float(row[0]) and is_float(row[1]): - x.append(float(row[0])) - y.append(float(row[1])) + if len(row) >= 2: + try: + fx, fy = float(row[0]), float(row[1]) + x.append(fx) + y.append(fy) + except: + continue + + return x, y + + def read_csv_file_format(self, x, y): data_type_lst = ['Q(t)', 'Z(t)', 'Z(x)'] data_type, ok = QInputDialog.getItem( - self, 'Data type', 'Chose the type of data:', data_type_lst) + self, 'Data type', + 'Chose the type of data:', + data_type_lst + ) if not ok: return @@ -1291,25 +1299,37 @@ class ResultsWindow(PamhyrWindow): tmp_unit = {'Z': ' (m)', 'Q': ' (m³/s)'} - data = {'type_x': tmp_dict[data_type[2]], - 'type_y': tmp_dict[data_type[0]], - 'legend': legend, - 'unit': tmp_unit[data_type[0]], - 'x': x, - 'y': y} + data = { + 'type_x': tmp_dict[data_type[2]], + 'type_y': tmp_dict[data_type[0]], + 'legend': legend, + 'unit': tmp_unit[data_type[0]], + 'x': x, 'y': y + } - if data_type == 'Z(x)': - line = self.canvas_2.axes.plot(x, y, marker="+", - label=legend + ' (m)') + return data + + def read_csv_file_update_plot(self, data): + x, y = data['x'], data['y'] + legend = data['legend'] + unit = data['unit'] + + if data['type_x'] == 'water_elevation' and data['type_y'] == 'time': + line = self.canvas_2.axes.plot( + x, y, marker="+", + label=legend + ' ' + unit + ) self.plot_rkc.canvas.draw_idle() self.plot_rkc.update_idle() - if data_type == 'Q(t)': - line = self.canvas_4.axes.plot(x, y, marker="+", - label=legend + ' (m³/s)') + if data['type_x'] == 'discharge' and data['type_y'] == 'time': + line = self.canvas_4.axes.plot( + x, y, marker="+", + label=legend + ' ' + unit + ) self.plot_h._line.append(line) self.plot_h.enable_legend() self.plot_h.canvas.draw_idle() - self.plot_h.update_idle + self.plot_h.update_idle() for p in self._additional_plot: self._additional_plot[p].add_imported_plot(data) From 2487bec6d653a6ed931df8877e841febbc9fdd88 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 3 Nov 2025 15:04:05 +0100 Subject: [PATCH 07/40] Results: Minor change. --- src/View/Results/Window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index a5b2d89d..3040fc99 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -1321,6 +1321,7 @@ class ResultsWindow(PamhyrWindow): ) self.plot_rkc.canvas.draw_idle() self.plot_rkc.update_idle() + if data['type_x'] == 'discharge' and data['type_y'] == 'time': line = self.canvas_4.axes.plot( x, y, marker="+", From 14549330b9b661eb0689651b2fd8d87c2f0cc4b8 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 3 Nov 2025 16:49:59 +0100 Subject: [PATCH 08/40] Results: Add additional data to SQL study save. --- src/Model/Results/Results.py | 136 ++++++++++++++++++++++++++++++++++- src/Model/Study.py | 2 +- src/View/Results/Window.py | 81 +++++++++++++-------- 3 files changed, 187 insertions(+), 32 deletions(-) diff --git a/src/Model/Results/Results.py b/src/Model/Results/Results.py index 3b352bec..c6e51e2e 100644 --- a/src/Model/Results/Results.py +++ b/src/Model/Results/Results.py @@ -29,8 +29,133 @@ from Model.Results.River.River import River logger = logging.getLogger() +class AdditionalData(SQLSubModel): + _sub_classes = [] + + def __init__(self, id=-1, study=None, data=None): + super(AdditionalData, self).__init__( + id=id, status=study.status, + owner_scenario=study.status.scenario.id + ) + + self._study = study + self._data = data + + @property + def data(self): + return self._data + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE results_add_data{ext} ( + {cls.create_db_add_pamhyr_id()}, + result INTEGER NOT NULL, + type_x TEXT NOT NULL, + type_y TEXT NOT NULL, + legend TEXT NOT NULL, + unit TEXT NOT NULL, + data_len INTEGER NOT NULL, + x BLOB NOT NULL, + y BLOB NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()}, + FOREIGN KEY(result) REFERENCES results(pamhyr_id), + PRIMARY KEY(pamhyr_id, result, scenario) + ) + """) + + if ext != "": + return True + + return cls._create_submodel(execute) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + + if major == "0" and int(minor) == 2 and int(release) <= 1: + cls._db_create(execute) + + return cls._update_submodel(execute, version, data) + + @classmethod + def _db_load(cls, execute, data=None): + new = [] + + study = data['study'] + status = data['status'] + scenario = data["scenario"] + + table = execute( + "SELECT pamhyr_id, type_x, type_y, " + + "legend, unit, data_len, x, y, " + + "scenario " + + "FROM results_add_data " + + f"WHERE scenario = {scenario.id}" + ) + + if table is None: + return new + + for v in table: + it = iter(v) + + pid = next(it) + type_x = next(it) + type_y = next(it) + legend = next(it) + unit = next(it) + data_len = next(it) + bx = next(it) + by = next(it) + owner_scenario = next(it) + + data_format = ">" + ''.join(itertools.repeat("d", data_len)) + x = struct.unpack(data_format, bx) + y = struct.unpack(data_format, by) + + data = { + 'type_x': tmp_dict[data_type[2]], + 'type_y': tmp_dict[data_type[0]], + 'legend': legend, + 'unit': tmp_unit[data_type[0]], + 'x': x, 'y': y + } + + new_results = cls(study=study) + new.append(new_results) + + return new + + def _db_save(self, execute, data=None): + if self._status.scenario.id != self._owner_scenario: + return + + pid = self._pamhyr_id + data_len = len(self._data["x"]) + + data_format = ">" + ''.join(itertools.repeat("d", data_len)) + bx = struct.pack(data_format, *self._data["x"]) + by = struct.pack(data_format, *self._data["y"]) + + execute( + "INSERT INTO " + + "results_add_data (pamhyr_id, result, " + + "type_x, type_y, " + + "legend, unit, data_len, x, y, " + + "scenario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + self._pamhyr_id, data["result"], + self._data["type_x"], self._data["type_y"], + self._data["legend"], self._data["unit"], + data_len, bx, by, self._owner_scenario + ) + + return True + + class Results(SQLSubModel): - _sub_classes = [River] + _sub_classes = [River, AdditionalData] def __init__(self, id=-1, study=None, solver=None, repertory="", name="0"): @@ -50,6 +175,7 @@ class Results(SQLSubModel): # Keep results creation date "creation_date": datetime.now(), "study_revision": study.status.version, + "additional_data": [], } if solver is not None: @@ -206,6 +332,11 @@ class Results(SQLSubModel): f"WHERE scenario = {self._owner_scenario} " + f"AND result = {pid}" ) + execute( + "DELETE FROM results_add_data " + + f"WHERE scenario = {self._owner_scenario} " + + f"AND result = {pid}" + ) def _db_save(self, execute, data=None): if self._status.scenario.id != self._owner_scenario: @@ -238,4 +369,7 @@ class Results(SQLSubModel): data["result"] = self._pamhyr_id self._river._db_save(execute, data) + for add_data in self.get("additional_data"): + add_data._db_save(execute, data) + return True diff --git a/src/Model/Study.py b/src/Model/Study.py index 2d7292b7..bdb3d2db 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -37,7 +37,7 @@ logger = logging.getLogger() class Study(SQLModel): - _version = "0.2.1" + _version = "0.2.2" _sub_classes = [ Scenario, diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 3040fc99..d88f82ea 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -59,6 +59,8 @@ from PyQt5.QtWidgets import ( QSlider, QLabel, QWidget, QGridLayout, QTabBar, QInputDialog ) +from Model.Results.Results import AdditionalData + from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar @@ -1249,7 +1251,17 @@ class ResultsWindow(PamhyrWindow): x, y = self.read_csv_file_data(filename) data = self.read_csv_file_format(x, y) - self.read_csv_file_update_plot(data) + + results = self._results[self._current_results[0]] + data_lst = results.get("additional_data") + data_lst.append( + AdditionalData( + study=self._study, + data=data + ) + ) + + self.update_plot_additional_data() def read_csv_file_data(self, filename): sep = "," @@ -1262,13 +1274,15 @@ class ResultsWindow(PamhyrWindow): if line[0] != "*" and line[0] != "#" and line[0] != "$": row = line.split(sep) - if len(row) >= 2: - try: - fx, fy = float(row[0]), float(row[1]) - x.append(fx) - y.append(fy) - except: - continue + if len(row) < 2: + continue + + try: + fx, fy = float(row[0]), float(row[1]) + x.append(fx) + y.append(fy) + except Exception as e: + continue return x, y @@ -1309,28 +1323,35 @@ class ResultsWindow(PamhyrWindow): return data - def read_csv_file_update_plot(self, data): - x, y = data['x'], data['y'] - legend = data['legend'] - unit = data['unit'] + def update_plot_additional_data(self): + results = self._results[self._current_results[0]] - if data['type_x'] == 'water_elevation' and data['type_y'] == 'time': - line = self.canvas_2.axes.plot( - x, y, marker="+", - label=legend + ' ' + unit - ) - self.plot_rkc.canvas.draw_idle() - self.plot_rkc.update_idle() + for data in results.get("additional_data"): + data = data._data + x, y = data['x'], data['y'] + legend = data['legend'] + unit = data['unit'] - if data['type_x'] == 'discharge' and data['type_y'] == 'time': - line = self.canvas_4.axes.plot( - x, y, marker="+", - label=legend + ' ' + unit - ) - self.plot_h._line.append(line) - self.plot_h.enable_legend() - self.plot_h.canvas.draw_idle() - self.plot_h.update_idle() + if ( + data['type_x'] == 'water_elevation' and + data['type_y'] == 'time' + ): + line = self.canvas_2.axes.plot( + x, y, marker="+", + label=legend + ' ' + unit + ) + self.plot_rkc.canvas.draw_idle() + self.plot_rkc.update_idle() - for p in self._additional_plot: - self._additional_plot[p].add_imported_plot(data) + if data['type_x'] == 'discharge' and data['type_y'] == 'time': + line = self.canvas_4.axes.plot( + x, y, marker="+", + label=legend + ' ' + unit + ) + self.plot_h._line.append(line) + self.plot_h.enable_legend() + self.plot_h.canvas.draw_idle() + self.plot_h.update_idle() + + for p in self._additional_plot: + self._additional_plot[p].add_imported_plot(data) From 9b0bdd1e6319fea8369d43cd24e9f58160b64fed Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 4 Nov 2025 10:15:37 +0100 Subject: [PATCH 09/40] Results: Fix plot add data 'Q(t)'. --- src/View/Results/Window.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index d88f82ea..44fe6861 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -146,11 +146,14 @@ class ResultsWindow(PamhyrWindow): profile_id=[0]) self.update_table_selection_solver(0) + self.update_plot_additional_data() def setup_table(self): self._table = {} + for t in ["reach", "profile", "raw_data", "solver"]: table = self.find(QTableView, f"tableView_{t}") + self._table[t] = TableModel( table_view=table, table_headers=self._trad.get_dict(f"table_headers_{t}"), @@ -159,8 +162,11 @@ class ResultsWindow(PamhyrWindow): opt_data=t, parent=self ) + self._table[t]._timestamp = self._timestamps[ - self._slider_time.value()] + self._slider_time.value() + ] + if len(self._results) <= 1: table = self.find(QTableView, f"tableView_solver") table.hide() @@ -191,12 +197,15 @@ class ResultsWindow(PamhyrWindow): def setup_plots(self): self.canvas = MplCanvas(width=5, height=4, dpi=100) + tab_widget = self.find(QTabWidget, f"tabWidget") + tab_widget.setTabsClosable(True) tab_widget.tabCloseRequested.connect(self.delete_tab) tab_widget.tabBar().setTabButton(0, QTabBar.RightSide, None) tab_widget.tabBar().setTabButton(1, QTabBar.RightSide, None) tab_widget.tabBar().setTabButton(2, QTabBar.RightSide, None) + self.canvas.setObjectName("canvas") self.toolbar = PamhyrPlotToolbar( self.canvas, self, items=[ @@ -204,6 +213,7 @@ class ResultsWindow(PamhyrWindow): "iso", "back/forward" ] ) + self.plot_layout = self.find(QVBoxLayout, "verticalLayout") self.plot_layout.addWidget(self.toolbar) self.plot_layout.addWidget(self.canvas) @@ -1343,7 +1353,7 @@ class ResultsWindow(PamhyrWindow): self.plot_rkc.canvas.draw_idle() self.plot_rkc.update_idle() - if data['type_x'] == 'discharge' and data['type_y'] == 'time': + if data['type_x'] == 'time' and data['type_y'] == 'discharge': line = self.canvas_4.axes.plot( x, y, marker="+", label=legend + ' ' + unit From 874f592cf4e6e36842ad04e15b82f81bf625dd05 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 4 Nov 2025 14:18:00 +0100 Subject: [PATCH 10/40] Results: Fix add data settings. --- src/Model/Results/Results.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Model/Results/Results.py b/src/Model/Results/Results.py index c6e51e2e..aad79c04 100644 --- a/src/Model/Results/Results.py +++ b/src/Model/Results/Results.py @@ -116,15 +116,16 @@ class AdditionalData(SQLSubModel): y = struct.unpack(data_format, by) data = { - 'type_x': tmp_dict[data_type[2]], - 'type_y': tmp_dict[data_type[0]], + 'type_x': type_x, + 'type_y': type_y, 'legend': legend, - 'unit': tmp_unit[data_type[0]], + 'unit': unit, 'x': x, 'y': y } - new_results = cls(study=study) - new.append(new_results) + new_data = cls(study=study) + new_data._data = data + new.append(new_data) return new @@ -310,6 +311,11 @@ class Results(SQLSubModel): data["timestamps"] = sorted(ts) new_results._river = River._db_load(execute, data) + new_results.set( + "additional_data", + AdditionalData._db_load(execute, data) + ) + yield (solver_type, new_results) def _db_save_clear(self, execute, solver_type, data=None): From 81d58122d6c6e24ec6e2c3b6df7c469fb86ce98a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 4 Nov 2025 14:34:32 +0100 Subject: [PATCH 11/40] MainWindow: Fix save callback. --- src/View/MainWindow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 7ce68594..a22b0d16 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -277,7 +277,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): "action_menu_edit": self.open_edit_study, "action_menu_open": self.open_model, "action_menu_save": lambda: self.save_study(), - "action_menu_save_as": self.save_as_study, + "action_menu_save_as": lambda: self.save_as_study(), "action_menu_numerical_parameter": self.open_solver_parameters, "action_menu_edit_scenarios": self.open_scenarios, "action_menu_edit_network": self.open_network, @@ -313,7 +313,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): "action_menu_about": self.open_about, # ToolBar action "action_toolBar_open": self.open_model, - "action_toolBar_save": self.save_study, + "action_toolBar_save": lambda: self.save_study(), "action_toolBar_close": self.close_model, "action_toolBar_run_solver": self.run_lasest_solver, # Current actions @@ -1566,6 +1566,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) dlg.exec_() results = self._tmp_results + self.last_results = results # No results available if results is None: From b04e367e722b7def1e39026b4480abe3795feacb Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 4 Nov 2025 16:22:11 +0100 Subject: [PATCH 12/40] Results: Add InputDialog to select solver. --- src/View/MainWindow.py | 57 ++++++++++++++++++++++++++++++-------- src/View/Results/Window.py | 8 ++---- src/View/Translate.py | 1 + 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index a22b0d16..4a0b4476 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -47,7 +47,7 @@ from PyQt5.QtWidgets import ( QMainWindow, QApplication, QAction, QFileDialog, QShortcut, QMenu, QToolBar, QMessageBox, QProgressDialog, QTabWidget, - QDialog, QVBoxLayout, QLabel, + QDialog, QVBoxLayout, QLabel, QInputDialog, ) from PyQt5.uic import loadUi @@ -606,6 +606,16 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): return None + def get_last_results(self, solver): + if self._study is None: + return None + + results = self._study.results + if solver in results: + return self._study.results[solver] + + return None + @last_results.setter def last_results(self, results): if self._study is None: @@ -1534,14 +1544,14 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # If no specific results, get last results if results is None: def reading_fn(): - self._tmp_results = self.last_results + self._tmp_results = solver.results( + self._study, + self._solver_workdir(solver), + ) - if self.last_results is None: + if solver == self._last_solver: def reading_fn(): - self._tmp_results = solver.results( - self._study, - self._solver_workdir(solver), - ) + self._tmp_results = self.last_results # Open from file if type(results) is str: @@ -1566,7 +1576,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) dlg.exec_() results = self._tmp_results - self.last_results = results + # self.last_results = results # No results available if results is None: @@ -1691,12 +1701,35 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if self._last_solver is None: return + solver_type = self._study.results + + solver_name, ok = QInputDialog.getItem( + self, self._trad['Solver'], + self._trad['Solver'] + ":", + list( + map(lambda s: s.name, + filter(lambda s: s._type in solver_type, + self.conf.solvers)) + ) + ) + if not ok: + return + + solver = next( + filter( + lambda s: s.name == solver_name, + self.conf.solvers + ) + ) + if self._last_solver._type == "mage8": - self.open_solver_results(self._last_solver, - self.last_results) + self.open_solver_results( + solver, # self.last_results + ) elif self._last_solver._type == "adistswc": - self.open_solver_results_adists(self._last_solver, - self.last_results) + self.open_solver_results_adists( + solver, # self.last_results + ) def open_results_from_file(self): if self._study is None: diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 44fe6861..45953b2d 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -56,7 +56,8 @@ from PyQt5.QtWidgets import ( QFileDialog, QTableView, QAbstractItemView, QUndoStack, QShortcut, QAction, QItemDelegate, QComboBox, QVBoxLayout, QHeaderView, QTabWidget, - QSlider, QLabel, QWidget, QGridLayout, QTabBar, QInputDialog + QSlider, QLabel, QWidget, QGridLayout, QTabBar, + QInputDialog, ) from Model.Results.Results import AdditionalData @@ -1229,8 +1230,7 @@ class ResultsWindow(PamhyrWindow): extent=[b[0], b[2], b[1], b[3]]) else: dlg = CoordinatesDialog( - xlim, - ylim, + xlim, ylim, trad=self._trad, parent=self ) @@ -1303,12 +1303,10 @@ class ResultsWindow(PamhyrWindow): 'Chose the type of data:', data_type_lst ) - if not ok: return legend, ok = QInputDialog.getText(self, 'Legend', 'Legend:') - if not ok: return diff --git a/src/View/Translate.py b/src/View/Translate.py index 6bd9018b..463b1057 100644 --- a/src/View/Translate.py +++ b/src/View/Translate.py @@ -261,3 +261,4 @@ class MainTranslate(UnitTranslate): self._dict["Cancel"] = _translate("MainWindow", "Cancel") self._dict["Save"] = _translate("MainWindow", "Save") self._dict["Close"] = _translate("MainWindow", "Close") + self._dict["Solver"] = _translate("MainWindow", "Solver") From 291b97ac9b0452fb800018be9b28a4de9fd1138a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 4 Nov 2025 16:35:37 +0100 Subject: [PATCH 13/40] Results: Select solver for last results also if the results path exists. --- src/View/MainWindow.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 4a0b4476..00db0ce4 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -1697,6 +1697,11 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): return workdir + def is_solver_workdir_exists(self, solver, scenario=None): + return os.path.exists( + self._solver_workdir(solver, scenario) + ) + def open_last_results(self): if self._last_solver is None: return @@ -1707,9 +1712,14 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self, self._trad['Solver'], self._trad['Solver'] + ":", list( - map(lambda s: s.name, - filter(lambda s: s._type in solver_type, - self.conf.solvers)) + map( + lambda s: s.name, + filter( + lambda s: (self.is_solver_workdir_exists(s) + or s._type in solver_type), + self.conf.solvers + ) + ) ) ) if not ok: From 4cf40155798e7524e4c3016bc36edea422e06d49 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 6 Nov 2025 09:30:29 +0100 Subject: [PATCH 14/40] Results: Set import data methode default dir as results dir. --- src/View/Results/Window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 45953b2d..6d0c85b6 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -1253,6 +1253,7 @@ class ResultsWindow(PamhyrWindow): callback=lambda f: self.read_csv_file(f[0]), default_suffix=".csv", file_filter=file_types, + directory=self._results[self._current_results[0]]._repertory, ) def read_csv_file(self, filename): From a308af41e00fa872315b3613b844b63158b8c8b0 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 7 Nov 2025 17:12:27 +0100 Subject: [PATCH 15/40] GeoTIFF: Prepare add geotiff into pamhyr2 db. --- src/Model/GeoTIFF/GeoTIFF.py | 298 +++++++++++++++++++++++++++++++ src/Model/GeoTIFF/GeoTIFFList.py | 59 ++++++ 2 files changed, 357 insertions(+) create mode 100644 src/Model/GeoTIFF/GeoTIFF.py create mode 100644 src/Model/GeoTIFF/GeoTIFFList.py diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py new file mode 100644 index 00000000..4bd17632 --- /dev/null +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -0,0 +1,298 @@ +# GeoTIFF.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import struct +import logging + +from functools import reduce + +from tools import trace, timer + +from Model.Tools.PamhyrDB import SQLSubModel +from Model.Except import NotImplementedMethodeError +from Model.Scenario import Scenario + +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + + +class GeoTIFF(SQLSubModel): + _sub_classes = [] + + def __init__(self, id: int = -1, enabled=True, + name="", description="", + path="", coordinates=None, + status=None, owner_scenario=-1): + super(GeoTIFF, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) + + self._enabled = enabled + self._name = f"GeoTIFF #{self._pamhyr_id}" if name == "" else name + self._description = text + + self._file_bytes = b'' + self._coordinates = coordinates + + if path != "": + self.read_file(path) + + self._memfile = None + + def __getitem__(self, key): + value = None + + if key == "enabled": + value = self._enabled + elif key == "name": + value = self.name + elif key == "description": + value = self.description + elif key == "file_name": + value = self.file_name + elif key == "coordinates": + value = self.coordinates + elif key == "memfile": + value = self.memfile + + return value + + def __setitem__(self, key, value): + if key == "enabled": + self.enabled = value + elif key == "name": + self.name = value + elif key == "description": + self.description = value + elif key == "file_name": + self.file_name = value + elif key == "coordinates": + self.coordinates = value + + self.modified() + + @property + def enabled(self): + return self._enabled + + @enabled.setter + def enabled(self, enabled): + self._enabled = enabled + self.modified() + + def is_enabled(self): + return self._enabled + + @property + def name(self): + return self._name + + @name.setter + def name(self, name): + self._name = name + self.modified() + + @property + def description(self): + return self._description + + @description.setter + def description(self, description): + self._description = description + self.modified() + + @property + def file_name(self): + return self._file_name + + @file_name.setter + def file_name(self, file_name): + self._file_name = file_name + self.modified() + + @property + def coordinates(self): + return self._coordinates + + @coordinates.setter + def coordinates(self, coordinates): + self._coordinates = coordinates + self.modified() + + @property + def memfile(self): + if not _rasterio_loaded: + return None + + if self._file_bytes == b'': + return None + + if self._memfile == None: + self._memfile = MemoryFile() + self._memfile.write(self._file_bytes) + + return self._memfile + + def read_file(self, path): + self._file_name = path + self._file_bytes = b'' + self._memfile = None + + with open(path, "rb") as f: + while True: + data = f.read(4096) + if not data: + break + self._file_bytes += data + + def write_file(self, path): + with open(path, "w+b") as f: + f.write(self._file_bytes) + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE geotiff{ext} ( + {cls.create_db_add_pamhyr_id()}, + enabled BOOLEAN NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + name TEXT NOT NULL, + description TEXT NOT NULL, + file_name TEXT NOT NULL, + file_bytes BLOB NOT NULL, + coordinates_bottom REAL NOT NULL, + coordinates_top REAL NOT NULL, + coordinates_left REAL NOT NULL, + coordinates_right REAL NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()}, + PRIMARY KEY(pamhyr_id, scenario) + ) + """) + + return cls._create_submodel(execute) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + + if major == "0" and int(minor) <= 2: + if int(release) < 3: + cls._create_submodel(execute) + + return True + + @classmethod + def _db_load(cls, execute, data=None): + new = [] + scenario = data["scenario"] + loaded = data['loaded_pid'] + + if scenario is None: + return new + + table = execute( + "SELECT pamhyr_id, enabled, deleted, " + + "name, description, file_name, file_bytes, " + + "coordinates_bottom, coordinates_top, " + + "coordinates_left, coordinates_right, " + + "scenario " + + "FROM geotiff " + + f"WHERE scenario = {scenario.id} " + + f"AND pamhyr_id NOT IN ({', '.join(map(str, loaded))})" + ) + + for row in table: + it = iter(row) + + id = next(it) + enabled = (next(it) == 1) + deleted = (next(it) == 1) + name = next(it) + description = next(it) + file_name = next(it) + file_bytes = next(it) + coordinates_bottom = next(it) + coordinates_top = next(it) + coordinates_left = next(it) + coordinates_right = next(it) + owner_scenario = next(it) + + f = cls( + id=id, enabled=enabled, name=name, + description=description, coordinates={ + "bottom": coordinates_bottom, + "top": coordinates_top, + "left": coordinates_left, + "right": coordinates_right, + }, + status=data['status'], + owner_scenario=owner_scenario + ) + if deleted: + f.set_as_deleted() + + f._file_bytes = file_bytes + + loaded.add(id) + new.append(f) + + data["scenario"] = scenario.parent + new += cls._db_load(execute, data) + data["scenario"] = scenario + + return new + + def _db_save(self, execute, data=None): + if not self.must_be_saved(): + return True + + execute( + "INSERT INTO geotiff (" + + "pamhyr_id, enabled, deleted, " + + "name, description, file_name, file_bytes, " + + "coordinates_bottom, coordinates_top, " + + "coordinates_left, coordinates_right, " + + "scenario) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + self._pamhyr_id, + self._enabled, + self.is_deleted(), + self.name, + self.description, + self.file_name, + self.file_bytes + self.coordinates['bottom'], + self.coordinates['top'], + self.coordinates['left'], + self.coordinates['right'], + self._status.scenario_id, + ) + + return True diff --git a/src/Model/GeoTIFF/GeoTIFFList.py b/src/Model/GeoTIFF/GeoTIFFList.py new file mode 100644 index 00000000..9e0fafb6 --- /dev/null +++ b/src/Model/GeoTIFF/GeoTIFFList.py @@ -0,0 +1,59 @@ +# GeoTIFFList.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from tools import trace, timer + +from Model.Except import NotImplementedMethodeError +from Model.Tools.PamhyrListExt import PamhyrModelList +from Model.AdditionalFile.GeoTIFF import GeoTIFF + + +class GeoTIFFList(PamhyrModelList): + _sub_classes = [GeoTIFF] + + @classmethod + def _db_load(cls, execute, data=None): + new = cls(status=data["status"]) + + new._lst = GeoTIFF._db_load(execute, data) + + return new + + def _db_save(self, execute, data=None): + ok = True + + # Delete previous data + execute( + "DELETE FROM geotiff " + + f"WHERE scenario = {self._status.scenario_id}" + ) + + for af in self._lst: + ok &= af._db_save(execute, data) + + return ok + + @property + def files(self): + return self.lst + + def new(self, index): + n = GeoTIFF(status=self._status) + self.insert(index, n) + self._status.modified() + return n From 4c0a12dcf9632f77c4de34a7f3d2a5e02f693fe6 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 10 Nov 2025 10:21:40 +0100 Subject: [PATCH 16/40] GeoTIFF: Integrate to River submodel. --- src/Model/GeoTIFF/GeoTIFF.py | 76 +++++++++++++++++--------------- src/Model/GeoTIFF/GeoTIFFList.py | 2 +- src/Model/River.py | 7 +++ src/Model/Study.py | 2 +- src/View/MainWindow.py | 4 +- 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index 4bd17632..315d58cf 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -35,6 +35,8 @@ try: import rasterio.sample import rasterio.vrt import rasterio._features + + from rasterio.io import MemoryFile _rasterio_loaded = True except Exception as e: print(f"Module 'rasterio' is not available: {e}") @@ -153,7 +155,7 @@ class GeoTIFF(SQLSubModel): if self._file_bytes == b'': return None - if self._memfile == None: + if self._memfile is None: self._memfile = MemoryFile() self._memfile.write(self._file_bytes) @@ -202,9 +204,12 @@ class GeoTIFF(SQLSubModel): def _db_update(cls, execute, version, data=None): major, minor, release = version.strip().split(".") - if major == "0" and int(minor) <= 2: + if major == "0" and int(minor) < 2: + cls._db_create(execute) + + if major == "0" and int(minor) == 2: if int(release) < 3: - cls._create_submodel(execute) + cls._db_create(execute) return True @@ -225,43 +230,44 @@ class GeoTIFF(SQLSubModel): "scenario " + "FROM geotiff " + f"WHERE scenario = {scenario.id} " + - f"AND pamhyr_id NOT IN ({', '.join(map(str, loaded))})" + f"AND pamhyr_id NOT IN ({', '.join(map(str, loaded))})" ) - for row in table: - it = iter(row) + if table is not None: + for row in table: + it = iter(row) - id = next(it) - enabled = (next(it) == 1) - deleted = (next(it) == 1) - name = next(it) - description = next(it) - file_name = next(it) - file_bytes = next(it) - coordinates_bottom = next(it) - coordinates_top = next(it) - coordinates_left = next(it) - coordinates_right = next(it) - owner_scenario = next(it) + id = next(it) + enabled = (next(it) == 1) + deleted = (next(it) == 1) + name = next(it) + description = next(it) + file_name = next(it) + file_bytes = next(it) + coordinates_bottom = next(it) + coordinates_top = next(it) + coordinates_left = next(it) + coordinates_right = next(it) + owner_scenario = next(it) - f = cls( - id=id, enabled=enabled, name=name, - description=description, coordinates={ - "bottom": coordinates_bottom, - "top": coordinates_top, - "left": coordinates_left, - "right": coordinates_right, - }, - status=data['status'], - owner_scenario=owner_scenario - ) - if deleted: - f.set_as_deleted() + f = cls( + id=id, enabled=enabled, name=name, + description=description, coordinates={ + "bottom": coordinates_bottom, + "top": coordinates_top, + "left": coordinates_left, + "right": coordinates_right, + }, + status=data['status'], + owner_scenario=owner_scenario + ) + if deleted: + f.set_as_deleted() - f._file_bytes = file_bytes + f._file_bytes = file_bytes - loaded.add(id) - new.append(f) + loaded.add(id) + new.append(f) data["scenario"] = scenario.parent new += cls._db_load(execute, data) @@ -287,7 +293,7 @@ class GeoTIFF(SQLSubModel): self.name, self.description, self.file_name, - self.file_bytes + self.file_bytes, self.coordinates['bottom'], self.coordinates['top'], self.coordinates['left'], diff --git a/src/Model/GeoTIFF/GeoTIFFList.py b/src/Model/GeoTIFF/GeoTIFFList.py index 9e0fafb6..b80a7694 100644 --- a/src/Model/GeoTIFF/GeoTIFFList.py +++ b/src/Model/GeoTIFF/GeoTIFFList.py @@ -20,7 +20,7 @@ from tools import trace, timer from Model.Except import NotImplementedMethodeError from Model.Tools.PamhyrListExt import PamhyrModelList -from Model.AdditionalFile.GeoTIFF import GeoTIFF +from Model.GeoTIFF.GeoTIFF import GeoTIFF class GeoTIFFList(PamhyrModelList): diff --git a/src/Model/River.py b/src/Model/River.py index e0c4602b..b9418026 100644 --- a/src/Model/River.py +++ b/src/Model/River.py @@ -59,6 +59,7 @@ from Model.LateralContributionsAdisTS.LateralContributionsAdisTSList \ import LateralContributionsAdisTSList from Model.D90AdisTS.D90AdisTSList import D90AdisTSList from Model.DIFAdisTS.DIFAdisTSList import DIFAdisTSList +from Model.GeoTIFF.GeoTIFFList import GeoTIFFList from Model.Results.Results import Results logger = logging.getLogger() @@ -468,6 +469,7 @@ class River(Graph): LateralContributionsAdisTSList, D90AdisTSList, DIFAdisTSList, + GeoTIFFList, Results ] @@ -505,6 +507,8 @@ class River(Graph): self._D90AdisTS = D90AdisTSList(status=self._status) self._DIFAdisTS = DIFAdisTSList(status=self._status) + self._geo_tiff = GeoTIFFList(status=self._status) + self._results = {} @classmethod @@ -617,6 +621,8 @@ class River(Graph): new._DIFAdisTS = DIFAdisTSList._db_load(execute, data) + new._geo_tiff = GeoTIFFList._db_load(execute, data) + return new def _db_load_results(self, execute, data=None): @@ -726,6 +732,7 @@ class River(Graph): self._BoundaryConditionsAdisTS, self._LateralContributionsAdisTS, self._D90AdisTS, self._DIFAdisTS, + self._geo_tiff, ] for solver in self._parameters: diff --git a/src/Model/Study.py b/src/Model/Study.py index bdb3d2db..f32e90bf 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -37,7 +37,7 @@ logger = logging.getLogger() class Study(SQLModel): - _version = "0.2.2" + _version = "0.2.3" _sub_classes = [ Scenario, diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 00db0ce4..8f0f54ac 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -1734,11 +1734,11 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if self._last_solver._type == "mage8": self.open_solver_results( - solver, # self.last_results + solver, # self.last_results ) elif self._last_solver._type == "adistswc": self.open_solver_results_adists( - solver, # self.last_results + solver, # self.last_results ) def open_results_from_file(self): From 5bb6cc40fea1bfedddcb375aa7fa10261977832c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 10 Nov 2025 16:04:18 +0100 Subject: [PATCH 17/40] GeoTIFF: Add to modules list. --- src/Modules.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Modules.py b/src/Modules.py index fecb6322..860bc0cf 100644 --- a/src/Modules.py +++ b/src/Modules.py @@ -56,6 +56,7 @@ class Modules(IterableFlag): SEDIMENT_LAYER = auto() ADDITIONAL_FILES = auto() OUTPUT_RK = auto() + GEOTIFF = auto() # Results RESULTS = auto() @@ -81,6 +82,7 @@ class Modules(IterableFlag): cls.RESULTS, cls.WINDOW_LIST, cls.OUTPUT_RK, + cls.GEOTIFF ] @classmethod @@ -99,6 +101,7 @@ class Modules(IterableFlag): | cls.HYDRAULIC_STRUCTURES | cls.RESERVOIR | cls.SEDIMENT_LAYER + | cls.GEOTIFF ) @classmethod @@ -114,6 +117,7 @@ class Modules(IterableFlag): cls.HYDRAULIC_STRUCTURES, cls.RESERVOIR, cls.SEDIMENT_LAYER, + cls.GEOTIFF, ] @classmethod @@ -129,6 +133,7 @@ class Modules(IterableFlag): cls.HYDRAULIC_STRUCTURES: "Hydraulic structures", cls.RESERVOIR: "Reservoir", cls.SEDIMENT_LAYER: "Sediment layer", + cls.GEOTIFF: "GeoTIFF", } def impact(self): @@ -168,4 +173,5 @@ _impact = { Modules.HYDRAULIC_STRUCTURES: [], Modules.RESERVOIR: [], Modules.SEDIMENT_LAYER: [], + Modules.GEOTIFF: [], } From 1ed5d69bf4f9336faf52ee70823d4d4f6002032a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 10 Nov 2025 16:15:40 +0100 Subject: [PATCH 18/40] GeoTIFF: Prepare view integration. --- src/View/GeoTIFF/Edit/Window.py | 102 +++++++++++++++++++++++++++ src/View/GeoTIFF/List.py | 96 +++++++++++++++++++++++++ src/View/GeoTIFF/Translate.py | 35 ++++++++++ src/View/GeoTIFF/UndoCommand.py | 81 +++++++++++++++++++++ src/View/GeoTIFF/Window.py | 120 ++++++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 src/View/GeoTIFF/Edit/Window.py create mode 100644 src/View/GeoTIFF/List.py create mode 100644 src/View/GeoTIFF/Translate.py create mode 100644 src/View/GeoTIFF/UndoCommand.py create mode 100644 src/View/GeoTIFF/Window.py diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py new file mode 100644 index 00000000..f3ede26a --- /dev/null +++ b/src/View/GeoTIFF/Edit/Window.py @@ -0,0 +1,102 @@ +# Window.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import logging + +from Modules import Modules +from View.Tools.PamhyrWindow import PamhyrWindow + +from PyQt5.QtWidgets import ( + QLabel, QPlainTextEdit, QPushButton, + QCheckBox, +) + +from View.GeoTIFF.Translate import GeoTIFFTranslate +from View.GeoTIFF.UndoCommand import ( + SetCommand +) + +logger = logging.getLogger() + + +class EditGeoTIFFWindow(PamhyrWindow): + _pamhyr_ui = "EditGeoTIFF" + _pamhyr_name = "Edit GeoTIFF" + + def __init__(self, study=None, config=None, add_file=None, + trad=None, undo=None, parent=None): + + name = trad[self._pamhyr_name] + " - " + study.name + super(EditGeoTIFFWindow, self).__init__( + title=name, + study=study, + config=config, + options=[], + parent=parent + ) + + self._geotiff = geotiff + self._hash_data.append(self._geotiff) + + self._undo = undo + + self.setup_values() + self.setup_connection() + + def setup_values(self): + self.set_check_box("checkBox", self._geotiff.enabled) + self.set_line_edit_text("lineEdit_name", self._geotiff.name) + self.set_line_edit_text("lineEdit_path", self._geotiff.description) + self.set_plaintext_edit_text("plainTextEdit", self._geotiff.text) + + if self._study.is_read_only(): + self.set_check_box_enable("checkBox", False) + self.set_line_edit_enable("lineEdit_name", False) + self.set_line_edit_enable("lineEdit_path", False) + self.set_plaintext_edit_enable("plainTextEdit", False) + + def setup_connection(self): + self.find(QPushButton, "pushButton_cancel")\ + .clicked.connect(self.close) + self.find(QPushButton, "pushButton_ok")\ + .clicked.connect(self.accept) + + def accept(self): + if self._study.is_editable(): + is_enabled = self.get_check_box("checkBox") + name = self.get_line_edit_text("lineEdit_name") + path = self.get_line_edit_text("lineEdit_path") + coord_bottom = self.get_plaintext_edit_text("plainTextEdit") + coord_top = self.get_plaintext_edit_text("plainTextEdit") + coord_left = self.get_plaintext_edit_text("plainTextEdit") + coord_right = self.get_plaintext_edit_text("plainTextEdit") + + self._undo.push( + SetCommand( + self._geotiff, enabled=is_enabled, + name=name, description=description, + coordinates_bottom=coord_bottom, + coordinates_top=coord_top, + coordinates_left=coord_left, + coordinates_right=coord_right, + ) + ) + + self._propagate_update(key=Modules.GEOTIFF) + + self.close() diff --git a/src/View/GeoTIFF/List.py b/src/View/GeoTIFF/List.py new file mode 100644 index 00000000..8bb158aa --- /dev/null +++ b/src/View/GeoTIFF/List.py @@ -0,0 +1,96 @@ +# List.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import logging + +from functools import reduce +from tools import trace, timer + +from PyQt5.QtCore import ( + Qt, QVariant, +) + +from PyQt5.QtGui import ( + QColor, QBrush, +) + +from View.Tools.PamhyrList import PamhyrListModel +from View.GeoTIFF.UndoCommand import ( + AddCommand, DelCommand +) + +logger = logging.getLogger() + + +class ListModel(PamhyrListModel): + def get_true_data_row(self, row): + el = self._data.get(row) + + return next( + map( + lambda e: e[0], + filter( + lambda e: e[1] == el, + enumerate(self._data._lst) + ) + ), 0 + ) + + def data(self, index, role): + row = index.row() + column = index.column() + + file = self._data.files[row] + + if role == Qt.ForegroundRole: + color = Qt.gray + + if file.is_enabled(): + color = QColor("black") + else: + color = QColor("grey") + + return QBrush(color) + + if role == Qt.ItemDataRole.DisplayRole: + text = f"{file.name}: '{file.path}'" + + if not file.is_enabled(): + text += " (disabled)" + + return text + + return QVariant() + + def add(self, row): + row = self.get_true_data_row(row) + + self._undo.push( + AddCommand( + self._data, row + ) + ) + self.update() + + def delete(self, row): + self._undo.push( + DelCommand( + self._data, self._data.files[row] + ) + ) + self.update() diff --git a/src/View/GeoTIFF/Translate.py b/src/View/GeoTIFF/Translate.py new file mode 100644 index 00000000..fe39460f --- /dev/null +++ b/src/View/GeoTIFF/Translate.py @@ -0,0 +1,35 @@ +# Translate.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from PyQt5.QtCore import QCoreApplication +from View.Translate import MainTranslate + +_translate = QCoreApplication.translate + + +class GeoTIFFTranslate(MainTranslate): + def __init__(self): + super(AddFileTranslate, self).__init__() + + self._dict["GeoTIFF files"] = _translate( + "GeoTIFF", "GeoTIFF files" + ) + + self._dict["Edit additional file"] = _translate( + "GeoTIFF", "Edit GeoTIFF file" + ) diff --git a/src/View/GeoTIFF/UndoCommand.py b/src/View/GeoTIFF/UndoCommand.py new file mode 100644 index 00000000..9e26c1be --- /dev/null +++ b/src/View/GeoTIFF/UndoCommand.py @@ -0,0 +1,81 @@ +# UndoCommand.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from tools import trace, timer + +from PyQt5.QtWidgets import ( + QMessageBox, QUndoCommand, QUndoStack, +) + + +class SetCommand(QUndoCommand): + def __init__(self, geotiff, **kwargs): + QUndoCommand.__init__(self) + + self._geotiff = geotiff + self._new = kwargs + self._old = None + + def undo(self): + f = self._geotiff + + for key in self._old: + f[key] = self._old[key] + + def redo(self): + f = self._geotiff + + if self._old is None: + self._old = {} + for key in self._new: + self._old[key] = f[key] + + for key in self._new: + f[key] = self._new[key] + + +class AddCommand(QUndoCommand): + def __init__(self, files, row): + QUndoCommand.__init__(self) + + self._files = files + self._row = row + self._new = None + + def undo(self): + self._new.set_as_deleted() + + def redo(self): + if self._new is None: + self._new = self._files.new(self._row) + else: + self._new.set_as_not_deleted() + + +class DelCommand(QUndoCommand): + def __init__(self, files, line): + QUndoCommand.__init__(self) + + self._files = files + self._line = line + + def undo(self): + self._line.set_as_not_deleted() + + def redo(self): + self._line.set_as_deleted() diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py new file mode 100644 index 00000000..98d1d058 --- /dev/null +++ b/src/View/GeoTIFF/Window.py @@ -0,0 +1,120 @@ +# Window.py -- Pamhyr +# Copyright (C) 2024-2025 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from tools import trace, timer + +from PyQt5.QtWidgets import ( + QAction, QListView, +) + +from View.Tools.PamhyrWindow import PamhyrWindow + +from View.GeoTIFF.List import ListModel +from View.GeoTIFF.Translate import GeoTIFFTranslate +from View.GeoTIFF.Edit.Window import EditGeoTIFFWindow + + +class GeoTIFFListWindow(PamhyrWindow): + _pamhyr_ui = "GeoTIFFList" + _pamhyr_name = "GeoTIFF files" + + def __init__(self, study=None, config=None, + parent=None): + trad = GeoTIFFTranslate() + name = trad[self._pamhyr_name] + " - " + study.name + + super(GeoTIFFListWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + options=[], + parent=parent + ) + + self.setup_list() + self.setup_connections() + + def setup_list(self): + lst = self.find(QListView, f"listView") + self._list = ListModel( + list_view=lst, + data=self._study.river.geotiff, + undo=self._undo_stack, + trad=self._trad, + ) + + def setup_connections(self): + if self._study.is_editable(): + self.find(QAction, "action_add").triggered.connect(self.add) + self.find(QAction, "action_delete").triggered.connect(self.delete) + + self.find(QAction, "action_edit").triggered.connect(self.edit) + + def update(self): + self._list.update() + + def selected_rows(self): + lst = self.find(QListView, f"listView") + return list(map(lambda i: i.row(), lst.selectedIndexes())) + + def add(self): + rows = self.selected_rows() + if len(rows) > 0: + row = rows[0] + else: + row = 0 + + self._list.add(row) + + def delete(self): + rows = self.selected_rows() + if len(rows) == 0: + return + + self._list.delete(rows[0]) + + def edit(self): + rows = self.selected_rows() + + for row in rows: + add_file = self._study.river.geotiff.files[row] + + if self.sub_window_exists( + EditGeoTIFFWindow, + data=[self._study, self._config, add_file] + ): + continue + + win = EditGeoTIFFWindow( + study=self._study, + config=self._config, + add_file=add_file, + trad=self._trad, + undo=self._undo_stack, + parent=self, + ) + win.show() + + def _undo(self): + self._undo_stack.undo() + self.update() + + def _redo(self): + self._undo_stack.redo() + self.update() From f27b2cc586e232c67ff843fe4433373cfeb15cae Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 10 Nov 2025 17:09:27 +0100 Subject: [PATCH 19/40] GeoTIFF: Prepare model/view integration. --- src/Model/GeoTIFF/GeoTIFF.py | 44 +++++++++++++++++++++++++++++++++ src/View/GeoTIFF/Edit/Window.py | 17 ++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index 315d58cf..221b2acc 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -80,6 +80,14 @@ class GeoTIFF(SQLSubModel): value = self.file_name elif key == "coordinates": value = self.coordinates + elif key == "coordinates_bottom": + value = self.coordinates["bottom"] + elif key == "coordinates_top": + value = self.coordinates["top"] + elif key == "coordinates_left": + value = self.coordinates["left"] + elif key == "coordinates_right": + value = self.coordinates["right"] elif key == "memfile": value = self.memfile @@ -96,6 +104,14 @@ class GeoTIFF(SQLSubModel): self.file_name = value elif key == "coordinates": self.coordinates = value + elif key == "coordinates_bottom": + self.coordinates["bottom"] = value + elif key == "coordinates_top": + self.coordinates["top"] = value + elif key == "coordinates_left": + self.coordinates["left"] = value + elif key == "coordinates_right": + self.coordinates["right"] = value self.modified() @@ -147,6 +163,34 @@ class GeoTIFF(SQLSubModel): self._coordinates = coordinates self.modified() + @property + def coord_bottom(self): + if self._coordinates is None: + return 0.0 + + return self._coordinates["bottom"] + + @property + def coord_top(self): + if self._coordinates is None: + return 0.0 + + return self._coordinates["top"] + + @property + def coord_left(self): + if self._coordinates is None: + return 0.0 + + return self._coordinates["left"] + + @property + def coord_right(self): + if self._coordinates is None: + return 0.0 + + return self._coordinates["right"] + @property def memfile(self): if not _rasterio_loaded: diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index f3ede26a..08532128 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -61,8 +61,12 @@ class EditGeoTIFFWindow(PamhyrWindow): def setup_values(self): self.set_check_box("checkBox", self._geotiff.enabled) self.set_line_edit_text("lineEdit_name", self._geotiff.name) - self.set_line_edit_text("lineEdit_path", self._geotiff.description) - self.set_plaintext_edit_text("plainTextEdit", self._geotiff.text) + self.set_line_edit_text("lineEdit_description", self._geotiff.description) + + self.set_double_spin_box("doubleSpinBox_bottom", self._geotiff.coord_bottom) + self.set_double_spin_box("doubleSpinBox_top", self._geotiff.coord_top) + self.set_double_spin_box("doubleSpinBox_left", self._geotiff.coord_left) + self.set_double_spin_box("doubleSpinBox_right", self._geotiff.coord_right) if self._study.is_read_only(): self.set_check_box_enable("checkBox", False) @@ -81,10 +85,11 @@ class EditGeoTIFFWindow(PamhyrWindow): is_enabled = self.get_check_box("checkBox") name = self.get_line_edit_text("lineEdit_name") path = self.get_line_edit_text("lineEdit_path") - coord_bottom = self.get_plaintext_edit_text("plainTextEdit") - coord_top = self.get_plaintext_edit_text("plainTextEdit") - coord_left = self.get_plaintext_edit_text("plainTextEdit") - coord_right = self.get_plaintext_edit_text("plainTextEdit") + + coord_bottom = self.get_double_spin_box("doubleSpinBox_bottom") + coord_top = self.get_double_spin_box("doubleSpinBox_top") + coord_left = self.get_double_spin_box("doubleSpinBox_left") + coord_right = self.get_double_spin_box("doubleSpinBox_right") self._undo.push( SetCommand( From 6e52b1681eaf7650f2342903762914d4a31a9b07 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 11 Nov 2025 12:26:50 +0100 Subject: [PATCH 20/40] GeoTIFF: Continue view integration. --- src/Model/GeoTIFF/GeoTIFF.py | 15 ++++++- src/Model/River.py | 10 +++-- src/View/GeoTIFF/Edit/Window.py | 75 ++++++++++++++++++++++++++++----- src/View/GeoTIFF/List.py | 2 +- src/View/GeoTIFF/Translate.py | 2 +- src/View/GeoTIFF/Window.py | 6 +-- src/View/MainWindow.py | 14 ++++++ src/View/Results/translate.py | 5 --- src/View/Translate.py | 12 ++++++ 9 files changed, 116 insertions(+), 25 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index 221b2acc..d7b6e1d7 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -42,6 +42,8 @@ except Exception as e: print(f"Module 'rasterio' is not available: {e}") _rasterio_loaded = False +logger = logging.getLogger() + class GeoTIFF(SQLSubModel): _sub_classes = [] @@ -57,10 +59,11 @@ class GeoTIFF(SQLSubModel): self._enabled = enabled self._name = f"GeoTIFF #{self._pamhyr_id}" if name == "" else name - self._description = text + self._description = description self._file_bytes = b'' self._coordinates = coordinates + self._file_name = "" if path != "": self.read_file(path) @@ -101,7 +104,7 @@ class GeoTIFF(SQLSubModel): elif key == "description": self.description = value elif key == "file_name": - self.file_name = value + self.read_file(value) elif key == "coordinates": self.coordinates = value elif key == "coordinates_bottom": @@ -206,17 +209,25 @@ class GeoTIFF(SQLSubModel): return self._memfile def read_file(self, path): + logger.debug(f"Read GeoTIFF file at : '{path}'") + self._file_name = path self._file_bytes = b'' self._memfile = None + nbytes = 0 + with open(path, "rb") as f: while True: data = f.read(4096) if not data: break + + nbytes += len(data) self._file_bytes += data + logger.debug(f"Read GeoTIFF: {nbytes} bytes readed") + def write_file(self, path): with open(path, "w+b") as f: f.write(self._file_bytes) diff --git a/src/Model/River.py b/src/Model/River.py index b9418026..7520f838 100644 --- a/src/Model/River.py +++ b/src/Model/River.py @@ -507,7 +507,7 @@ class River(Graph): self._D90AdisTS = D90AdisTSList(status=self._status) self._DIFAdisTS = DIFAdisTSList(status=self._status) - self._geo_tiff = GeoTIFFList(status=self._status) + self._geotiff = GeoTIFFList(status=self._status) self._results = {} @@ -621,7 +621,7 @@ class River(Graph): new._DIFAdisTS = DIFAdisTSList._db_load(execute, data) - new._geo_tiff = GeoTIFFList._db_load(execute, data) + new._geotiff = GeoTIFFList._db_load(execute, data) return new @@ -732,7 +732,7 @@ class River(Graph): self._BoundaryConditionsAdisTS, self._LateralContributionsAdisTS, self._D90AdisTS, self._DIFAdisTS, - self._geo_tiff, + self._geotiff, ] for solver in self._parameters: @@ -825,6 +825,10 @@ Last export at: @date.""" def additional_files(self): return self._additional_files + @property + def geotiff(self): + return self._geotiff + @property def rep_lines(self): return self._rep_lines diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 08532128..c0d08ef7 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -23,7 +23,15 @@ from View.Tools.PamhyrWindow import PamhyrWindow from PyQt5.QtWidgets import ( QLabel, QPlainTextEdit, QPushButton, - QCheckBox, + QCheckBox, QFileDialog, +) + +from PyQt5.QtGui import ( + QPixmap, +) + +from PyQt5.QtCore import ( + QSettings ) from View.GeoTIFF.Translate import GeoTIFFTranslate @@ -35,22 +43,24 @@ logger = logging.getLogger() class EditGeoTIFFWindow(PamhyrWindow): - _pamhyr_ui = "EditGeoTIFF" + _pamhyr_ui = "GeoTIFF" _pamhyr_name = "Edit GeoTIFF" - def __init__(self, study=None, config=None, add_file=None, + def __init__(self, study=None, config=None, geotiff=None, trad=None, undo=None, parent=None): - name = trad[self._pamhyr_name] + " - " + study.name super(EditGeoTIFFWindow, self).__init__( - title=name, + title=self._pamhyr_name, study=study, config=config, + trad=trad, options=[], parent=parent ) self._geotiff = geotiff + self._file_name = geotiff.file_name + self._hash_data.append(self._geotiff) self._undo = undo @@ -61,12 +71,17 @@ class EditGeoTIFFWindow(PamhyrWindow): def setup_values(self): self.set_check_box("checkBox", self._geotiff.enabled) self.set_line_edit_text("lineEdit_name", self._geotiff.name) - self.set_line_edit_text("lineEdit_description", self._geotiff.description) + self.set_line_edit_text("lineEdit_description", + self._geotiff.description) - self.set_double_spin_box("doubleSpinBox_bottom", self._geotiff.coord_bottom) - self.set_double_spin_box("doubleSpinBox_top", self._geotiff.coord_top) - self.set_double_spin_box("doubleSpinBox_left", self._geotiff.coord_left) - self.set_double_spin_box("doubleSpinBox_right", self._geotiff.coord_right) + self._values = { + "bottom": self._geotiff.coord_bottom, + "top": self._geotiff.coord_top, + "left": self._geotiff.coord_left, + "right": self._geotiff.coord_right, + } + + self._reset_values() if self._study.is_read_only(): self.set_check_box_enable("checkBox", False) @@ -74,11 +89,50 @@ class EditGeoTIFFWindow(PamhyrWindow): self.set_line_edit_enable("lineEdit_path", False) self.set_plaintext_edit_enable("plainTextEdit", False) + def _reset_values(self): + for key in self._values: + self._reset_values_key(key) + + def _reset_values_key(self, key): + self.set_double_spin_box(f"doubleSpinBox_{key}", self._values[key]) + def setup_connection(self): self.find(QPushButton, "pushButton_cancel")\ .clicked.connect(self.close) self.find(QPushButton, "pushButton_ok")\ .clicked.connect(self.accept) + self.find(QPushButton, "pushButton_import")\ + .clicked.connect(self._import) + + for key in self._values: + self.find(QPushButton, f"pushButton_{key}")\ + .clicked.connect(lambda: self._reset_values_key(key)) + + def _import(self): + options = QFileDialog.Options() + settings = QSettings(QSettings.IniFormat, + QSettings.UserScope, 'MyOrg', ) + options |= QFileDialog.DontUseNativeDialog + + file_types = [ + self._trad["file_geotiff"], + self._trad["file_all"], + ] + + filename, _ = QFileDialog.getOpenFileName( + self, + self._trad["open_file"], + "", + ";; ".join(file_types), + options=options + ) + + if filename != "": + self._file_name = filename + + pixmap = QPixmap(filename) + self.find(QLabel, "label_geotiff")\ + .setPixmap(pixmap) def accept(self): if self._study.is_editable(): @@ -99,6 +153,7 @@ class EditGeoTIFFWindow(PamhyrWindow): coordinates_top=coord_top, coordinates_left=coord_left, coordinates_right=coord_right, + file_name=self._file_name, ) ) diff --git a/src/View/GeoTIFF/List.py b/src/View/GeoTIFF/List.py index 8bb158aa..d604d72e 100644 --- a/src/View/GeoTIFF/List.py +++ b/src/View/GeoTIFF/List.py @@ -68,7 +68,7 @@ class ListModel(PamhyrListModel): return QBrush(color) if role == Qt.ItemDataRole.DisplayRole: - text = f"{file.name}: '{file.path}'" + text = f"{file.name}: '{file.description}'" if not file.is_enabled(): text += " (disabled)" diff --git a/src/View/GeoTIFF/Translate.py b/src/View/GeoTIFF/Translate.py index fe39460f..3e328230 100644 --- a/src/View/GeoTIFF/Translate.py +++ b/src/View/GeoTIFF/Translate.py @@ -24,7 +24,7 @@ _translate = QCoreApplication.translate class GeoTIFFTranslate(MainTranslate): def __init__(self): - super(AddFileTranslate, self).__init__() + super(GeoTIFFTranslate, self).__init__() self._dict["GeoTIFF files"] = _translate( "GeoTIFF", "GeoTIFF files" diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 98d1d058..3113c8d3 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -93,18 +93,18 @@ class GeoTIFFListWindow(PamhyrWindow): rows = self.selected_rows() for row in rows: - add_file = self._study.river.geotiff.files[row] + geotiff= self._study.river.geotiff.files[row] if self.sub_window_exists( EditGeoTIFFWindow, - data=[self._study, self._config, add_file] + data=[self._study, self._config, geotiff] ): continue win = EditGeoTIFFWindow( study=self._study, config=self._config, - add_file=add_file, + geotiff=geotiff, trad=self._trad, undo=self._undo_stack, parent=self, diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 8f0f54ac..96c123ae 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -79,6 +79,7 @@ from View.Frictions.Window import FrictionsWindow from View.SedimentLayers.Window import SedimentLayersWindow from View.SedimentLayers.Reach.Window import ReachSedimentLayersWindow from View.AdditionalFiles.Window import AddFileListWindow +from View.GeoTIFF.Window import GeoTIFFListWindow from View.REPLines.Window import REPLineListWindow from View.SolverParameters.Window import SolverParametersWindow from View.RunSolver.Window import ( @@ -1360,6 +1361,19 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) self.additonal_files.show() + def open_geotiff(self): + if self._study is not None: + if self.sub_window_exists( + GeoTIFFListWindow, + data=[self._study, None] + ): + return + + self.geotiff = GeoTIFFListWindow( + study=self._study, parent=self + ) + self.geotiff.show() + def open_rep_lines(self): if self._study is not None: if self.sub_window_exists( diff --git a/src/View/Results/translate.py b/src/View/Results/translate.py index 7a1e7e9b..95ddd9a4 100644 --- a/src/View/Results/translate.py +++ b/src/View/Results/translate.py @@ -53,11 +53,6 @@ class ResultsTranslate(MainTranslate): "Results", "Max water elevation" ) - self._dict["file_all"] = _translate("Results", "All files (*)") - self._dict["file_geotiff"] = _translate( - "Results", "GeoTIFF file (*.tiff *.tif)") - self._dict["file_csv"] = _translate( - "Results", "CSV file (*.csv)") self._dict["ImageCoordinates"] = _translate( "Results", "Image coordinates" ) diff --git a/src/View/Translate.py b/src/View/Translate.py index 463b1057..e288e196 100644 --- a/src/View/Translate.py +++ b/src/View/Translate.py @@ -54,6 +54,18 @@ class CommonWordTranslate(PamhyrTranslate): self._dict["method"] = _translate("CommonWord", "Method") + # Files + self._dict["open_file"] = _translate( + "CommonWord", "Open file" + ) + self._dict["file_all"] = _translate("CommonWord", "All files (*)") + self._dict["file_geotiff"] = _translate( + "CommonWord", "GeoTIFF file (*.tiff *.tif)" + ) + self._dict["file_csv"] = _translate( + "CommonWord", "CSV file (*.csv)" + ) + class UnitTranslate(CommonWordTranslate): def __init__(self): From 5c83d678656da7306e7e439a0ed8a319bfec115d Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 12 Nov 2025 10:41:45 +0100 Subject: [PATCH 21/40] GeoTIFF: Continue intergation. --- src/Model/GeoTIFF/GeoTIFF.py | 10 +++- src/Model/GeoTIFF/GeoTIFFList.py | 4 +- src/View/GeoTIFF/Edit/Window.py | 89 ++++++++++++++++++++++++++++---- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index d7b6e1d7..b92d9163 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -62,7 +62,15 @@ class GeoTIFF(SQLSubModel): self._description = description self._file_bytes = b'' - self._coordinates = coordinates + if coordinates is None: + self._coordinates = { + "bottom" : 0.0, + "top": 0.0, + "left": 0.0, + "right": 0.0, + } + else: + self._coordinates = coordinates self._file_name = "" if path != "": diff --git a/src/Model/GeoTIFF/GeoTIFFList.py b/src/Model/GeoTIFF/GeoTIFFList.py index b80a7694..84f3c803 100644 --- a/src/Model/GeoTIFF/GeoTIFFList.py +++ b/src/Model/GeoTIFF/GeoTIFFList.py @@ -43,8 +43,8 @@ class GeoTIFFList(PamhyrModelList): f"WHERE scenario = {self._status.scenario_id}" ) - for af in self._lst: - ok &= af._db_save(execute, data) + for gt in self._lst: + ok &= gt._db_save(execute, data) return ok diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index c0d08ef7..299c3bdb 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -23,11 +23,7 @@ from View.Tools.PamhyrWindow import PamhyrWindow from PyQt5.QtWidgets import ( QLabel, QPlainTextEdit, QPushButton, - QCheckBox, QFileDialog, -) - -from PyQt5.QtGui import ( - QPixmap, + QCheckBox, QFileDialog, QVBoxLayout, ) from PyQt5.QtCore import ( @@ -39,6 +35,23 @@ from View.GeoTIFF.UndoCommand import ( SetCommand ) +from View.Tools.Plot.PamhyrCanvas import MplCanvas +from View.LateralContribution.PlotXY import PlotXY + +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + + from rasterio.io import MemoryFile + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + logger = logging.getLogger() @@ -65,9 +78,30 @@ class EditGeoTIFFWindow(PamhyrWindow): self._undo = undo + self.setup_graph() self.setup_values() self.setup_connection() + def setup_graph(self): + self.canvas = MplCanvas(width=5, height=4, dpi=100) + self.canvas.setObjectName("canvas") + self.plot_layout = self.find(QVBoxLayout, "verticalLayout_geotiff") + self.plot_layout.addWidget(self.canvas) + + self.plot = PlotXY( + canvas=self.canvas, + data=None, + trad=self._trad, + toolbar=None, + parent=self + ) + + self._plot_img = None + + memfile = self._geotiff.memfile + if memfile is not None: + self.draw_geotiff(memfile=memfile) + def setup_values(self): self.set_check_box("checkBox", self._geotiff.enabled) self.set_line_edit_text("lineEdit_name", self._geotiff.name) @@ -89,6 +123,16 @@ class EditGeoTIFFWindow(PamhyrWindow): self.set_line_edit_enable("lineEdit_path", False) self.set_plaintext_edit_enable("plainTextEdit", False) + def _set_values_from_bounds(self, bounds): + self._values = { + "bottom": bounds[0], + "top": bounds[2], + "left": bounds[1], + "right": bounds[3], + } + + self._reset_values() + def _reset_values(self): for key in self._values: self._reset_values_key(key) @@ -108,6 +152,34 @@ class EditGeoTIFFWindow(PamhyrWindow): self.find(QPushButton, f"pushButton_{key}")\ .clicked.connect(lambda: self._reset_values_key(key)) + def draw_geotiff(self, memfile=None): + if not _rasterio_loaded: + return + + if memfile is None: + if self._file_name == "": + return + + with rasterio.open(self._file_name) as data: + img = data.read() + b = data.bounds[:] + else: + with memfile.open() as gt: + img = gt.read() + b = gt.bounds[:] + + if self._plot_img is not None: + self._plot_img.remove() + + self._set_values_from_bounds(b) + + self._plot_img = self.canvas.axes.imshow( + img.transpose((1, 2, 0)), + extent=[b[0], b[2], b[1], b[3]] + ) + + self.plot.idle() + def _import(self): options = QFileDialog.Options() settings = QSettings(QSettings.IniFormat, @@ -129,16 +201,13 @@ class EditGeoTIFFWindow(PamhyrWindow): if filename != "": self._file_name = filename - - pixmap = QPixmap(filename) - self.find(QLabel, "label_geotiff")\ - .setPixmap(pixmap) + self. draw_geotiff() def accept(self): if self._study.is_editable(): is_enabled = self.get_check_box("checkBox") name = self.get_line_edit_text("lineEdit_name") - path = self.get_line_edit_text("lineEdit_path") + description = self.get_line_edit_text("lineEdit_description") coord_bottom = self.get_double_spin_box("doubleSpinBox_bottom") coord_top = self.get_double_spin_box("doubleSpinBox_top") From 7bce725c63ec9faec8cfefada49486682199cf8e Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 12 Nov 2025 10:43:20 +0100 Subject: [PATCH 22/40] GeoTIFF: Fix pep8. --- src/Model/GeoTIFF/GeoTIFF.py | 2 +- src/View/GeoTIFF/Window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index b92d9163..a6f6de59 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -64,7 +64,7 @@ class GeoTIFF(SQLSubModel): self._file_bytes = b'' if coordinates is None: self._coordinates = { - "bottom" : 0.0, + "bottom": 0.0, "top": 0.0, "left": 0.0, "right": 0.0, diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 3113c8d3..69fa40c7 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -93,7 +93,7 @@ class GeoTIFFListWindow(PamhyrWindow): rows = self.selected_rows() for row in rows: - geotiff= self._study.river.geotiff.files[row] + geotiff = self._study.river.geotiff.files[row] if self.sub_window_exists( EditGeoTIFFWindow, From 8976f054c7e77aa7ef513432e02f007f40626ed6 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 12 Nov 2025 11:16:28 +0100 Subject: [PATCH 23/40] GeoTIFF: Save geotiff into study db. --- src/Model/GeoTIFF/GeoTIFF.py | 2 +- src/Model/River.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index a6f6de59..e86588be 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -356,7 +356,7 @@ class GeoTIFF(SQLSubModel): self.name, self.description, self.file_name, - self.file_bytes, + self._file_bytes, self.coordinates['bottom'], self.coordinates['top'], self.coordinates['left'], diff --git a/src/Model/River.py b/src/Model/River.py index 7520f838..ba5f5e8a 100644 --- a/src/Model/River.py +++ b/src/Model/River.py @@ -656,6 +656,8 @@ class River(Graph): objs.append(self._D90AdisTS) objs.append(self._DIFAdisTS) + objs.append(self._geotiff) + for solv_type in self.results: objs.append(self.results[solv_type]) From 2e360943b28fbd16f6c1d1a408ce3907e0c20ae5 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 12 Nov 2025 11:42:46 +0100 Subject: [PATCH 24/40] GeoTIFF: Switch PlotXY to display all river reaches. --- src/View/GeoTIFF/Edit/Window.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 299c3bdb..8559710f 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -36,7 +36,7 @@ from View.GeoTIFF.UndoCommand import ( ) from View.Tools.Plot.PamhyrCanvas import MplCanvas -from View.LateralContribution.PlotXY import PlotXY +from View.PlotXY import PlotXY try: import rasterio @@ -90,11 +90,12 @@ class EditGeoTIFFWindow(PamhyrWindow): self.plot = PlotXY( canvas=self.canvas, - data=None, + data=self._study.river.enable_edges(), trad=self._trad, toolbar=None, parent=self ) + self.plot.update() self._plot_img = None From a2f3d220013193ef53f99a5cea2ba7fc2c12139c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 13 Nov 2025 10:44:23 +0100 Subject: [PATCH 25/40] GeoTIFF: Add geometry and geotiff diplay next to the geotiff list. --- src/View/Debug/Window.py | 4 ++- src/View/GeoTIFF/Window.py | 70 +++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/View/Debug/Window.py b/src/View/Debug/Window.py index c3ebae91..c5f4c720 100644 --- a/src/View/Debug/Window.py +++ b/src/View/Debug/Window.py @@ -17,6 +17,7 @@ # -*- coding: utf-8 -*- import logging +import traceback from tools import trace, timer @@ -92,7 +93,8 @@ class ReplWindow(PamhyrWindow): value = exec(rich_code) value = self.__debug_exec_result__ except Exception as e: - value = f"" + str(e) + "" + value = f"" + str(e) + "\n" + value += f"{traceback.format_exc()}" # Display code msg = f" # " + code + " #" diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 69fa40c7..12ab2047 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -19,7 +19,7 @@ from tools import trace, timer from PyQt5.QtWidgets import ( - QAction, QListView, + QAction, QListView, QVBoxLayout, ) from View.Tools.PamhyrWindow import PamhyrWindow @@ -28,6 +28,23 @@ from View.GeoTIFF.List import ListModel from View.GeoTIFF.Translate import GeoTIFFTranslate from View.GeoTIFF.Edit.Window import EditGeoTIFFWindow +from View.Tools.Plot.PamhyrCanvas import MplCanvas +from View.PlotXY import PlotXY + +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + + from rasterio.io import MemoryFile + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + class GeoTIFFListWindow(PamhyrWindow): _pamhyr_ui = "GeoTIFFList" @@ -48,6 +65,7 @@ class GeoTIFFListWindow(PamhyrWindow): ) self.setup_list() + self.setup_graph() self.setup_connections() def setup_list(self): @@ -59,6 +77,26 @@ class GeoTIFFListWindow(PamhyrWindow): trad=self._trad, ) + def setup_graph(self): + self.canvas = MplCanvas(width=5, height=4, dpi=100) + self.canvas.setObjectName("canvas") + self.plot_layout = self.find(QVBoxLayout, "verticalLayout") + self.plot_layout.addWidget(self.canvas) + + self.plot = PlotXY( + canvas=self.canvas, + data=self._study.river.enable_edges(), + trad=self._trad, + toolbar=None, + parent=self + ) + self.plot.update() + + self._plot_img = {} + + for geotiff in self._study.river.geotiff.lst: + self.draw_geotiff(geotiff) + def setup_connections(self): if self._study.is_editable(): self.find(QAction, "action_add").triggered.connect(self.add) @@ -66,9 +104,39 @@ class GeoTIFFListWindow(PamhyrWindow): self.find(QAction, "action_edit").triggered.connect(self.edit) + def draw_geotiff(self, geotiff): + if not _rasterio_loaded: + return + + memfile = geotiff.memfile + if memfile is None: + return + + with memfile.open() as gt: + img = gt.read() + coords = geotiff.coordinates + + if geotiff in self._plot_img: + self._plot_img[geotiff].remove() + + self._plot_img[geotiff] = self.canvas.axes.imshow( + img.transpose((1, 2, 0)), + extent=list(coords.values()) + ) + + self.plot.idle() + def update(self): self._list.update() + for geotiff in self._plot_img: + self._plot_img[geotiff].remove() + + self._plot_img = {} + + for geotiff in self._study.river.geotiff.lst: + self.draw_geotiff(geotiff) + def selected_rows(self): lst = self.find(QListView, f"listView") return list(map(lambda i: i.row(), lst.selectedIndexes())) From 869e116ad0e9c651a48c8b5f335773a3465e3098 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 13 Nov 2025 10:49:22 +0100 Subject: [PATCH 26/40] GeoTIFF: Minor change. --- src/View/GeoTIFF/Window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 12ab2047..e87db148 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -156,6 +156,7 @@ class GeoTIFFListWindow(PamhyrWindow): return self._list.delete(rows[0]) + self.update() def edit(self): rows = self.selected_rows() From 9308a73e8ee5b030a35dfed2ca99c8f3858078b3 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 13 Nov 2025 11:47:51 +0100 Subject: [PATCH 27/40] GeoTIFF: Minor change. --- src/Model/GeoTIFF/GeoTIFF.py | 3 ++- src/View/GeoTIFF/Edit/Window.py | 36 +++++++++++++++++++++++++-------- src/View/GeoTIFF/Window.py | 2 +- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index e86588be..c35bc875 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -112,7 +112,8 @@ class GeoTIFF(SQLSubModel): elif key == "description": self.description = value elif key == "file_name": - self.read_file(value) + if self._file_name != value: + self.read_file(value) elif key == "coordinates": self.coordinates = value elif key == "coordinates_bottom": diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 8559710f..91c083da 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -22,8 +22,8 @@ from Modules import Modules from View.Tools.PamhyrWindow import PamhyrWindow from PyQt5.QtWidgets import ( - QLabel, QPlainTextEdit, QPushButton, - QCheckBox, QFileDialog, QVBoxLayout, + QLabel, QPlainTextEdit, QPushButton, QCheckBox, + QFileDialog, QVBoxLayout, QDoubleSpinBox, ) from PyQt5.QtCore import ( @@ -78,8 +78,8 @@ class EditGeoTIFFWindow(PamhyrWindow): self._undo = undo - self.setup_graph() self.setup_values() + self.setup_graph() self.setup_connection() def setup_graph(self): @@ -115,6 +115,12 @@ class EditGeoTIFFWindow(PamhyrWindow): "left": self._geotiff.coord_left, "right": self._geotiff.coord_right, } + self._values_default = { + "bottom": self._geotiff.coord_bottom, + "top": self._geotiff.coord_top, + "left": self._geotiff.coord_left, + "right": self._geotiff.coord_right, + } self._reset_values() @@ -131,6 +137,12 @@ class EditGeoTIFFWindow(PamhyrWindow): "left": bounds[1], "right": bounds[3], } + self._values_default = { + "bottom": bounds[0], + "top": bounds[2], + "left": bounds[1], + "right": bounds[3], + } self._reset_values() @@ -139,7 +151,7 @@ class EditGeoTIFFWindow(PamhyrWindow): self._reset_values_key(key) def _reset_values_key(self, key): - self.set_double_spin_box(f"doubleSpinBox_{key}", self._values[key]) + self.set_double_spin_box(f"doubleSpinBox_{key}", self._values_default[key]) def setup_connection(self): self.find(QPushButton, "pushButton_cancel")\ @@ -153,6 +165,14 @@ class EditGeoTIFFWindow(PamhyrWindow): self.find(QPushButton, f"pushButton_{key}")\ .clicked.connect(lambda: self._reset_values_key(key)) + self.find(QDoubleSpinBox, f"doubleSpinBox_{key}")\ + .valueChanged.connect(lambda: self.update_spinbox_value(key)) + + def update_spinbox_value(self, key): + self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") + + self._plot_img.set_extent(list(self._values.values())) + def draw_geotiff(self, memfile=None): if not _rasterio_loaded: return @@ -169,14 +189,14 @@ class EditGeoTIFFWindow(PamhyrWindow): img = gt.read() b = gt.bounds[:] + self._set_values_from_bounds(b) + if self._plot_img is not None: self._plot_img.remove() - self._set_values_from_bounds(b) - self._plot_img = self.canvas.axes.imshow( img.transpose((1, 2, 0)), - extent=[b[0], b[2], b[1], b[3]] + extent=list(self._values.values()) ) self.plot.idle() @@ -202,7 +222,7 @@ class EditGeoTIFFWindow(PamhyrWindow): if filename != "": self._file_name = filename - self. draw_geotiff() + self.draw_geotiff() def accept(self): if self._study.is_editable(): diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index e87db148..64bd2d3c 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -134,7 +134,7 @@ class GeoTIFFListWindow(PamhyrWindow): self._plot_img = {} - for geotiff in self._study.river.geotiff.lst: + for geotiff in self._study.river.geotiff.files: self.draw_geotiff(geotiff) def selected_rows(self): From deb9b2069f81db7085179f057b8ddf356e2db0d6 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 13 Nov 2025 14:56:03 +0100 Subject: [PATCH 28/40] GeoTIFF: Integrate geotiff menu to mainwindow and fix bounds update. --- src/View/GeoTIFF/Edit/Window.py | 49 ++++++++++++++++----------------- src/View/MainWindow.py | 3 +- src/View/ui/MainWindow.ui | 6 ++++ 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 91c083da..37ab2e58 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -85,7 +85,8 @@ class EditGeoTIFFWindow(PamhyrWindow): def setup_graph(self): self.canvas = MplCanvas(width=5, height=4, dpi=100) self.canvas.setObjectName("canvas") - self.plot_layout = self.find(QVBoxLayout, "verticalLayout_geotiff") + self.plot_layout = self.find(QVBoxLayout, + "verticalLayout_geotiff") self.plot_layout.addWidget(self.canvas) self.plot = PlotXY( @@ -109,20 +110,10 @@ class EditGeoTIFFWindow(PamhyrWindow): self.set_line_edit_text("lineEdit_description", self._geotiff.description) - self._values = { - "bottom": self._geotiff.coord_bottom, - "top": self._geotiff.coord_top, - "left": self._geotiff.coord_left, - "right": self._geotiff.coord_right, - } - self._values_default = { - "bottom": self._geotiff.coord_bottom, - "top": self._geotiff.coord_top, - "left": self._geotiff.coord_left, - "right": self._geotiff.coord_right, - } - - self._reset_values() + bounds = list(self._geotiff.coordinates.values()) + self._set_values_from_bounds(bounds) + self._set_default_values_from_bounds(bounds) + self._reset_spinboxes() if self._study.is_read_only(): self.set_check_box_enable("checkBox", False) @@ -137,6 +128,8 @@ class EditGeoTIFFWindow(PamhyrWindow): "left": bounds[1], "right": bounds[3], } + + def _set_default_values_from_bounds(self, bounds): self._values_default = { "bottom": bounds[0], "top": bounds[2], @@ -144,14 +137,15 @@ class EditGeoTIFFWindow(PamhyrWindow): "right": bounds[3], } - self._reset_values() - - def _reset_values(self): + def _reset_spinboxes(self): for key in self._values: - self._reset_values_key(key) + self._reset_spinbox(key) - def _reset_values_key(self, key): - self.set_double_spin_box(f"doubleSpinBox_{key}", self._values_default[key]) + def _reset_spinbox(self, key): + self.set_double_spin_box( + f"doubleSpinBox_{key}", + self._values_default[key] + ) def setup_connection(self): self.find(QPushButton, "pushButton_cancel")\ @@ -163,15 +157,18 @@ class EditGeoTIFFWindow(PamhyrWindow): for key in self._values: self.find(QPushButton, f"pushButton_{key}")\ - .clicked.connect(lambda: self._reset_values_key(key)) + .clicked.connect(lambda: self._reset_spinbox(key)) self.find(QDoubleSpinBox, f"doubleSpinBox_{key}")\ - .valueChanged.connect(lambda: self.update_spinbox_value(key)) + .valueChanged.connect( + lambda: self.update_values_from_spinbox(key) + ) - def update_spinbox_value(self, key): + def update_values_from_spinbox(self, key): self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") self._plot_img.set_extent(list(self._values.values())) + self.plot.idle() def draw_geotiff(self, memfile=None): if not _rasterio_loaded: @@ -184,13 +181,13 @@ class EditGeoTIFFWindow(PamhyrWindow): with rasterio.open(self._file_name) as data: img = data.read() b = data.bounds[:] + + self._set_values_from_bounds(b) else: with memfile.open() as gt: img = gt.read() b = gt.bounds[:] - self._set_values_from_bounds(b) - if self._plot_img is not None: self._plot_img.remove() diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 96c123ae..da2b7bbf 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -158,7 +158,7 @@ define_model_action = [ "action_menu_boundary_conditions_sediment", "action_menu_rep_additional_lines", "action_menu_output_rk", "action_menu_run_adists", "action_menu_pollutants", - "action_menu_d90", "action_menu_dif", + "action_menu_d90", "action_menu_dif", "action_menu_edit_geotiff" ] action = ( @@ -298,6 +298,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self.open_reach_sediment_layers, "action_menu_additional_file": self.open_additional_files, "action_menu_rep_additional_lines": self.open_rep_lines, + "action_menu_edit_geotiff": self.open_geotiff, "action_menu_close": self.close_model, "action_menu_results_last": self.open_last_results, "action_menu_open_results_from_file": self.open_results_from_file, diff --git a/src/View/ui/MainWindow.ui b/src/View/ui/MainWindow.ui index 96661c4f..b5c6f93f 100644 --- a/src/View/ui/MainWindow.ui +++ b/src/View/ui/MainWindow.ui @@ -129,6 +129,7 @@ &Geometry + @@ -811,6 +812,11 @@ Compare results + + + GeoTIFF + + From 786923bdbfc81a8d84d23b8de91e953998700cf2 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 13 Nov 2025 15:08:50 +0100 Subject: [PATCH 29/40] GeoTIFF: Add missing ui files. --- src/View/ui/GeoTIFF.ui | 264 +++++++++++++++++++++++++++++++++++++ src/View/ui/GeoTIFFList.ui | 84 ++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 src/View/ui/GeoTIFF.ui create mode 100644 src/View/ui/GeoTIFFList.ui diff --git a/src/View/ui/GeoTIFF.ui b/src/View/ui/GeoTIFF.ui new file mode 100644 index 00000000..48d953da --- /dev/null +++ b/src/View/ui/GeoTIFF.ui @@ -0,0 +1,264 @@ + + + MainWindow + + + + 0 + 0 + 896 + 504 + + + + MainWindow + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Ok + + + + + + + + + Informations + + + + + + Name + + + + + + + Enabled + + + true + + + + + + + Description + + + + + + + The relative file path on executable directory + + + + + + + + + + + + + GeoTIFF file + + + + + + Import GeoTIFF file + + + + + + + Qt::Horizontal + + + + + + + + + + + + Reset + + + + + + + true + + + Right coordinate + + + + + + + Reset + + + + + + + 4 + + + -99999999.000000000000000 + + + 99999999.000000000000000 + + + + + + + Reset + + + + + + + Reset + + + + + + + 4 + + + -99999999.000000000000000 + + + 99999999.000000000000000 + + + 1.000000000000000 + + + + + + + 4 + + + -99999999.000000000000000 + + + 99999999.000000000000000 + + + 1.000000000000000 + + + + + + + Top coordinate + + + + + + + 4 + + + -99999999.000000000000000 + + + 99999999.000000000000000 + + + + + + + Left coordinate + + + + + + + Bottom coordinate + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + diff --git a/src/View/ui/GeoTIFFList.ui b/src/View/ui/GeoTIFFList.ui new file mode 100644 index 00000000..b05ec968 --- /dev/null +++ b/src/View/ui/GeoTIFFList.ui @@ -0,0 +1,84 @@ + + + MainWindow + + + + 0 + 0 + 896 + 504 + + + + MainWindow + + + + + + + Qt::Horizontal + + + + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + ressources/add.pngressources/add.png + + + Add + + + Add a new file + + + + + + ressources/del.pngressources/del.png + + + Delete + + + Delete selected file(s) + + + + + + ressources/edit.pngressources/edit.png + + + Edit + + + Edit file + + + + + + From 7f0102a88122f9cb64eaa919ff85f32a805055c3 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 09:56:46 +0100 Subject: [PATCH 30/40] GeoTIFF: Add geotiff to mainwindow tab info and fix bounds at file import. --- src/View/GeoTIFF/Edit/Window.py | 28 ++++++++++++++++------ src/View/GeoTIFF/Window.py | 2 -- src/View/MainWindowTabInfo.py | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 37ab2e58..5cbba817 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -35,6 +35,7 @@ from View.GeoTIFF.UndoCommand import ( SetCommand ) +from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.PlotXY import PlotXY @@ -87,6 +88,11 @@ class EditGeoTIFFWindow(PamhyrWindow): self.canvas.setObjectName("canvas") self.plot_layout = self.find(QVBoxLayout, "verticalLayout_geotiff") + self._toolbar = PamhyrPlotToolbar( + self.canvas, self, + items=["home", "zoom", "save", "iso", "back/forward", "move"] + ) + self.plot_layout.addWidget(self._toolbar) self.plot_layout.addWidget(self.canvas) self.plot = PlotXY( @@ -124,16 +130,16 @@ class EditGeoTIFFWindow(PamhyrWindow): def _set_values_from_bounds(self, bounds): self._values = { "bottom": bounds[0], - "top": bounds[2], - "left": bounds[1], + "top": bounds[1], + "left": bounds[2], "right": bounds[3], } def _set_default_values_from_bounds(self, bounds): self._values_default = { "bottom": bounds[0], - "top": bounds[2], - "left": bounds[1], + "top": bounds[1], + "left": bounds[2], "right": bounds[3], } @@ -180,13 +186,20 @@ class EditGeoTIFFWindow(PamhyrWindow): with rasterio.open(self._file_name) as data: img = data.read() - b = data.bounds[:] + b = data.bounds[:] # left, bottom, right, top - self._set_values_from_bounds(b) + if b[2] > b[0] and b[1] < b[3]: + coord = [b[0], b[2], b[1], b[3]] + else: + xlim = self.canvas.axes.get_xlim() + ylim = self.canvas.axes.get_ylim() + coord = xlim + ylim + + self._set_values_from_bounds(coord) + self._set_default_values_from_bounds(coord) else: with memfile.open() as gt: img = gt.read() - b = gt.bounds[:] if self._plot_img is not None: self._plot_img.remove() @@ -197,6 +210,7 @@ class EditGeoTIFFWindow(PamhyrWindow): ) self.plot.idle() + self._reset_spinboxes() def _import(self): options = QFileDialog.Options() diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 64bd2d3c..724a76c3 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -124,8 +124,6 @@ class GeoTIFFListWindow(PamhyrWindow): extent=list(coords.values()) ) - self.plot.idle() - def update(self): self._list.update() diff --git a/src/View/MainWindowTabInfo.py b/src/View/MainWindowTabInfo.py index 1dc959fb..447e3e40 100644 --- a/src/View/MainWindowTabInfo.py +++ b/src/View/MainWindowTabInfo.py @@ -27,6 +27,20 @@ from View.Tools.PamhyrWidget import PamhyrWidget from PyQt5.QtWidgets import QVBoxLayout +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + + from rasterio.io import MemoryFile + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + logger = logging.getLogger() @@ -90,6 +104,8 @@ class WidgetInfo(PamhyrWidget): parent=self ) + self._plot_img = {} + def update(self): if self._study is None: self.set_initial_values() @@ -108,8 +124,33 @@ class WidgetInfo(PamhyrWidget): toolbar=self._toolbar_xy, parent=self ) + + self.draw_all_geotiff() self.plot.update() + def draw_all_geotiff(self): + if not _rasterio_loaded: + return + + for img in self._plot_img: + self._plot_img[img].remove() + + self._plot_img = {} + + for geotiff in self._study.river.geotiff.lst: + memfile = geotiff.memfile + if memfile is None: + return + + with memfile.open() as gt: + img = gt.read() + coords = geotiff.coordinates + + self._plot_img[geotiff] = self.canvas.axes.imshow( + img.transpose((1, 2, 0)), + extent=list(coords.values()) + ) + def set_network_values(self): river = self._study.river From 97ece018aa29b66e6aaf02d1f1c4b55064210dc4 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 10:01:56 +0100 Subject: [PATCH 31/40] Reservoirs: Fix loading without nodes. --- src/Model/Reservoir/Reservoir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Reservoir/Reservoir.py b/src/Model/Reservoir/Reservoir.py index 1d1998b6..c093691c 100644 --- a/src/Model/Reservoir/Reservoir.py +++ b/src/Model/Reservoir/Reservoir.py @@ -310,7 +310,7 @@ class Reservoir(SQLSubModel): new_reservoir.set_as_deleted() new_reservoir._node = None - if node_id != -1: + if node_id != -1 and node_id is not None: new_reservoir._node = next( filter( lambda n: n.id == node_id, data["nodes"] From ae086421165a51577fad5fd50981fe33dc3912a1 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 15:04:31 +0100 Subject: [PATCH 32/40] GeoTIFF: Factorise draw geotiff function. --- src/View/GeoTIFF/Window.py | 35 ++----------------- src/View/MainWindowTabInfo.py | 27 +-------------- src/View/PlotXY.py | 63 +++++++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 65 deletions(-) diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 724a76c3..1a924ecf 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -86,17 +86,14 @@ class GeoTIFFListWindow(PamhyrWindow): self.plot = PlotXY( canvas=self.canvas, data=self._study.river.enable_edges(), + geotiff=self._study.river.geotiff, trad=self._trad, toolbar=None, parent=self ) + self.plot.update() - self._plot_img = {} - - for geotiff in self._study.river.geotiff.lst: - self.draw_geotiff(geotiff) - def setup_connections(self): if self._study.is_editable(): self.find(QAction, "action_add").triggered.connect(self.add) @@ -104,37 +101,9 @@ class GeoTIFFListWindow(PamhyrWindow): self.find(QAction, "action_edit").triggered.connect(self.edit) - def draw_geotiff(self, geotiff): - if not _rasterio_loaded: - return - - memfile = geotiff.memfile - if memfile is None: - return - - with memfile.open() as gt: - img = gt.read() - coords = geotiff.coordinates - - if geotiff in self._plot_img: - self._plot_img[geotiff].remove() - - self._plot_img[geotiff] = self.canvas.axes.imshow( - img.transpose((1, 2, 0)), - extent=list(coords.values()) - ) - def update(self): self._list.update() - for geotiff in self._plot_img: - self._plot_img[geotiff].remove() - - self._plot_img = {} - - for geotiff in self._study.river.geotiff.files: - self.draw_geotiff(geotiff) - def selected_rows(self): lst = self.find(QListView, f"listView") return list(map(lambda i: i.row(), lst.selectedIndexes())) diff --git a/src/View/MainWindowTabInfo.py b/src/View/MainWindowTabInfo.py index 447e3e40..a895f723 100644 --- a/src/View/MainWindowTabInfo.py +++ b/src/View/MainWindowTabInfo.py @@ -104,8 +104,6 @@ class WidgetInfo(PamhyrWidget): parent=self ) - self._plot_img = {} - def update(self): if self._study is None: self.set_initial_values() @@ -120,37 +118,14 @@ class WidgetInfo(PamhyrWidget): self.plot = PlotXY( canvas=self.canvas, data=self._study.river.enable_edges(), + geotiff=self._study.river.geotiff, trad=self.parent._trad, toolbar=self._toolbar_xy, parent=self ) - self.draw_all_geotiff() self.plot.update() - def draw_all_geotiff(self): - if not _rasterio_loaded: - return - - for img in self._plot_img: - self._plot_img[img].remove() - - self._plot_img = {} - - for geotiff in self._study.river.geotiff.lst: - memfile = geotiff.memfile - if memfile is None: - return - - with memfile.open() as gt: - img = gt.read() - coords = geotiff.coordinates - - self._plot_img[geotiff] = self.canvas.axes.imshow( - img.transpose((1, 2, 0)), - extent=list(coords.values()) - ) - def set_network_values(self): river = self._study.river diff --git a/src/View/PlotXY.py b/src/View/PlotXY.py index c8ed228f..32f9d523 100644 --- a/src/View/PlotXY.py +++ b/src/View/PlotXY.py @@ -27,12 +27,26 @@ from PyQt5.QtCore import ( ) from PyQt5.QtWidgets import QApplication, QTableView +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + + from rasterio.io import MemoryFile + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + _translate = QCoreApplication.translate class PlotXY(PamhyrPlot): - def __init__(self, canvas=None, trad=None, data=None, toolbar=None, - table=None, parent=None): + def __init__(self, canvas=None, trad=None, data=None, geotiff=None, + toolbar=None, table=None, parent=None): super(PlotXY, self).__init__( canvas=canvas, trad=trad, @@ -43,10 +57,14 @@ class PlotXY(PamhyrPlot): ) self._data = data + self._geotiff = geotiff + self.label_x = self._trad["x"] self.label_y = self._trad["y"] self.parent = parent + self._plot_img = {} + self._isometric_axis = True self._auto_relim_update = True @@ -69,7 +87,12 @@ class PlotXY(PamhyrPlot): if data.reach.number_profiles != 0: self.draw_xy(data.reach) self.draw_lr(data.reach) - self.idle() + + if self._geotiff is not None: + self.draw_geotiff(self._geotiff.files) + + self.idle() + return def draw_xy(self, reach): @@ -78,9 +101,9 @@ class PlotXY(PamhyrPlot): line_xy.append(np.column_stack(xy)) line_xy_collection = collections.LineCollection( - line_xy, - colors=self.color_plot_river_bottom - ) + line_xy, + colors=self.color_plot_river_bottom + ) self.canvas.axes.add_collection(line_xy_collection) def draw_lr(self, reach): @@ -113,6 +136,34 @@ class PlotXY(PamhyrPlot): ) self.line_lr.append(line) + def draw_geotiff(self, lst): + if not _rasterio_loaded: + return + + for img in self._plot_img: + self._plot_img[img].remove() + + self._plot_img = {} + + for geotiff in lst: + memfile = geotiff.memfile + if memfile is None: + return + + with memfile.open() as gt: + img = gt.read() + coords = geotiff.coordinates + + self._plot_img[geotiff] = self.canvas.axes.imshow( + img.transpose((1, 2, 0)), + extent=list(coords.values()) + ) + + if not geotiff.is_enabled(): + self._plot_img[geotiff].set(alpha=0.5) + + self.idle() + @timer def update(self): self.draw() From 7b833390f1d9429708fbed0216fd427699af0e93 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 15:05:00 +0100 Subject: [PATCH 33/40] Results: Add study geotiff in plot xy. --- src/View/Results/PlotXY.py | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/View/Results/PlotXY.py b/src/View/Results/PlotXY.py index 88f5ebdf..e52fa33d 100644 --- a/src/View/Results/PlotXY.py +++ b/src/View/Results/PlotXY.py @@ -31,6 +31,20 @@ from PyQt5.QtCore import ( ) from PyQt5.QtWidgets import QApplication, QTableView +try: + import rasterio + import rasterio.control + import rasterio.crs + import rasterio.sample + import rasterio.vrt + import rasterio._features + + from rasterio.io import MemoryFile + _rasterio_loaded = True +except Exception as e: + print(f"Module 'rasterio' is not available: {e}") + _rasterio_loaded = False + _translate = QCoreApplication.translate logger = logging.getLogger() @@ -52,6 +66,8 @@ class PlotXY(PamhyrPlot): self.line_gl = [] self.overflow = [] + self._plot_img = {} + self._timestamps = parent._timestamps self._current_timestamp = max(self._timestamps) self._current_reach_id = reach_id @@ -153,6 +169,7 @@ class PlotXY(PamhyrPlot): reach = results.river.reach(self._current_reach_id) reaches = results.river.reachs + self.draw_geotiff() self.draw_profiles(reach, reaches) self.draw_water_elevation(reach) self.draw_water_elevation_max(reach) @@ -166,6 +183,7 @@ class PlotXY(PamhyrPlot): if reach.geometry.number_profiles == 0: self._init = False return + self.line_xy = [] # TODO uncomment to draw all the reaches # self.draw_other_profiles(reaches) @@ -306,6 +324,36 @@ class PlotXY(PamhyrPlot): alpha=0.7 ) + def draw_geotiff(self): + if not _rasterio_loaded: + return + + lst = self._data[0]._study.river._geotiff.lst + + for img in self._plot_img: + self._plot_img[img].remove() + + self._plot_img = {} + + for geotiff in lst: + memfile = geotiff.memfile + if memfile is None: + return + + with memfile.open() as gt: + img = gt.read() + coords = geotiff.coordinates + + self._plot_img[geotiff] = self.canvas.axes.imshow( + img.transpose((1, 2, 0)), + extent=list(coords.values()) + ) + + if not geotiff.is_enabled(): + self._plot_img[geotiff].set(alpha=0.5) + + self.idle() + def set_reach(self, reach_id): self._current_reach_id = reach_id self._current_profile_id = 0 From b1c7a77f37d7a8a4f72a63b50da63fd518a5f915 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 15:18:39 +0100 Subject: [PATCH 34/40] GeoTIFF: Fix pep8. --- src/View/GeoTIFF/Edit/Window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 5cbba817..4e32cb27 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -186,7 +186,7 @@ class EditGeoTIFFWindow(PamhyrWindow): with rasterio.open(self._file_name) as data: img = data.read() - b = data.bounds[:] # left, bottom, right, top + b = data.bounds[:] # left, bottom, right, top if b[2] > b[0] and b[1] < b[3]: coord = [b[0], b[2], b[1], b[3]] From 117e5222e490b05ca43fa0c856650b74299951e3 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 15:50:24 +0100 Subject: [PATCH 35/40] GeoTIFF: Edit: Fix event connection. --- src/View/GeoTIFF/Edit/Window.py | 34 ++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 4e32cb27..5cb26d60 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -148,6 +148,8 @@ class EditGeoTIFFWindow(PamhyrWindow): self._reset_spinbox(key) def _reset_spinbox(self, key): + print(f"_reset_spinbox {key}") + self.set_double_spin_box( f"doubleSpinBox_{key}", self._values_default[key] @@ -161,16 +163,34 @@ class EditGeoTIFFWindow(PamhyrWindow): self.find(QPushButton, "pushButton_import")\ .clicked.connect(self._import) - for key in self._values: - self.find(QPushButton, f"pushButton_{key}")\ - .clicked.connect(lambda: self._reset_spinbox(key)) + self.find(QPushButton, "pushButton_bottom")\ + .clicked.connect(lambda: self._reset_spinbox("bottom")) + self.find(QPushButton, "pushButton_top")\ + .clicked.connect(lambda: self._reset_spinbox("top")) + self.find(QPushButton, f"pushButton_left")\ + .clicked.connect(lambda: self._reset_spinbox("left")) + self.find(QPushButton, f"pushButton_right")\ + .clicked.connect(lambda: self._reset_spinbox("right")) - self.find(QDoubleSpinBox, f"doubleSpinBox_{key}")\ - .valueChanged.connect( - lambda: self.update_values_from_spinbox(key) - ) + self.find(QDoubleSpinBox, f"doubleSpinBox_bottom")\ + .valueChanged.connect( + lambda: self.update_values_from_spinbox("bottom") + ) + self.find(QDoubleSpinBox, f"doubleSpinBox_top")\ + .valueChanged.connect( + lambda: self.update_values_from_spinbox("top") + ) + self.find(QDoubleSpinBox, f"doubleSpinBox_left")\ + .valueChanged.connect( + lambda: self.update_values_from_spinbox("left") + ) + self.find(QDoubleSpinBox, f"doubleSpinBox_right")\ + .valueChanged.connect( + lambda: self.update_values_from_spinbox("right") + ) def update_values_from_spinbox(self, key): + print(f"update_values_from_spinbox {key}") self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") self._plot_img.set_extent(list(self._values.values())) From d47dc0687eec4ae6345c2e02b0b2f9c494d1c45e Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 14 Nov 2025 15:56:24 +0100 Subject: [PATCH 36/40] GeoTIFF: Minor change. --- src/View/GeoTIFF/Edit/Window.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 5cb26d60..47ad9e78 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -148,11 +148,8 @@ class EditGeoTIFFWindow(PamhyrWindow): self._reset_spinbox(key) def _reset_spinbox(self, key): - print(f"_reset_spinbox {key}") - self.set_double_spin_box( - f"doubleSpinBox_{key}", - self._values_default[key] + f"doubleSpinBox_{key}", self._values_default[key] ) def setup_connection(self): @@ -190,7 +187,6 @@ class EditGeoTIFFWindow(PamhyrWindow): ) def update_values_from_spinbox(self, key): - print(f"update_values_from_spinbox {key}") self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") self._plot_img.set_extent(list(self._values.values())) From 624ae826ebcd34401673a2287943512bfddb669a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 17 Nov 2025 10:08:06 +0100 Subject: [PATCH 37/40] GeoTIFF: Fix plot display where geotiff is deleted and fix update propagation. --- src/View/GeoTIFF/Window.py | 17 +++++++++++++---- src/View/PlotXY.py | 5 ++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 1a924ecf..630b3981 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -22,6 +22,8 @@ from PyQt5.QtWidgets import ( QAction, QListView, QVBoxLayout, ) +from Modules import Modules + from View.Tools.PamhyrWindow import PamhyrWindow from View.GeoTIFF.List import ListModel @@ -80,10 +82,10 @@ class GeoTIFFListWindow(PamhyrWindow): def setup_graph(self): self.canvas = MplCanvas(width=5, height=4, dpi=100) self.canvas.setObjectName("canvas") - self.plot_layout = self.find(QVBoxLayout, "verticalLayout") - self.plot_layout.addWidget(self.canvas) + self._plot_layout = self.find(QVBoxLayout, "verticalLayout") + self._plot_layout.addWidget(self.canvas) - self.plot = PlotXY( + self._plot = PlotXY( canvas=self.canvas, data=self._study.river.enable_edges(), geotiff=self._study.river.geotiff, @@ -92,7 +94,7 @@ class GeoTIFFListWindow(PamhyrWindow): parent=self ) - self.plot.update() + self._plot.update() def setup_connections(self): if self._study.is_editable(): @@ -101,8 +103,15 @@ class GeoTIFFListWindow(PamhyrWindow): self.find(QAction, "action_edit").triggered.connect(self.edit) + def _propagated_update(self, key=Modules(0)): + if Modules.GEOMETRY not in key and Modules.GEOTIFF not in key: + return + + self.update() + def update(self): self._list.update() + self._plot.update() def selected_rows(self): lst = self.find(QListView, f"listView") diff --git a/src/View/PlotXY.py b/src/View/PlotXY.py index 32f9d523..a62ecb4d 100644 --- a/src/View/PlotXY.py +++ b/src/View/PlotXY.py @@ -146,9 +146,12 @@ class PlotXY(PamhyrPlot): self._plot_img = {} for geotiff in lst: + if geotiff.is_deleted(): + continue + memfile = geotiff.memfile if memfile is None: - return + continue with memfile.open() as gt: img = gt.read() From f8a41fce088b72bffebd14b93fdbbabf3a65def9 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 17 Nov 2025 15:01:00 +0100 Subject: [PATCH 38/40] GeoTIFF: Add red rectangle at GeoTIFF selected. --- src/Model/GeoTIFF/GeoTIFF.py | 8 +++--- src/View/GeoTIFF/Window.py | 50 +++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py index c35bc875..1a9281cd 100644 --- a/src/Model/GeoTIFF/GeoTIFF.py +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -116,13 +116,13 @@ class GeoTIFF(SQLSubModel): self.read_file(value) elif key == "coordinates": self.coordinates = value - elif key == "coordinates_bottom": + elif key == "coordinates_bottom" or key == "bottom": self.coordinates["bottom"] = value - elif key == "coordinates_top": + elif key == "coordinates_top" or key == "top": self.coordinates["top"] = value - elif key == "coordinates_left": + elif key == "coordinates_left" or key == "left": self.coordinates["left"] = value - elif key == "coordinates_right": + elif key == "coordinates_right" or key == "right": self.coordinates["right"] = value self.modified() diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 630b3981..2ac03485 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -22,6 +22,8 @@ from PyQt5.QtWidgets import ( QAction, QListView, QVBoxLayout, ) +from matplotlib.patches import Rectangle + from Modules import Modules from View.Tools.PamhyrWindow import PamhyrWindow @@ -30,6 +32,7 @@ from View.GeoTIFF.List import ListModel from View.GeoTIFF.Translate import GeoTIFFTranslate from View.GeoTIFF.Edit.Window import EditGeoTIFFWindow +from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.PlotXY import PlotXY @@ -71,7 +74,7 @@ class GeoTIFFListWindow(PamhyrWindow): self.setup_connections() def setup_list(self): - lst = self.find(QListView, f"listView") + lst = self.find(QListView, "listView") self._list = ListModel( list_view=lst, data=self._study.river.geotiff, @@ -83,6 +86,11 @@ class GeoTIFFListWindow(PamhyrWindow): self.canvas = MplCanvas(width=5, height=4, dpi=100) self.canvas.setObjectName("canvas") self._plot_layout = self.find(QVBoxLayout, "verticalLayout") + self._toolbar = PamhyrPlotToolbar( + self.canvas, self, + items=["home", "zoom", "save", "iso", "back/forward", "move"] + ) + self._plot_layout.addWidget(self._toolbar) self._plot_layout.addWidget(self.canvas) self._plot = PlotXY( @@ -93,6 +101,7 @@ class GeoTIFFListWindow(PamhyrWindow): toolbar=None, parent=self ) + self._plot_rect = [] self._plot.update() @@ -103,6 +112,11 @@ class GeoTIFFListWindow(PamhyrWindow): self.find(QAction, "action_edit").triggered.connect(self.edit) + self.find(QListView, "listView")\ + .selectionModel()\ + .selectionChanged\ + .connect(self._update_rectangle) + def _propagated_update(self, key=Modules(0)): if Modules.GEOMETRY not in key and Modules.GEOTIFF not in key: return @@ -113,6 +127,40 @@ class GeoTIFFListWindow(PamhyrWindow): self._list.update() self._plot.update() + self._update_rectangle() + + def _update_rectangle(self): + for rect in self._plot_rect: + rect.remove() + + self._plot_rect = [] + + rows = self.selected_rows() + if len(rows) <= 0: + return + + for row in rows: + geotiff = self._study.river.geotiff.files[row] + coord = geotiff.coordinates + + xy = (coord["bottom"], coord["left"]) + width = abs(coord["top"] - coord["bottom"]) + height = abs(coord["right"] - coord["left"]) + + rect = Rectangle( + xy, width, height, + edgecolor='red', facecolor='none', + lw=2 + ) + + self._plot_rect.append(rect) + + self.canvas.axes.add_patch( + rect + ) + + self._plot.idle() + def selected_rows(self): lst = self.find(QListView, f"listView") return list(map(lambda i: i.row(), lst.selectedIndexes())) From 2c49e991b4ec5d747753b6ef54b40f4a7ad5a0f7 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 19 Nov 2025 11:46:22 +0100 Subject: [PATCH 39/40] GeoTIFF: Fix coordinates. --- src/View/GeoTIFF/Edit/Window.py | 18 ++++++++++++++---- src/View/GeoTIFF/Window.py | 12 ++++++++---- src/View/PlotXY.py | 7 ++++++- src/View/Results/PlotXY.py | 7 ++++++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/View/GeoTIFF/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py index 47ad9e78..fa14292d 100644 --- a/src/View/GeoTIFF/Edit/Window.py +++ b/src/View/GeoTIFF/Edit/Window.py @@ -189,7 +189,12 @@ class EditGeoTIFFWindow(PamhyrWindow): def update_values_from_spinbox(self, key): self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") - self._plot_img.set_extent(list(self._values.values())) + left = self._values["left"] + right = self._values["right"] + bottom = self._values["bottom"] + top = self._values["top"] + + self._plot_img.set_extent((left, right, bottom, top)) self.plot.idle() def draw_geotiff(self, memfile=None): @@ -205,11 +210,11 @@ class EditGeoTIFFWindow(PamhyrWindow): b = data.bounds[:] # left, bottom, right, top if b[2] > b[0] and b[1] < b[3]: - coord = [b[0], b[2], b[1], b[3]] + coord = [b[1], b[3], b[0], b[2]] else: xlim = self.canvas.axes.get_xlim() ylim = self.canvas.axes.get_ylim() - coord = xlim + ylim + coord = ylim + xlim self._set_values_from_bounds(coord) self._set_default_values_from_bounds(coord) @@ -220,9 +225,14 @@ class EditGeoTIFFWindow(PamhyrWindow): if self._plot_img is not None: self._plot_img.remove() + left = self._values["left"] + right = self._values["right"] + bottom = self._values["bottom"] + top = self._values["top"] + self._plot_img = self.canvas.axes.imshow( img.transpose((1, 2, 0)), - extent=list(self._values.values()) + extent=(left, right, bottom, top) ) self.plot.idle() diff --git a/src/View/GeoTIFF/Window.py b/src/View/GeoTIFF/Window.py index 2ac03485..1fcf6650 100644 --- a/src/View/GeoTIFF/Window.py +++ b/src/View/GeoTIFF/Window.py @@ -140,12 +140,16 @@ class GeoTIFFListWindow(PamhyrWindow): return for row in rows: - geotiff = self._study.river.geotiff.files[row] + files = self._study.river.geotiff.files + if len(files) <= row: + continue + + geotiff = files[row] coord = geotiff.coordinates - xy = (coord["bottom"], coord["left"]) - width = abs(coord["top"] - coord["bottom"]) - height = abs(coord["right"] - coord["left"]) + xy = (coord["left"], coord["bottom"]) + width = abs(coord["right"] - coord["left"]) + height = abs(coord["top"] - coord["bottom"]) rect = Rectangle( xy, width, height, diff --git a/src/View/PlotXY.py b/src/View/PlotXY.py index a62ecb4d..e51933ef 100644 --- a/src/View/PlotXY.py +++ b/src/View/PlotXY.py @@ -157,9 +157,14 @@ class PlotXY(PamhyrPlot): img = gt.read() coords = geotiff.coordinates + left = coords["left"] + right = coords["right"] + bottom = coords["bottom"] + top = coords["top"] + self._plot_img[geotiff] = self.canvas.axes.imshow( img.transpose((1, 2, 0)), - extent=list(coords.values()) + extent=(left, right, bottom, top) ) if not geotiff.is_enabled(): diff --git a/src/View/Results/PlotXY.py b/src/View/Results/PlotXY.py index e52fa33d..a880c009 100644 --- a/src/View/Results/PlotXY.py +++ b/src/View/Results/PlotXY.py @@ -344,9 +344,14 @@ class PlotXY(PamhyrPlot): img = gt.read() coords = geotiff.coordinates + left = coords["left"] + right = coords["right"] + bottom = coords["bottom"] + top = coords["top"] + self._plot_img[geotiff] = self.canvas.axes.imshow( img.transpose((1, 2, 0)), - extent=list(coords.values()) + extent=(left, right, bottom, top) ) if not geotiff.is_enabled(): From d705accaed46272effede65e59a3b9a3cd7cfc89 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Thu, 20 Nov 2025 09:58:30 +0100 Subject: [PATCH 40/40] Wiki: Add scenarios screenshot. --- doc/images/wiki/fr_scenarios.png | Bin 0 -> 42681 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/images/wiki/fr_scenarios.png diff --git a/doc/images/wiki/fr_scenarios.png b/doc/images/wiki/fr_scenarios.png new file mode 100644 index 0000000000000000000000000000000000000000..96bdd3414f457c9f4ddc40dec80374406f383204 GIT binary patch literal 42681 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 literal 0 HcmV?d00001