From 95dd648b90cb7e2415f8f0eaf10a73f72aee84f6 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Fri, 26 Jun 2026 17:36:42 +0200 Subject: [PATCH] Temperature: Interface for temperature's initial conditions --- .../InitialConditionsTemperature.py | 229 +++++++++++++ .../InitialConditionsTemperatureList.py | 67 ++++ .../InitialConditionsTemperatureSpec.py | 202 ++++++++++++ src/Model/River.py | 16 + src/Model/Study.py | 2 +- .../InitialConditionsTemperature/Table.py | 240 ++++++++++++++ .../TableDefault.py | 94 ++++++ .../UndoCommand.py | 153 +++++++++ .../InitialConditionsTemperature/Window.py | 312 ++++++++++++++++++ .../InitialConditionsTemperature/translate.py | 46 +++ src/View/MainWindow.py | 32 +- src/View/Translate.py | 2 + src/View/ui/InitialConditionsTemperature.ui | 118 +++++++ src/View/ui/MainWindow.ui | 16 +- src/lang/fr.ts | 172 ++++++---- 15 files changed, 1619 insertions(+), 82 deletions(-) create mode 100644 src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py create mode 100644 src/Model/InitialConditionsTemperature/InitialConditionsTemperatureList.py create mode 100644 src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py create mode 100644 src/View/InitialConditionsTemperature/Table.py create mode 100644 src/View/InitialConditionsTemperature/TableDefault.py create mode 100644 src/View/InitialConditionsTemperature/UndoCommand.py create mode 100644 src/View/InitialConditionsTemperature/Window.py create mode 100644 src/View/InitialConditionsTemperature/translate.py create mode 100644 src/View/ui/InitialConditionsTemperature.ui diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py new file mode 100644 index 00000000..350970c6 --- /dev/null +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py @@ -0,0 +1,229 @@ +# InitialConditionsTemperature.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 functools import reduce + +from tools import trace, timer, old_pamhyr_date_to_timestamp + +from Model.Tools.PamhyrDB import SQLSubModel +from Model.Except import NotImplementedMethodeError +from Model.Scenario import Scenario + +from Model.InitialConditionsTemperature.InitialConditionsTemperatureSpec \ + import ICTemperatureSpec + +logger = logging.getLogger() + + +class InitialConditionsTemperature(SQLSubModel): + _sub_classes = [ + ICTemperatureSpec, + ] + + def __init__(self, id: int = -1, name: str = "default", + status=None, owner_scenario=-1): + super(InitialConditionsTemperature, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) + self._status = status + + self._name = name + self._temperature = None + self._data = [] + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE initial_conditions_temperature{ext}( + {cls.create_db_add_pamhyr_id()}, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + name TEXT NOT NULL, + temperature REAL NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()} + ) + """) + + return cls._create_submodel(execute) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + + if major == "0": + if int(minor) < 2 or (int(minor) == 2 and int(release) < 7): + table_name = "initial_conditions_temperature" + if not cls.is_table_exists(execute, table_name): + cls._db_create(execute) + + return cls._update_submodel(execute, version, data) + + @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, " + + "name, temperature, scenario " + + "FROM initial_conditions_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) + name = next(it) + temperature = next(it) + owner_scenario = next(it) + + ic = cls( + id=pid, + name=name, + status=status, + owner_scenario=owner_scenario + ) + if deleted: + ic.set_as_deleted() + + ic.temperature = temperature + + data['ic_default_id'] = pid + ic._data = ICTemperatureSpec._db_load(execute, data) + + loaded.add(pid) + new.append(ic) + + 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( + "DELETE FROM initial_conditions_temperature " + + f"WHERE pamhyr_id = {self.id} " + + f"AND scenario = {self._status.scenario_id}" + ) + + temperature = self._temperature if \ + self._temperature is not None else 0.0 + + sql = ( + "INSERT INTO " + + "initial_conditions_temperature(" + + "pamhyr_id, deleted, name, temperature, " + + "scenario" + + ") " + + "VALUES (" + + f"{self.id}, {self.is_deleted()}, " + + f"'{self._db_format(self._name)}', " + + f"{temperature}, {self._status.scenario_id}" + + ")" + ) + + execute(sql) + + data['ic_default_id'] = self.id + execute( + "DELETE FROM initial_conditions_temperature_spec " + + f"WHERE ic_default = {self.id} " + + f"AND scenario = {self._status.scenario_id}" + ) + + for ic_spec in self._data: + ic_spec._db_save(execute, data) + + return True + + def __len__(self): + return len(self._data) + + @property + def name(self): + return self._name + + @name.setter + def name(self, name): + self._name = name + self.modified() + + @property + def temperature(self): + return self._temperature + + @temperature.setter + def temperature(self, temperature): + self._temperature = temperature + self.modified() + + def new(self, index): + n = ICTemperatureSpec(status=self._status) + self._data.insert(index, n) + self.modified() + return n + + def delete(self, data): + list( + map( + lambda x: x.set_as_deleted(), + data + ) + ) + self.modified() + + def delete_i(self, indexes): + list( + map( + lambda e: e[1].set_as_deleted(), + filter( + lambda e: e[0] in indexes, + enumerate(self._data) + ) + ) + ) + self.modified() + + def insert(self, index, data): + if data in self._data: + self.undelete([data]) + else: + self._data.insert(index, data) + + self.modified() + + def undelete(self, lst): + for x in lst: + x.set_as_not_deleted() + + self.modified() diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureList.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureList.py new file mode 100644 index 00000000..2d60bc79 --- /dev/null +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureList.py @@ -0,0 +1,67 @@ +# InitialConditionsTemperatureList.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.InitialConditionsTemperature.InitialConditionsTemperature \ + import InitialConditionsTemperature + + +class InitialConditionsTemperatureList(PamhyrModelList): + _sub_classes = [ + InitialConditionsTemperature, + ] + + @classmethod + def _db_load(cls, execute, data=None): + new = cls(status=data['status']) + + if data is None: + data = {} + + new._lst = InitialConditionsTemperature._db_load( + execute, data + ) + + return new + + def _db_save(self, execute, data=None): + execute( + "DELETE FROM initial_conditions_temperature " + + f"WHERE scenario = {self._status.scenario_id}" + ) + + if data is None: + data = {} + + for ic in self._lst: + ic._db_save(execute, data=data) + + return True + + def new(self, index): + n = InitialConditionsTemperature(status=self._status) + self._lst.insert(index, n) + self._status.modified() + return n + + @property + def Initial_Conditions_List(self): + return self.lst diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py new file mode 100644 index 00000000..3682b275 --- /dev/null +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py @@ -0,0 +1,202 @@ +# InitialConditionsAdisTSSpec.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 + +from Model.Tools.PamhyrDB import SQLSubModel +from Model.Except import NotImplementedMethodeError +from Model.Scenario import Scenario + +logger = logging.getLogger() + + +class ICTemperatureSpec(SQLSubModel): + _sub_classes = [] + + def __init__(self, id: int = -1, name: str = "", + status=None, owner_scenario=None): + super(ICTemperatureSpec, self).__init__() + + self._status = status + + self._name_section = name + self._reach = None + self._start_rk = None + self._end_rk = None + self._temperature = None + + @classmethod + def _db_create(cls, execute, ext=""): + execute(f""" + CREATE TABLE initial_conditions_temperature_spec{ext}( + {cls.create_db_add_pamhyr_id()}, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + ic_default INTEGER NOT NULL, + name TEXT NOT NULL, + reach INTEGER NOT NULL, + start_rk REAL NOT NULL, + end_rk REAL NOT NULL, + temperature REAL NOT NULL, + {Scenario.create_db_add_scenario()}, + {Scenario.create_db_add_scenario_fk()}, + FOREIGN KEY(ic_default) + REFERENCES initial_conditions_temperature(pamhyr_id), + FOREIGN KEY(reach) REFERENCES river_reach(pamhyr_id) + ) + """) + + return cls._create_submodel(execute) + + @classmethod + def _db_update(cls, execute, version, data=None): + major, minor, release = version.strip().split(".") + + if major == "0": + if int(minor) < 2 or (int(minor) == 2 and int(release) < 7): + table_name = "initial_conditions_temperature_spec" + if not cls.is_table_exists(execute, table_name): + cls._db_create(execute) + + return True + + @classmethod + def _db_load(cls, execute, data=None): + new = [] + status = data['status'] + reachs = data["edges"] + scenario = data["scenario"] + loaded = data['loaded_pid'] + + if scenario is None: + return new + + table = execute( + "SELECT pamhyr_id, deleted, name, reach, start_rk, end_rk, " + + "temperature, scenario " + + "FROM initial_conditions_temperature_spec " + + f"WHERE ic_default = {data['ic_default_id']} " + + f"AND 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) + deleted = (next(it) == 1) + name = next(it) + reach = next(it) + start_rk = next(it) + end_rk = next(it) + temperature = next(it) + owner_scenario = next(it) + + new_spec = cls( + id=id, name=name, + status=status, + owner_scenario=owner_scenario + ) + if deleted: + new_spec.set_as_deleted() + + new_spec.reach = reach + new_spec.start_rk = start_rk + new_spec.end_rk = end_rk + new_spec.temperature = temperature + + # loaded.add(pid) + new.append(new_spec) + + 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 + + ic_default = data['ic_default_id'] + + sql = ( + "INSERT INTO " + + "initial_conditions_temperature_spec(pamhyr_id, deleted, " + + "ic_default, name, reach, " + + "start_rk, end_rk, temperature, scenario) " + + "VALUES (" + + f"{self.id}, {self._db_format(self.is_deleted())}, " + + f"{ic_default}, " + + f"'{self._db_format(self._name_section)}', " + + f"{self._reach}, " + + f"{self._start_rk}, {self._end_rk}, " + + f"{self._temperature}, " + + f"{self._status.scenario_id}" + + ")" + ) + execute(sql) + + return True + + @property + def name(self): + return self._name_section + + @name.setter + def name(self, name): + self._name_section = name + self._status.modified() + + @property + def reach(self): + return self._reach + + @reach.setter + def reach(self, reach): + self._reach = reach + self._status.modified() + + @property + def start_rk(self): + return self._start_rk + + @start_rk.setter + def start_rk(self, start_rk): + self._start_rk = start_rk + self._status.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._status.modified() + + @property + def temperature(self): + return self._temperature + + @temperature.setter + def temperature(self, temperature): + self._temperature = temperature + self._status.modified() diff --git a/src/Model/River.py b/src/Model/River.py index 58d93cd5..eb53957f 100644 --- a/src/Model/River.py +++ b/src/Model/River.py @@ -59,6 +59,10 @@ from Model.LateralContributionsAdisTS.LateralContributionsAdisTSList \ import LateralContributionsAdisTSList from Model.D90AdisTS.D90AdisTSList import D90AdisTSList from Model.DIFAdisTS.DIFAdisTSList import DIFAdisTSList + +from Model.InitialConditionsTemperature.InitialConditionsTemperatureList \ + import InitialConditionsTemperatureList + from Model.GeoTIFF.GeoTIFFList import GeoTIFFList from Model.Results.Results import Results @@ -475,6 +479,7 @@ class River(Graph): LateralContributionsAdisTSList, D90AdisTSList, DIFAdisTSList, + InitialConditionsTemperatureList, GeoTIFFList, Results ] @@ -512,6 +517,8 @@ 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._geotiff = GeoTIFFList(status=self._status) @@ -627,6 +634,9 @@ class River(Graph): new._DIFAdisTS = DIFAdisTSList._db_load(execute, data) + new._InitialConditionsTemperature = \ + InitialConditionsTemperatureList._db_load(execute, data) + new._geotiff = GeoTIFFList._db_load(execute, data) return new @@ -661,6 +671,7 @@ class River(Graph): objs.append(self._LateralContributionsAdisTS) objs.append(self._D90AdisTS) objs.append(self._DIFAdisTS) + objs.append(self._InitialConditionsTemperature) objs.append(self._geotiff) @@ -740,6 +751,7 @@ class River(Graph): self._BoundaryConditionsAdisTS, self._LateralContributionsAdisTS, self._D90AdisTS, self._DIFAdisTS, + self._InitialConditionsTemperature, self._geotiff, ] @@ -873,6 +885,10 @@ Last export at: @date.""" def dif_adists(self): return self._DIFAdisTS + @property + def ic_temperature(self): + return self._InitialConditionsTemperature + def get_params(self, solver): if solver in self._parameters: return self._parameters[solver] diff --git a/src/Model/Study.py b/src/Model/Study.py index 2be1069c..dfa25d2d 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -46,7 +46,7 @@ logger = logging.getLogger() class Study(SQLModel): - _version = "0.2.6" + _version = "0.2.7" _sub_classes = [ Scenario, diff --git a/src/View/InitialConditionsTemperature/Table.py b/src/View/InitialConditionsTemperature/Table.py new file mode 100644 index 00000000..97bfaa2f --- /dev/null +++ b/src/View/InitialConditionsTemperature/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.InitialConditionsTemperature.UndoCommand import ( + SetCommand, AddCommand, SetCommandSpec, + DelCommand, +) + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class ComboBoxDelegate(QItemDelegate): + def __init__(self, data=None, ic_spec_lst=None, + trad=None, parent=None, mode="reaches"): + super(ComboBoxDelegate, self).__init__(parent) + + self._data = data + self._mode = mode + self._trad = trad + self._ic_spec_lst = ic_spec_lst + + def createEditor(self, parent, option, index): + self.editor = QComboBox(parent) + + val = [] + if self._mode == "rk": + reach_id = self._ic_spec_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 InitialConditionTableModel(PamhyrTableModel): + def __init__(self, river=None, data=None, **kwargs): + self._river = river + + super(InitialConditionTableModel, self).__init__(data=data, **kwargs) + + self._data = data + + def _setup_lst(self): + self._lst = list( + filter( + lambda ica: ica._deleted is False, + 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] == "temperature": + n = self._lst[row].temperature + 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 + + if self.is_same_data(index, value): + return False + + row = index.row() + column = index.column() + + try: + if self._headers[column] in ["name", "temperature"]: + self._undo.push( + SetCommand( + self._lst, row, self._headers[column], value + ) + ) + else: + self._undo.push( + SetCommandSpec( + self._lst, row, self._headers[column], + (self._river.edge(value).id + if self._headers[column] == "reach" + else value + ) + ) + ) + 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/InitialConditionsTemperature/TableDefault.py b/src/View/InitialConditionsTemperature/TableDefault.py new file mode 100644 index 00000000..45a27752 --- /dev/null +++ b/src/View/InitialConditionsTemperature/TableDefault.py @@ -0,0 +1,94 @@ +# 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.InitialConditionsTemperature.UndoCommand import ( + SetCommand, +) + +logger = logging.getLogger() + +_translate = QCoreApplication.translate + + +class InitialConditionTableDefaultModel(PamhyrTableModel): + def __init__(self, **kwargs): + super(InitialConditionTableDefaultModel, 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].name + elif self._headers[column] == "temperature": + n = self._data[row].temperature + if n is None: + return 0 + 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/InitialConditionsTemperature/UndoCommand.py b/src/View/InitialConditionsTemperature/UndoCommand.py new file mode 100644 index 00000000..fb1f257e --- /dev/null +++ b/src/View/InitialConditionsTemperature/UndoCommand.py @@ -0,0 +1,153 @@ +# 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.InitialConditionsTemperature.InitialConditionsTemperature \ + import InitialConditionsTemperature +from Model.InitialConditionsTemperature.InitialConditionsTemperatureList \ + import InitialConditionsTemperatureList + + +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 == "temperature": + self._old = self._data[self._row].temperature + + _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 == "temperature": + self._data[self._row].temperature = self._old + + def redo(self): + if self._column == "name": + self._data[self._row].name = self._new + elif self._column == "temperature": + self._data[self._row].temperature = 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 == "temperature": + self._old = self._data[self._row].temperature + + _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 == "temperature": + self._data[self._row].temperature = 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 == "temperature": + self._data[self._row].temperature = 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/InitialConditionsTemperature/Window.py b/src/View/InitialConditionsTemperature/Window.py new file mode 100644 index 00000000..d1002fb1 --- /dev/null +++ b/src/View/InitialConditionsTemperature/Window.py @@ -0,0 +1,312 @@ +# 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.InitialConditionsTemperature.UndoCommand import ( + SetCommand, +) + +from View.InitialConditionsTemperature.TableDefault import ( + InitialConditionTableDefaultModel, +) + +from View.InitialConditionsTemperature.Table import ( + InitialConditionTableModel, ComboBoxDelegate, +) + +from View.InitialConditionsTemperature.translate import IcTemperatureTranslate + +from Solver.Mage import Mage8 + +_translate = QCoreApplication.translate + +logger = logging.getLogger() + + +class InitialConditionsTemperatureWindow(PamhyrWindow): + _pamhyr_ui = "InitialConditionsTemperature" + _pamhyr_name = "Initial condition Temperature" + + def __init__(self, data=None, study=None, + config=None, parent=None): + self._data = [] + self._data.append(data) + trad = IcTemperatureTranslate() + + name = ( + trad[self._pamhyr_name] + + " - " + study.name + ) + + super(InitialConditionsTemperatureWindow, self).__init__( + title=name, + study=study, + config=config, + trad=trad, + parent=parent + ) + self._hash_data.append(data) + + # self._ics_Temperature_lst = study.river.ic_Temperature + + 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 = InitialConditionTableDefaultModel( + table_view=table_default, + table_headers=self._trad.get_dict("table_headers"), + editable_headers=editable_headers, + delegates={}, + data=self._data, + 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, + ic_spec_lst=self._data[0]._data, + parent=self, + mode="reaches" + ) + self._delegate_rk = ComboBoxDelegate( + trad=self._trad, + data=self._study.river, + ic_spec_lst=self._data[0]._data, + 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 = InitialConditionTableModel( + 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[0], + 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/InitialConditionsTemperature/translate.py b/src/View/InitialConditionsTemperature/translate.py new file mode 100644 index 00000000..370d0bb1 --- /dev/null +++ b/src/View/InitialConditionsTemperature/translate.py @@ -0,0 +1,46 @@ +# 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 IcTemperatureTranslate(MainTranslate): + def __init__(self): + super(IcTemperatureTranslate, self).__init__() + + self._dict["Initial condition Temperature"] = _translate( + "InitialConditionTemperature", "Initial condition temperature") + + self._dict["rk"] = self._dict["unit_rk"] + + self._sub_dict["table_headers"] = { + "name": self._dict["name"], + "temperature": self._dict["unit_temperature"], + } + + 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)"), + "temperature": self._dict["unit_temperature"], + } diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 70a70f5c..064f4df4 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -106,6 +106,10 @@ from View.Pollutants.Window import PollutantsWindow from View.D90AdisTS.Window import D90AdisTSWindow from View.DIFAdisTS.Window import DIFAdisTSWindow +from View.InitialConditionsTemperature.Window import ( + InitialConditionsTemperatureWindow +) + from Solver.Solvers import GenericSolver from Model.Results.Results import Results @@ -157,7 +161,10 @@ 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_edit_geotiff" + "action_menu_d90", "action_menu_dif", "action_menu_edit_geotiff", + "action_menu_boundary_conditions_temperature", + "action_menu_initial_conditions_temperature", + "action_menu_weather_parameters", ] action = ( @@ -267,6 +274,8 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): """ actions = { # Menu action + "action_menu_initial_conditions_temperature": + self.open_initial_conditions_temperature, "action_menu_dif": self.open_dif, "action_menu_d90": self.open_d90, "action_menu_pollutants": self.open_pollutants, @@ -1022,6 +1031,27 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # SUB WINDOWS # ############### + def open_initial_conditions_temperature(self): + + iclist = self._study.river.ic_temperature.lst + if len(iclist) != 0: + ic_default = iclist[0] + else: + ic_default = self._study.river.ic_temperature.new(0) + + if self.sub_window_exists( + InitialConditionsTemperatureWindow, + data=[self._study, None, ic_default] + ): + return + + InitialConditions = InitialConditionsTemperatureWindow( + study=self._study, + parent=self, + data=ic_default + ) + InitialConditions.show() + def open_d90(self): if len(self._study.river.d90_adists.lst) != 0: d90_default = self._study.river.d90_adists.lst[0] diff --git a/src/View/Translate.py b/src/View/Translate.py index c0872a74..5a480059 100644 --- a/src/View/Translate.py +++ b/src/View/Translate.py @@ -161,6 +161,8 @@ class UnitTranslate(CommonWordTranslate): self._dict["unit_mass"] = _translate("Unit", "Mass (kg)") self._dict["unit_concentration"] = _translate( "Unit", "Concentration") + " (kg/m³)" + self._dict["unit_temperature"] = _translate( + "Unit", "Temperature") + " (°C)" class MainTranslate(UnitTranslate): diff --git a/src/View/ui/InitialConditionsTemperature.ui b/src/View/ui/InitialConditionsTemperature.ui new file mode 100644 index 00000000..97321f4e --- /dev/null +++ b/src/View/ui/InitialConditionsTemperature.ui @@ -0,0 +1,118 @@ + + + MainWindow + + + + 0 + 0 + 849 + 576 + + + + MainWindow + + + + + + + Qt::Horizontal + + + + + + + + + + + Qt::Vertical + + + + + + + + + + + + + 0 + 0 + 849 + 20 + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + ressources/add.pngressources/add.png + + + Add + + + Add new initial condition + + + + + + ressources/del.pngressources/del.png + + + delete + + + Delete inital condition + + + + + + ressources/sort_1-9.pngressources/sort_1-9.png + + + sort + + + Sort inital conditions + + + + + + ressources/import.pngressources/import.png + + + Import + + + Import from file + + + + + + diff --git a/src/View/ui/MainWindow.ui b/src/View/ui/MainWindow.ui index 88f23272..5ea27ab1 100644 --- a/src/View/ui/MainWindow.ui +++ b/src/View/ui/MainWindow.ui @@ -232,11 +232,11 @@ Temperature - - + + - - + + @@ -822,12 +822,12 @@ GeoTIFF - + Initial conditions - + Boundary conditions @@ -837,12 +837,12 @@ Weather parameters - + DIF ? - + Lateral contributions ? diff --git a/src/lang/fr.ts b/src/lang/fr.ts index bd8a71bd..735ed188 100644 --- a/src/lang/fr.ts +++ b/src/lang/fr.ts @@ -1650,6 +1650,14 @@ Some profiles don't contain any point. Conditions initiales AdisTS + + InitialConditionTemperature + + + Initial condition temperature + + + LateralContribution @@ -1884,7 +1892,7 @@ Some profiles don't contain any point. &Fenêtres - + New study Nouvelle étude @@ -1894,82 +1902,82 @@ Some profiles don't contain any point. Ctrl+N - + Open a study Ouvrir une étude - + Ctrl+O Ctrl+O - + Close Fermer - + Close current study Fermer l'étude en cours - + Save Sauvegarder - + Save study Sauvegarder l'étude - + Ctrl+S Ctrl+S - + Save as ... Sauvegarder sous ... - + Save study as ... Sauvegarder l'étude sous ... - + Ctrl+Shift+S Ctrl+Shift+S - + Pamhyr2 configuration Configuration de Pamhyr2 - + Quit Quitter - + Quit application Quitter l'application - + Ctrl+F4 Ctrl+F4 - + Edit river network Éditer le réseau - + Edit geometry Éditer la géométrie @@ -1984,37 +1992,37 @@ Some profiles don't contain any point. Exporter la géométrie - + Numerical parameters of solvers Paramètres numériques des solveurs - + Boundary conditions and point sources Conditions aux limites et apports ponctuels - + Initial conditions Conditions initiales - + Edit friction Éditer les frottements - + Edit lateral sources Éditer les contributions latérales - + Run solver Lancer le solveur - + F5 F5 @@ -2024,137 +2032,137 @@ Some profiles don't contain any point. Ouvrir - + Visualize last results Visualisation des derniers résultats - + Visualize the last results Visualisation des derniers résultats - + About À propos - + Save current study Sauvegarder l'étude - + Save the study (Ctrl+S) Sauvegarde de l'étude (Ctrl+S) - + Close the study (Ctrl+F) Fermeture de l'étude (Ctrl+F) - + Ctrl+F Ctrl+F - + Run a solver Lancer un solveur - + River network Réseau - + Geometry Géometrie - + Edit reach geometry Éditer la géométrie du bief actuel - + Boundary conditions Conditions aux limites - + Edit boundary conditions and point sources Éditer les conditions aux limites et les apports ponctuels - + Lateral sources Contributions latérales - + Friction Frottements - + Edit study Éditer l'étude - + Define initial conditions Définir les conditions initiales - + Sediment layers Couches sédimentaires - + Define sediment layers Définition des couches sédimentaires - + Edit reach sediment layers Éditer les couches sédimentaires - + Mage Mage - + Open Mage documentation Ouvrir la documentation de Mage - + Users (wiki) Utilisateurs (wiki) - + Developers (pdf) Développeurs (pdf) - + Developers (html) Développeurs (html) - + Reservoirs Casiers - + Edit reservoirs Éditer les casiers @@ -2164,12 +2172,12 @@ Some profiles don't contain any point. Ouvrages hydrauliques - + Edit hydraulic structures Éditer les ouvrages hydrauliques - + Open results from file Ouvrir des résultats depuis un fichier @@ -2504,22 +2512,22 @@ Some profiles don't contain any point. Éditer les couches sédimentaires du profil - + Add new initial condition Ajouter une nouvelle condition initiale - + Delete inital condition Supprimer une condition initiale - + sort sort - + Sort inital conditions Trier les conditions initiales @@ -2614,12 +2622,12 @@ Some profiles don't contain any point. &Avancé - + &Additional files Fichiers &supplémentaires - + REP additional lines Lignes REP supplémentaires @@ -2694,7 +2702,7 @@ Some profiles don't contain any point. Éditer fichier - + Edit the study information Éditer les informations de l'étude @@ -2704,12 +2712,12 @@ Some profiles don't contain any point. toolBar - + toolBar_2 toolBar_2 - + Edit frictions Éditer les frottements @@ -2734,7 +2742,7 @@ Some profiles don't contain any point. Retourner l'ordre des points - + Import from file Importer depuis un fichier @@ -2899,32 +2907,32 @@ Some profiles don't contain any point. AdisTS - + Output RK Pk de sortie - + Run AdisTS Lancer AdisTS - + Pollutants Polluants - + D90 D90 - + DIF DIF - + Open results AdisTS Ouvrir des résultats AdisTS @@ -3049,7 +3057,7 @@ Some profiles don't contain any point. Non - + Compare results Comparaison de résultats @@ -3204,12 +3212,12 @@ Some profiles don't contain any point. Scénarios - + Edit scenarios tree Editer l'arbre des scénarios - + GeoTIFF GeoTIFF @@ -3248,6 +3256,26 @@ Some profiles don't contain any point. total_sediment.bin file not found Fichier total_sediment.bin non trouvé + + + Temperature + + + + + Weather parameters + + + + + DIF ? + + + + + Lateral contributions ? + + MainWindow_reach