From b2ca1c2ab7d4c517a43fabf022f20e9cf6a40670 Mon Sep 17 00:00:00 2001 From: JeaDylan <101608960+JeaDylan@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:49:35 +0200 Subject: [PATCH] WeatherParameters: Add first parameters view + model --- src/Model/River.py | 22 +- src/Model/WeatherParameters/AirTemperature.py | 406 ++++++++++++++++++ .../WeatherParameters/AirTemperatureList.py | 73 ++++ src/View/MainWindow.py | 21 + src/View/WeatherParameters/Table.py | 240 +++++++++++ src/View/WeatherParameters/TableDefault.py | 95 ++++ src/View/WeatherParameters/UndoCommand.py | 195 +++++++++ src/View/WeatherParameters/Window.py | 313 ++++++++++++++ src/View/WeatherParameters/translate.py | 50 +++ src/View/ui/WeatherParameters.ui | 73 ++++ 10 files changed, 1484 insertions(+), 4 deletions(-) create mode 100644 src/Model/WeatherParameters/AirTemperature.py create mode 100644 src/Model/WeatherParameters/AirTemperatureList.py create mode 100644 src/View/WeatherParameters/Table.py create mode 100644 src/View/WeatherParameters/TableDefault.py create mode 100644 src/View/WeatherParameters/UndoCommand.py create mode 100644 src/View/WeatherParameters/Window.py create mode 100644 src/View/WeatherParameters/translate.py create mode 100644 src/View/ui/WeatherParameters.ui diff --git a/src/Model/River.py b/src/Model/River.py index 92d62146..7eeafa8d 100644 --- a/src/Model/River.py +++ b/src/Model/River.py @@ -66,6 +66,9 @@ from Model.InitialConditionsTemperature.InitialConditionsTemperatureList \ from Model.BoundaryConditionsTemperature.BoundaryConditionsTemperatureList \ import BoundaryConditionsTemperatureList +from Model.WeatherParameters.AirTemperatureList \ + import AirTemperatureList + from Model.GeoTIFF.GeoTIFFList import GeoTIFFList from Model.Results.Results import Results @@ -484,6 +487,7 @@ class River(Graph): DIFAdisTSList, InitialConditionsTemperatureList, BoundaryConditionsTemperatureList, + AirTemperatureList, GeoTIFFList, Results ] @@ -521,11 +525,13 @@ class River(Graph): status=self._status) self._D90AdisTS = D90AdisTSList(status=self._status) self._DIFAdisTS = DIFAdisTSList(status=self._status) - self._InitialConditionsTemperature = InitialConditionsTemperatureList( - status=self._status) + self._InitialConditionsTemperature = ( + InitialConditionsTemperatureList(status=self._status) + ) self._BoundaryConditionsTemperature = ( - BoundaryConditionsTemperatureList( - status=self._status)) + BoundaryConditionsTemperatureList(status=self._status) + ) + self._AirTemperature = (AirTemperatureList(status=self._status)) self._geotiff = GeoTIFFList(status=self._status) @@ -647,6 +653,8 @@ class River(Graph): new._BoundaryConditionsTemperature = \ BoundaryConditionsTemperatureList._db_load(execute, data) + new._AirTemperature = AirTemperatureList._db_load(execute, data) + new._geotiff = GeoTIFFList._db_load(execute, data) return new @@ -684,6 +692,7 @@ class River(Graph): objs.append(self._InitialConditionsTemperature) objs.append(self._BoundaryConditionsTemperature) + objs.append(self._AirTemperature) objs.append(self._geotiff) @@ -765,6 +774,7 @@ class River(Graph): self._D90AdisTS, self._DIFAdisTS, self._InitialConditionsTemperature, self._BoundaryConditionsTemperature, + self._AirTemperature, self._geotiff, ] @@ -906,6 +916,10 @@ Last export at: @date.""" def boundary_conditions_temperature(self): return self._BoundaryConditionsTemperature + @property + def air_temperature(self): + return self._AirTemperature + def get_params(self, solver): if solver in self._parameters: return self._parameters[solver] diff --git a/src/Model/WeatherParameters/AirTemperature.py b/src/Model/WeatherParameters/AirTemperature.py new file mode 100644 index 00000000..212ea52c --- /dev/null +++ b/src/Model/WeatherParameters/AirTemperature.py @@ -0,0 +1,406 @@ +# AirTemperature.py -- Pamhyr +# Copyright (C) 2023-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 tools import ( + trace, timer, + old_pamhyr_date_to_timestamp, + date_iso_to_timestamp, + date_dmy_to_timestamp, +) + +from Model.Tools.PamhyrDB import SQLSubModel +from Model.Except import NotImplementedMethodeError +from Model.Scenario import Scenario + +logger = logging.getLogger() + + +class Data(SQLSubModel): + _sub_classes = [] + + def __init__(self, + data0, data1, + id: int = -1, + types=[float, float], + status=None, + owner_scenario=-1): + super(Data, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) + + self._types = types + self._data = [data0, data1] + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE air_temperature_data{ext}( + {cls.create_db_add_pamhyr_id()}, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + data0 TEXT NOT NULL, + data1 TEXT NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()}, + ) + """) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + created = False + + if major == "0" and int(minor) < 2: + if cls.is_table_exists( + execute, "air_temperature_data"): + cls._db_update_to_0_2_0(execute, data) + else: + cls._db_create(execute) + + return True + + @classmethod + def _db_load(cls, execute, data=None): + new = [] + status = data['status'] + scenario = data["scenario"] + loaded = data['loaded_pid'] + + if scenario is None: + return new + + table = execute( + "SELECT pamhyr_id, deleted, data0, data1, scenario " + + "FROM air_temperature_data " + + 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) + + pid = next(it) + deleted = (next(it) == 1) + data0 = next(it) + data1 = next(it) + owner_scenario = next(it) + + lc = cls( + data0, data1, + id=pid, status=status, + owner_scenario=owner_scenario + ) + if deleted: + lc.set_as_deleted() + + loaded.add(pid) + new.append(lc) + + 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 " + + "air_temperature_data(pamhyr_id, deleted," + + "data0, data1, scenario) " + + "VALUES (" + + f"{self.id}, {self._db_format(self.is_deleted())}, " + + f"{self._data[0]}, {self._data[1]}, " + + f"{self._status.scenario_id}" + + ")" + ) + + return True + + def __getitem__(self, key): + return self._types[key](self._data[key]) + + def __setitem__(self, key, value): + self._data[key] = self._types[key](value) + + +class AirTemperature(SQLSubModel): + _sub_classes = [Data] + + def __init__(self, id: int = -1, int = -1, + name: str = "", status=None, + owner_scenario=-1): + super(AirTemperature, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) + + self._status = status + + self._reach = None + self._begin_rk = 0.0 + self._end_rk = 0.0 + self._data = [] + self._header = ["time", "rate"] + self._types = [self.time_convert, float] + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE air_temperature{ext}( + {cls.create_db_add_pamhyr_id()}, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + reach INTEGER NOT NULL, + begin_rk REAL NOT NULL, + end_rk REAL NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()}, + FOREIGN KEY(reach) REFERENCES river_reach(pamhyr_id) + ) + """) + + if ext != "": + return True + + return cls._create_submodel(execute) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + created = False + + if major == "0" and int(minor) < 2: + if cls.is_table_exists(execute, "air_temperature"): + cls._db_update_to_0_2_0(execute, data) + else: + cls._db_create(execute) + created = True + + if not created: + return cls._update_submodel(execute, version, data) + + return True + + @classmethod + def _db_load(cls, execute, data=None): + new = [] + status = data['status'] + scenario = data["scenario"] + loaded = data['loaded_pid'] + + if scenario is None: + return new + + table = execute( + "SELECT pamhyr_id, deleted, reach, " + + "begin_rk, end_rk, scenario " + + "FROM air_temperature " + + 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) + + pid = next(it) + deleted = (next(it) == 1) + reach = next(it) + brk = next(it) + erk = next(it) + owner_scenario = next(it) + + 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( + f"DELETE FROM air_temperature " + + f"WHERE pamhyr_id = {self.id} " + + f"AND scenario = {self._status.scenario_id}" + ) + execute( + f"DELETE FROM air_temperature_data " + + f"WHERE scenario = {self._status.scenario_id}" + ) + + execute( + "INSERT INTO " + + "air_temperature(pamhyr_id, deleted, " + + "reach, begin_rk, end_rk, scenario) " + + "VALUES (" + + f"{self.id}, {self._db_format(self.is_deleted())}, {self.reach}, " + + f"{self._begin_rk}, {self._end_rk}, " + + f"{self._status.scenario_id}" + + ")" + ) + ind = 0 + for d in self._data: + data["ind"] = ind + + d._db_save(execute, data) + + ind += 1 + + return True + + def _data_traversal(self, + predicate=lambda obj, data: True, + modifier=lambda obj, data: None, + data={}): + if predicate(self, data): + modifier(self, data) + + for d in self._data: + d._data_traversal(predicate, modifier, data) + + def __len__(self): + return len( + list( + filter( + lambda el: el is not None and not el.is_deleted(), + self._data + ) + ) + ) + + @classmethod + def time_convert(cls, data): + if type(data) is str: + if data.count("-") == 2: + return date_iso_to_timestamp(data) + if data.count("/") == 2: + return date_dmy_to_timestamp(data) + if data.count(":") == 3: + return old_pamhyr_date_to_timestamp(data) + if data.count(":") == 2: + return old_pamhyr_date_to_timestamp("00:" + data) + if data.count(".") == 1: + return round(float(data)) + + return int(data) + + @property + def reach(self): + return self._reach + + @reach.setter + def reach(self, reach): + self._reach = reach + self.modified() + + @property + def header(self): + return self._header.copy() + + @header.setter + def header(self, header): + self._header = header + self.modified() + + @property + def data(self): + return list( + filter( + lambda el: el is not None and not el.is_deleted(), + self._data + ) + ) + + @property + def begin_rk(self): + return self._begin_rk + + @begin_rk.setter + def begin_rk(self, begin_rk): + self._begin_rk = begin_rk + self.modified() + + @property + def end_rk(self): + return self._end_rk + + @end_rk.setter + def end_rk(self, end_rk): + self._end_rk = end_rk + self.modified() + + @property + def _default_0(self): + return self._types[0](0) + + @property + def _default_1(self): + return self._types[1](0.0) + + def add(self, index: int): + value = Data(self._default_0, self._default_1, status=self._status) + self._data.insert(index, value) + self.modified() + return value + + def insert(self, index: int, data: Data): + self._data.insert(index, data) + self.modified() + + def delete_i(self, indexes): + self._data = list( + map( + lambda e: e[1].set_as_deleted(), + filter( + lambda e: e[0] not in indexes, + enumerate(self.data) + ) + ) + ) + self.modified() + + def index(self, bc): + self._data.index(bc) + + def get_i(self, index: int): + return self._data[index] + + def get_range(self, _range): + lst = [] + for r in _range: + lst.append(r) + return lst + + def _set_i_c_v(self, index: int, column: int, value): + v = self._data[index] + v[column] = self._types[column](value) + self._data[index] = v + self.modified() + + def set_i_0(self, index: int, value): + self._set_i_c_v(index, 0, value) + + def set_i_1(self, index: int, value): + self._set_i_c_v(index, 1, value) diff --git a/src/Model/WeatherParameters/AirTemperatureList.py b/src/Model/WeatherParameters/AirTemperatureList.py new file mode 100644 index 00000000..3875125d --- /dev/null +++ b/src/Model/WeatherParameters/AirTemperatureList.py @@ -0,0 +1,73 @@ +# AirTemperatureList.py -- Pamhyr +# Copyright (C) 2023-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 copy import copy +from tools import trace, timer + +from Model.Tools.PamhyrListExt import PamhyrModelList +from Model.Except import NotImplementedMethodeError + +from Model.WeatherParameters.AirTemperature \ + import AirTemperature + + +class AirTemperatureList(PamhyrModelList): + _sub_classes = [ + AirTemperature, + ] + + @classmethod + def _db_load(cls, execute, data=None): + new = cls(status=data['status']) + + if data is None: + data = {} + + new._lst = AirTemperature._db_load( + execute, data + ) + + return new + + def _db_save(self, execute, data=None): + execute( + "DELETE FROM air_temperature " + + f"WHERE scenario = {self._status.scenario_id}" + ) + execute( + "DELETE FROM air_temperature_data " + + f"WHERE scenario = {self._status.scenario_id}" + ) + + if data is None: + data = {} + + for lc in self._lst: + lc._db_save(execute, data=data) + + return True + + def new(self, index, pollutant): + n = AirTemperature(pollutant=pollutant, status=self._status) + self._lst.insert(index, n) + self._status.modified() + return n + + @property + def Air_Temperature_List(self): + return self.lst diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 7a25f529..104f5c50 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -114,6 +114,10 @@ from View.BoundaryConditionsTemperature.Window import ( BoundaryConditionsTemperatureWindow ) +from View.WeatherParameters.Window import ( + WeatherParametersWindow +) + from Solver.Solvers import GenericSolver from Model.Results.Results import Results @@ -282,6 +286,8 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self.open_initial_conditions_temperature, "action_menu_boundary_conditions_temperature": self.open_boundary_conditions_temperature, + "action_menu_weather_parameters": + self.open_weather_parameters, "action_menu_dif": self.open_dif, "action_menu_d90": self.open_d90, "action_menu_pollutants": self.open_pollutants, @@ -1037,6 +1043,21 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # SUB WINDOWS # ############### + def open_weather_parameters(self): + river = self._study.river + + if self.sub_window_exists( + WeatherParametersWindow, + data=[self._study, None] + ): + return + + bound = WeatherParametersWindow( + study=self._study, + parent=self, + ) + bound.show() + def open_boundary_conditions_temperature(self): river = self._study.river bclist = river.boundary_conditions_temperature.lst diff --git a/src/View/WeatherParameters/Table.py b/src/View/WeatherParameters/Table.py new file mode 100644 index 00000000..bc884ac7 --- /dev/null +++ b/src/View/WeatherParameters/Table.py @@ -0,0 +1,240 @@ +# Table.py -- Pamhyr +# Copyright (C) 2023-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 +import traceback +from tools import trace, timer + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QComboBox, +) + +from View.Tools.PamhyrTable import PamhyrTableModel + +from View.WeatherParameters.UndoCommand import ( + SetCommand, AddCommand, SetCommandSpec, + DelCommand, +) + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class ComboBoxDelegate(QItemDelegate): + def __init__(self, data=None, _weather_param_lst=None, + trad=None, parent=None, mode="reaches"): + super(ComboBoxDelegate, self).__init__(parent) + + self._data = data + self._mode = mode + self._trad = trad + # self._weather_param_lst = _weather_param_lst + + def createEditor(self, parent, option, index): + self.editor = QComboBox(parent) + + val = [] + if self._mode == "rk": + reach_id = self._weather_param_lst[index.row()].reach + + reach = next(filter(lambda edge: edge.id == reach_id, + self._data.edges()), None) + + if reach_id is not None: + val = list( + map( + lambda rk: str(rk), reach.reach.get_rk() + ) + ) + else: + val = list( + map( + lambda n: n.name, self._data.edges() + ) + ) + + self.editor.addItems( + [self._trad['not_associated']] + + val + ) + + self.editor.setCurrentText(str(index.data(Qt.DisplayRole))) + return self.editor + + def setEditorData(self, editor, index): + value = index.data(Qt.DisplayRole) + self.editor.currentTextChanged.connect(self.currentItemChanged) + + def setModelData(self, editor, model, index): + text = str(editor.currentText()) + model.setData(index, text) + editor.close() + editor.deleteLater() + + def updateEditorGeometry(self, editor, option, index): + r = QRect(option.rect) + if self.editor.windowFlags() & Qt.Popup: + if editor.parent() is not None: + r.setTopLeft(self.editor.parent().mapToGlobal(r.topLeft())) + editor.setGeometry(r) + + @pyqtSlot() + def currentItemChanged(self): + self.commitData.emit(self.sender()) + + +class WeatherParametersTableModel(PamhyrTableModel): + def __init__(self, river=None, data=None, **kwargs): + self._river = river + + super(WeatherParametersTableModel, self).__init__(data=data, **kwargs) + + self._data = data + print("self._data: ", self._data) + + def _setup_lst(self): + # self._lst = list( + # filter( + # lambda ica: ica._deleted is False, + # self._data._data + # ) + # ) + self._lst = self._data._data + + def rowCount(self, parent): + return len(self._lst) + + def data(self, index, role): + if role != Qt.ItemDataRole.DisplayRole: + return QVariant() + + row = index.row() + column = index.column() + + if self._headers[column] == "name": + n = self._lst[row].name + if n is None or n == "": + return self._trad['not_associated'] + return n + elif self._headers[column] == "reach": + n = self._lst[row].reach + if n is None: + return self._trad['not_associated'] + return next(filter( + lambda edge: edge.id == n, self._river.edges() + )).name + elif self._headers[column] == "start_rk": + n = self._lst[row].start_rk + if n is None: + return self._trad['not_associated'] + return n + elif self._headers[column] == "end_rk": + n = self._lst[row].end_rk + if n is None: + return self._trad['not_associated'] + return n + elif self._headers[column] == "value": + n = self._lst[row].value + if n is None: + return self._trad['not_associated'] + return n + return n + + return QVariant() + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid() or role != Qt.EditRole: + return False + + if self.is_same_data(index, value): + return False + + row = index.row() + column = index.column() + + try: + if self._headers[column] != "reach": + self._undo.push( + SetCommandSpec( + self._lst, row, self._headers[column], value + ) + ) + elif self._headers[column] == "reach": + self._undo.push( + SetCommandSpec( + self._lst, row, self._headers[column], + self._river.edge(value).id + ) + ) + except Exception as e: + logger.info(e) + logger.debug(traceback.format_exc()) + + self.dataChanged.emit(index, index) + return True + + def add(self, row, parent=QModelIndex()): + self.beginInsertRows(parent, row, row - 1) + + self._undo.push( + AddCommand( + self._data, self._lst, row + ) + ) + + self._setup_lst() + self.endInsertRows() + self.layoutChanged.emit() + + def delete(self, rows, parent=QModelIndex()): + self.beginRemoveRows(parent, rows[0], rows[-1]) + + data_rows = { + id(ica): i for i, ica in enumerate(self._data._data) + } + self._undo.push( + DelCommand( + self._data, + self._data._data, + [data_rows[id(self._lst[row])] for row in rows] + ) + ) + + self._setup_lst() + self.endRemoveRows() + self.layoutChanged.emit() + + def undo(self): + self._undo.undo() + self._setup_lst() + self.layoutChanged.emit() + + def redo(self): + self._undo.redo() + self._setup_lst() + self.layoutChanged.emit() diff --git a/src/View/WeatherParameters/TableDefault.py b/src/View/WeatherParameters/TableDefault.py new file mode 100644 index 00000000..c95560d6 --- /dev/null +++ b/src/View/WeatherParameters/TableDefault.py @@ -0,0 +1,95 @@ +# Table.py -- Pamhyr +# Copyright (C) 2023-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 +import traceback +from tools import trace, timer + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QComboBox, +) + +from View.Tools.PamhyrTable import PamhyrTableModel + +from View.WeatherParameters.UndoCommand import ( + SetCommand, +) + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class WeatherParametersTableDefaultModel(PamhyrTableModel): + def __init__(self, **kwargs): + super(WeatherParametersTableDefaultModel, self).__init__(**kwargs) + + def data(self, index, role): + if role != Qt.ItemDataRole.DisplayRole: + return QVariant() + + row = index.row() + column = index.column() + + if self._headers[column] == "name": + return self._data[row] + elif self._headers[column] == "value": + n = 0.0 + if n is None: + return self._trad['not_associated'] + return n + + return QVariant() + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid() or role != Qt.EditRole: + return False + + row = index.row() + column = index.column() + + try: + if self._headers[column] is not None: + self._undo.push( + SetCommand( + self._data, row, self._headers[column], value + ) + ) + except Exception as e: + logger.info(e) + logger.debug(traceback.format_exc()) + + self.dataChanged.emit(index, index) + return True + + def undo(self): + self._undo.undo() + self.layoutChanged.emit() + + def redo(self): + self._undo.redo() + self.layoutChanged.emit() diff --git a/src/View/WeatherParameters/UndoCommand.py b/src/View/WeatherParameters/UndoCommand.py new file mode 100644 index 00000000..984be6e7 --- /dev/null +++ b/src/View/WeatherParameters/UndoCommand.py @@ -0,0 +1,195 @@ +# UndoCommand.py -- Pamhyr +# Copyright (C) 2023-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 copy import deepcopy +from tools import trace, timer + +from PyQt5.QtWidgets import ( + QMessageBox, QUndoCommand, QUndoStack, +) + +from Model.InitialConditionsAdisTS.InitialConditionsAdisTS \ + import InitialConditionsAdisTS +from Model.InitialConditionsAdisTS.InitialConditionsAdisTSList \ + import InitialConditionsAdisTSList + + +class SetCommand(QUndoCommand): + def __init__(self, data, row, column, new_value): + QUndoCommand.__init__(self) + + self._data = data + self._row = row + self._column = column + + if self._column == "name": + self._old = self._data[self._row].name + elif self._column == "concentration": + self._old = self._data[self._row].concentration + elif self._column == "eg": + self._old = self._data[self._row].eg + elif self._column == "em": + self._old = self._data[self._row].em + elif self._column == "ed": + self._old = self._data[self._row].ed + + _type = float + if column == "name": + _type = str + + self._new = _type(new_value) + + def undo(self): + if self._column == "name": + self._data[self._row].name = self._old + elif self._column == "concentration": + self._data[self._row].concentration = self._old + elif self._column == "eg": + self._data[self._row].eg = self._old + elif self._column == "em": + self._data[self._row].em = self._old + elif self._column == "ed": + self._data[self._row].ed = self._old + + def redo(self): + if self._column == "name": + self._data[self._row].name = self._new + elif self._column == "concentration": + self._data[self._row].concentration = self._new + elif self._column == "eg": + self._data[self._row].eg = self._new + elif self._column == "em": + self._data[self._row].em = self._new + elif self._column == "ed": + self._data[self._row].ed = self._new + + +class SetCommandSpec(QUndoCommand): + def __init__(self, data, row, column, new_value): + QUndoCommand.__init__(self) + + self._data = data + self._row = row + self._column = column + + if self._column == "name": + self._old = self._data[self._row].name + elif self._column == "reach": + self._old = self._data[self._row].reach + elif self._column == "start_rk": + self._old = self._data[self._row].start_rk + elif self._column == "end_rk": + self._old = self._data[self._row].end_rk + elif self._column == "concentration": + self._old = self._data[self._row].concentration + elif self._column == "eg": + self._old = self._data[self._row].eg + elif self._column == "em": + self._old = self._data[self._row].em + elif self._column == "ed": + self._old = self._data[self._row].ed + elif self._column == "rate": + self._old = self._data[self._row].rate + + _type = float + if column == "name": + _type = str + elif column == "reach": + _type = int + + self._new = _type(new_value) + + def undo(self): + if self._column == "name": + self._data[self._row].name = self._old + elif self._column == "reach": + self._data[self._row].reach = self._old + elif self._column == "start_rk": + self._data[self._row].start_rk = self._old + elif self._column == "end_rk": + self._data[self._row].end_rk = self._old + elif self._column == "concentration": + self._data[self._row].concentration = self._old + elif self._column == "eg": + self._data[self._row].eg = self._old + elif self._column == "em": + self._data[self._row].em = self._old + elif self._column == "ed": + self._data[self._row].ed = self._old + elif self._column == "rate": + self._data[self._row].rate = self._old + + def redo(self): + if self._column == "name": + self._data[self._row].name = self._new + elif self._column == "reach": + self._data[self._row].reach = self._new + elif self._column == "start_rk": + self._data[self._row].start_rk = self._new + elif self._column == "end_rk": + self._data[self._row].end_rk = self._new + elif self._column == "concentration": + self._data[self._row].concentration = self._new + elif self._column == "eg": + self._data[self._row].eg = self._new + elif self._column == "em": + self._data[self._row].em = self._new + elif self._column == "ed": + self._data[self._row].ed = self._new + elif self._column == "rate": + self._data[self._row].rate = self._new + + +class AddCommand(QUndoCommand): + def __init__(self, data, ics_spec, index): + QUndoCommand.__init__(self) + + self._data = data + self._ics_spec = ics_spec + self._index = index + self._new = None + + def undo(self): + self._data.delete_i([self._index]) + + def redo(self): + if self._new is None: + self._new = self._data.new(self._index) + else: + self._data.insert(self._index, self._new) + + +class DelCommand(QUndoCommand): + def __init__(self, data, ics_spec, rows): + QUndoCommand.__init__(self) + + self._data = data + self._ics_spec = ics_spec + self._rows = rows + + self._ic = [] + for row in rows: + self._ic.append((row, self._ics_spec[row])) + self._ic.sort() + + def undo(self): + for row, el in self._ic: + self._data.insert(row, el) + + def redo(self): + self._data.delete_i(self._rows) diff --git a/src/View/WeatherParameters/Window.py b/src/View/WeatherParameters/Window.py new file mode 100644 index 00000000..4aa0c7ab --- /dev/null +++ b/src/View/WeatherParameters/Window.py @@ -0,0 +1,313 @@ +# Window.py -- Pamhyr +# Copyright (C) 2023-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 logging + +from tools import trace, timer, logger_exception + +from View.Tools.PamhyrWindow import PamhyrWindow + +from PyQt5.QtGui import ( + QKeySequence, QIcon, +) + +from PyQt5.QtCore import ( + Qt, QVariant, QAbstractTableModel, + QCoreApplication, QModelIndex, pyqtSlot, + QRect, QItemSelectionModel, +) + +from PyQt5.QtWidgets import ( + QDialogButtonBox, QPushButton, QLineEdit, + QFileDialog, QTableView, QAbstractItemView, + QUndoStack, QShortcut, QAction, QItemDelegate, + QComboBox, QVBoxLayout, QHeaderView, QTabWidget, + QVBoxLayout, QToolBar, QAction, QToolButton, +) + +from Modules import Modules + +from View.WeatherParameters.UndoCommand import ( + SetCommand, +) + +from View.WeatherParameters.TableDefault import ( + WeatherParametersTableDefaultModel, +) + +from View.WeatherParameters.Table import ( + WeatherParametersTableModel, ComboBoxDelegate, +) + +from View.WeatherParameters.translate import WeatherParametersTranslate + +from Solver.Mage import Mage8 + +_translate = QCoreApplication.translate + +logger = logging.getLogger() + + +class WeatherParametersWindow(PamhyrWindow): + _pamhyr_ui = "WeatherParameters" + _pamhyr_name = "Weather parameters" + + + def __init__(self, data=None, study=None, + config=None, parent=None): + self._data = [] + # self._data.append(data) + trad = WeatherParametersTranslate() + + self.weather_params = [trad.get_dict("weather_parameters")[k] for k in trad.get_dict("weather_parameters").keys()] + print(f"Weather parameters: {self.weather_params}") + name = ( + trad[self._pamhyr_name] + + " - " + study.name + ) + + super(WeatherParametersWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + parent=parent + ) + + self._hash_data.append(data) + + # self._ics_adists_lst = study.river.ic_adists + + self.setup_table() + + def setup_table(self): + path_icons = os.path.join(self._get_ui_directory(), f"ressources") + + table_default = self.find(QTableView, f"tableView") + + if self._study.is_editable(): + editable_headers = self._trad.get_dict("table_headers") + else: + editable_headers = [] + + self._table = WeatherParametersTableDefaultModel( + table_view=table_default, + table_headers=self._trad.get_dict("table_headers"), + editable_headers=editable_headers, + delegates={}, + data=self.weather_params, + undo=self._undo_stack, + trad=self._trad + ) + + table_default.setModel(self._table) + table_default.setSelectionBehavior(QAbstractItemView.SelectRows) + table_default.horizontalHeader().setSectionResizeMode( + QHeaderView.Stretch + ) + table_default.setAlternatingRowColors(True) + + layout = self.find(QVBoxLayout, f"verticalLayout_1") + toolBar = QToolBar() + layout.addWidget(toolBar) + + action_add = QAction(self) + action_add.setIcon(QIcon(os.path.join(path_icons, f"add.png"))) + + if self._study.is_editable(): + action_add.triggered.connect(self.add) + action_delete = QAction(self) + action_delete.setIcon(QIcon(os.path.join(path_icons, f"del.png"))) + + if self._study.is_editable(): + action_delete.triggered.connect(self.delete) + + toolBar.addAction(action_add) + toolBar.addAction(action_delete) + + self.table_spec = QTableView() + layout.addWidget(self.table_spec) + + self._delegate_reach = ComboBoxDelegate( + trad=self._trad, + data=self._study.river, + parent=self, + mode="reaches" + ) + self._delegate_rk = ComboBoxDelegate( + trad=self._trad, + data=self._study.river, + parent=self, + mode="rk" + ) + + if self._study.is_editable(): + editable_headers_spec = self._trad.get_dict("table_headers_spec") + else: + editable_headers_spec = [] + + self._table_spec = WeatherParametersTableModel( + table_view=self.table_spec, + table_headers=self._trad.get_dict("table_headers_spec"), + editable_headers=editable_headers_spec, + delegates={ + "reach": self._delegate_reach, + "start_rk": self._delegate_rk, + "end_rk": self._delegate_rk + }, + data=self._data, + undo=self._undo_stack, + trad=self._trad, + river=self._study.river + ) + + self.table_spec.setModel(self._table_spec) + self.table_spec.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table_spec.horizontalHeader().setSectionResizeMode( + QHeaderView.Stretch + ) + self.table_spec.setAlternatingRowColors(True) + + selectionModel = self.table_spec.selectionModel() + index = self.table_spec.model().index(0, 0) + + selectionModel.select( + index, + QItemSelectionModel.Rows | + QItemSelectionModel.ClearAndSelect | + QItemSelectionModel.Select + ) + self.table_spec.scrollTo(index) + + def index_selected_row(self): + # table = self.find(QTableView, f"tableView") + table = self.table_spec + rows = table.selectionModel()\ + .selectedRows() + + if len(rows) == 0: + return 0 + + return rows[0].row() + + def index_selected_rows(self): + # table = self.find(QTableView, f"tableView") + table = self.table_spec + return list( + # Delete duplicate + set( + map( + lambda i: i.row(), + table.selectedIndexes() + ) + ) + ) + + def move_up(self): + row = self.index_selected_row() + self._table.move_up(row) + self._update() + + def move_down(self): + row = self.index_selected_row() + self._table.move_down(row) + self._update() + + def _copy(self): + rows = list( + map( + lambda row: row.row(), + self.tableView.selectionModel().selectedRows() + ) + ) + + table = list( + map( + lambda eic: list( + map( + lambda k: eic[1][k], + ["rk", "discharge", "elevation"] + ) + ), + filter( + lambda eic: eic[0] in rows, + enumerate(self._ics.lst()) + ) + ) + ) + + self.copyTableIntoClipboard(table) + + def _paste(self): + header, data = self.parseClipboardTable() + + if len(data) + len(header) == 0: + return + + logger.debug( + "IC: Paste: " + + f"header = {header}, " + + f"data = {data}" + ) + + try: + row = self.index_selected_row() + # self._table.paste(row, header, data) + self._table.paste(row, [], data) + except Exception as e: + logger_exception(e) + + self._update() + + def _undo(self): + undo_stack = self._undo_stack + if undo_stack is None or not undo_stack.canUndo(): + return + + if isinstance(undo_stack.command(undo_stack.index() - 1), SetCommand): + self._table.undo() + else: + self._table_spec.undo() + + self._update() + + def _redo(self): + undo_stack = self._undo_stack + if undo_stack is None or not undo_stack.canRedo(): + return + + if isinstance(undo_stack.command(undo_stack.index()), SetCommand): + self._table.redo() + else: + self._table_spec.redo() + + self._update() + + def add(self): + rows = self.index_selected_rows() + if len(self._data[0]._data) == 0 or len(rows) == 0: + self._table_spec.add(0) + else: + self._table_spec.add(rows[0]) + + def delete(self): + rows = self.index_selected_rows() + if len(rows) == 0: + return + self._table_spec.delete(rows) diff --git a/src/View/WeatherParameters/translate.py b/src/View/WeatherParameters/translate.py new file mode 100644 index 00000000..4da9de4f --- /dev/null +++ b/src/View/WeatherParameters/translate.py @@ -0,0 +1,50 @@ +# translate.py -- Pamhyr +# Copyright (C) 2023-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 WeatherParametersTranslate(MainTranslate): + def __init__(self): + super(WeatherParametersTranslate, self).__init__() + + self._dict["Weather parameters"] = _translate( + "WeatherParameters", "Weather parameters") + + self._dict["rk"] = self._dict["unit_rk"] + + self._sub_dict["table_headers"] = { + "name": self._dict["name"], + "value": self._dict["value"] + } + + self._sub_dict["table_headers_spec"] = { + "name": self._dict["name"], + "reach": self._dict["reach"], + "start_rk": _translate("Unit", "Start_RK (m)"), + "end_rk": _translate("Unit", "End_RK (m)"), + "value": self._dict["value"] + } + + self._sub_dict["weather_parameters"] = { + "air_temperature": _translate("WeatherParameters", "Air temperature") + } \ No newline at end of file diff --git a/src/View/ui/WeatherParameters.ui b/src/View/ui/WeatherParameters.ui new file mode 100644 index 00000000..b4fe3e2d --- /dev/null +++ b/src/View/ui/WeatherParameters.ui @@ -0,0 +1,73 @@ + + + MainWindow + + + + 0 + 0 + 1024 + 576 + + + + MainWindow + + + + + + + Qt::Horizontal + + + + + + + + + + + + + + Qt::Vertical + + + + + + + + + + + + + + + + 0 + 0 + 1024 + 22 + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + +