diff --git a/doc/images/wiki/fr_scenarios.png b/doc/images/wiki/fr_scenarios.png new file mode 100644 index 00000000..96bdd341 Binary files /dev/null and b/doc/images/wiki/fr_scenarios.png differ diff --git a/src/Model/GeoTIFF/GeoTIFF.py b/src/Model/GeoTIFF/GeoTIFF.py new file mode 100644 index 00000000..1a9281cd --- /dev/null +++ b/src/Model/GeoTIFF/GeoTIFF.py @@ -0,0 +1,368 @@ +# 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 + + 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() + + +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 = description + + self._file_bytes = b'' + 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 != "": + 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 == "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 + + 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": + if self._file_name != value: + self.read_file(value) + elif key == "coordinates": + self.coordinates = value + elif key == "coordinates_bottom" or key == "bottom": + self.coordinates["bottom"] = value + elif key == "coordinates_top" or key == "top": + self.coordinates["top"] = value + elif key == "coordinates_left" or key == "left": + self.coordinates["left"] = value + elif key == "coordinates_right" or key == "right": + self.coordinates["right"] = 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 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: + return None + + if self._file_bytes == b'': + return None + + if self._memfile is None: + self._memfile = MemoryFile() + self._memfile.write(self._file_bytes) + + 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) + + @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: + cls._db_create(execute) + + if major == "0" and int(minor) == 2: + if int(release) < 3: + cls._db_create(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))})" + ) + + 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) + + 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..84f3c803 --- /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.GeoTIFF.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 gt in self._lst: + ok &= gt._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 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"] diff --git a/src/Model/Results/Results.py b/src/Model/Results/Results.py index 3b352bec..aad79c04 100644 --- a/src/Model/Results/Results.py +++ b/src/Model/Results/Results.py @@ -29,8 +29,134 @@ 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': type_x, + 'type_y': type_y, + 'legend': legend, + 'unit': unit, + 'x': x, 'y': y + } + + new_data = cls(study=study) + new_data._data = data + new.append(new_data) + + 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 +176,7 @@ class Results(SQLSubModel): # Keep results creation date "creation_date": datetime.now(), "study_revision": study.status.version, + "additional_data": [], } if solver is not None: @@ -184,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): @@ -206,6 +338,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 +375,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/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) diff --git a/src/Model/River.py b/src/Model/River.py index e0c4602b..ba5f5e8a 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._geotiff = GeoTIFFList(status=self._status) + self._results = {} @classmethod @@ -617,6 +621,8 @@ class River(Graph): new._DIFAdisTS = DIFAdisTSList._db_load(execute, data) + new._geotiff = GeoTIFFList._db_load(execute, data) + return new def _db_load_results(self, execute, data=None): @@ -650,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]) @@ -726,6 +734,7 @@ class River(Graph): self._BoundaryConditionsAdisTS, self._LateralContributionsAdisTS, self._D90AdisTS, self._DIFAdisTS, + self._geotiff, ] for solver in self._parameters: @@ -818,6 +827,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/Model/Study.py b/src/Model/Study.py index 2d7292b7..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.1" + _version = "0.2.3" _sub_classes = [ Scenario, 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: [], } 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/Edit/Window.py b/src/View/GeoTIFF/Edit/Window.py new file mode 100644 index 00000000..fa14292d --- /dev/null +++ b/src/View/GeoTIFF/Edit/Window.py @@ -0,0 +1,289 @@ +# 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, + QFileDialog, QVBoxLayout, QDoubleSpinBox, +) + +from PyQt5.QtCore import ( + QSettings +) + +from View.GeoTIFF.Translate import GeoTIFFTranslate +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 + +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() + + +class EditGeoTIFFWindow(PamhyrWindow): + _pamhyr_ui = "GeoTIFF" + _pamhyr_name = "Edit GeoTIFF" + + def __init__(self, study=None, config=None, geotiff=None, + trad=None, undo=None, parent=None): + + super(EditGeoTIFFWindow, self).__init__( + 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 + + self.setup_values() + self.setup_graph() + 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._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( + canvas=self.canvas, + data=self._study.river.enable_edges(), + trad=self._trad, + toolbar=None, + parent=self + ) + self.plot.update() + + 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) + self.set_line_edit_text("lineEdit_description", + self._geotiff.description) + + 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) + self.set_line_edit_enable("lineEdit_name", False) + 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[1], + "left": bounds[2], + "right": bounds[3], + } + + def _set_default_values_from_bounds(self, bounds): + self._values_default = { + "bottom": bounds[0], + "top": bounds[1], + "left": bounds[2], + "right": bounds[3], + } + + def _reset_spinboxes(self): + for key in self._values: + self._reset_spinbox(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")\ + .clicked.connect(self.close) + self.find(QPushButton, "pushButton_ok")\ + .clicked.connect(self.accept) + self.find(QPushButton, "pushButton_import")\ + .clicked.connect(self._import) + + 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_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): + self._values[key] = self.get_double_spin_box(f"doubleSpinBox_{key}") + + 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): + 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[:] # left, bottom, right, top + + if b[2] > b[0] and 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 = ylim + xlim + + self._set_values_from_bounds(coord) + self._set_default_values_from_bounds(coord) + else: + with memfile.open() as gt: + img = gt.read() + + 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=(left, right, bottom, top) + ) + + self.plot.idle() + self._reset_spinboxes() + + 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 + 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") + 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") + coord_left = self.get_double_spin_box("doubleSpinBox_left") + coord_right = self.get_double_spin_box("doubleSpinBox_right") + + 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, + file_name=self._file_name, + ) + ) + + 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..d604d72e --- /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.description}'" + + 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..3e328230 --- /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(GeoTIFFTranslate, 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..1fcf6650 --- /dev/null +++ b/src/View/GeoTIFF/Window.py @@ -0,0 +1,217 @@ +# 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, QVBoxLayout, +) + +from matplotlib.patches import Rectangle + +from Modules import Modules + +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 + +from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar +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" + _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_graph() + self.setup_connections() + + def setup_list(self): + lst = self.find(QListView, "listView") + self._list = ListModel( + list_view=lst, + data=self._study.river.geotiff, + undo=self._undo_stack, + 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._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( + canvas=self.canvas, + data=self._study.river.enable_edges(), + geotiff=self._study.river.geotiff, + trad=self._trad, + toolbar=None, + parent=self + ) + self._plot_rect = [] + + self._plot.update() + + 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) + + 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 + + self.update() + + def update(self): + 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: + files = self._study.river.geotiff.files + if len(files) <= row: + continue + + geotiff = files[row] + coord = geotiff.coordinates + + xy = (coord["left"], coord["bottom"]) + width = abs(coord["right"] - coord["left"]) + height = abs(coord["top"] - coord["bottom"]) + + 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())) + + 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]) + self.update() + + def edit(self): + rows = self.selected_rows() + + for row in rows: + geotiff = self._study.river.geotiff.files[row] + + if self.sub_window_exists( + EditGeoTIFFWindow, + data=[self._study, self._config, geotiff] + ): + continue + + win = EditGeoTIFFWindow( + study=self._study, + config=self._config, + geotiff=geotiff, + 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() diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 993b706a..d5ae0353 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 @@ -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 ( @@ -157,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 = ( @@ -276,8 +277,8 @@ 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_as": self.save_as_study, + "action_menu_save": lambda: self.save_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, @@ -297,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, @@ -313,7 +315,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 @@ -606,6 +608,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: @@ -637,7 +649,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 +682,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) @@ -684,6 +703,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) @@ -758,6 +778,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() @@ -782,6 +804,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) status += " Done" + progress.close() logger.info(status) self.statusbar.showMessage(status, 3000) @@ -1339,6 +1362,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( @@ -1523,14 +1559,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: @@ -1555,13 +1591,16 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) dlg.exec_() results = self._tmp_results + # self.last_results = results # 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: @@ -1587,6 +1626,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 @@ -1662,16 +1715,49 @@ 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 + 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: (self.is_solver_workdir_exists(s) + or 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/MainWindowTabInfo.py b/src/View/MainWindowTabInfo.py index 1dc959fb..a895f723 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() @@ -104,10 +118,12 @@ 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.plot.update() def set_network_values(self): diff --git a/src/View/PlotXY.py b/src/View/PlotXY.py index c8ed228f..e51933ef 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,42 @@ 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: + if geotiff.is_deleted(): + continue + + memfile = geotiff.memfile + if memfile is None: + continue + + with memfile.open() as gt: + 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=(left, right, bottom, top) + ) + + if not geotiff.is_enabled(): + self._plot_img[geotiff].set(alpha=0.5) + + self.idle() + @timer def update(self): self.draw() diff --git a/src/View/Results/PlotXY.py b/src/View/Results/PlotXY.py index 88f5ebdf..a880c009 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,41 @@ 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 + + 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=(left, right, bottom, top) + ) + + 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 diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index ccac22ef..5a6277d2 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -56,9 +56,12 @@ 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 + from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar @@ -144,11 +147,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}"), @@ -157,8 +163,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() @@ -189,12 +198,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=[ @@ -202,6 +214,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) @@ -1217,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 ) @@ -1231,7 +1243,6 @@ class ResultsWindow(PamhyrWindow): return def import_data(self): - file_types = [ self._trad["file_csv"], self._trad["file_all"], @@ -1242,41 +1253,61 @@ 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): if filename == "": return - sep = " " + x, y = self.read_csv_file_data(filename) + data = self.read_csv_file_format(x, y) - def is_float(string): - if string.replace(".", "").isnumeric(): - return True - else: - return False + 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 = "," + 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: + continue + + try: + fx, fy = float(row[0]), float(row[1]) + x.append(fx) + y.append(fy) + except Exception as e: + 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 legend, ok = QInputDialog.getText(self, 'Legend', 'Legend:') - if not ok: return @@ -1291,25 +1322,45 @@ 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)') - 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)') - self.plot_h._line.append(line) - self.plot_h.enable_legend() - self.plot_h.canvas.draw_idle() - self.plot_h.update_idle + return data - for p in self._additional_plot: - self._additional_plot[p].add_imported_plot(data) + def update_plot_additional_data(self): + results = self._results[self._current_results[0]] + + 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'] == '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_x'] == 'time' and data['type_y'] == 'discharge': + 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) 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/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" diff --git a/src/View/Translate.py b/src/View/Translate.py index 30434d4f..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): @@ -180,6 +192,9 @@ class MainTranslate(UnitTranslate): ) # Message box + self._dict["Error"] = _translate( + "MainWindow", "Error" + ) self._dict["Warning"] = _translate( "MainWindow", "Warning" ) @@ -234,6 +249,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 " @@ -247,3 +273,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") 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 "], ["█▓▒░", "▓█▓▒", "▒▓█▓", "░▒▓█", "▒▓█▓", "▓█▓▒"], "▖▘▝▗", "αβγδεζηθικλμνξοπρστυφχψω", 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 + + + + + + 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 + +