From 516efb64e85813c74e58085161d736c82c670c91 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Tue, 28 Jul 2026 13:50:21 +0200 Subject: [PATCH 01/12] WeatherParam: SetData targets the object, and no longer the line index in the tab, which reduces the risk of error with multiple QUndoCommand, and rewrite of the logic for the other UndoCommand --- .../WeatherParameters/WeatherParameters.py | 51 ++++++++---- src/View/Geometry/Window.py | 13 +++ src/View/WeatherParameters/Edit/Table.py | 23 ++---- .../WeatherParameters/Edit/UndoCommand.py | 80 +++++++++---------- 4 files changed, 91 insertions(+), 76 deletions(-) diff --git a/src/Model/WeatherParameters/WeatherParameters.py b/src/Model/WeatherParameters/WeatherParameters.py index b3637636..eb3a2068 100644 --- a/src/Model/WeatherParameters/WeatherParameters.py +++ b/src/Model/WeatherParameters/WeatherParameters.py @@ -636,14 +636,38 @@ class WeatherParameters(SQLSubModel): def add(self, index: int): value = Data(self._default_0, self._default_1, status=self._status) - self._data.insert(index, value) - self.modified() + self.insert(index, value) return value def insert(self, index: int, value): + visible_data = self.data + if index < len(visible_data): + index = self._data.index(visible_data[index]) + else: + index = len(self._data) self._data.insert(index, value) self.modified() + def set_data_value(self, value, column, data): + value[column] = self._types[column](data) + self.modified() + + def set_deleted(self, values, deleted): + for value in values: + if deleted: + value.set_as_deleted() + else: + value.set_as_not_deleted() + self.modified() + + def reorder(self, values): + values = iter(values) + self._data = [ + value if value.is_deleted() else next(values) + for value in self._data + ] + self.modified() + def delete_i(self, indexes): self._data = list( map( @@ -692,10 +716,7 @@ class WeatherParameters(SQLSubModel): return lst def _set_i_c_v(self, index, column, value): - v = self._data[index] - v[column] = self._types[column](value) - self._data[index] = v - self.modified() + self.set_data_value(self.get_i(index), column, value) def set_i_0(self, index: int, value): self._set_i_c_v(index, 0, value) @@ -726,15 +747,13 @@ class WeatherParameters(SQLSubModel): return new def move_up(self, index): - if index < len(self): - next = index - 1 - d = self._data - d[index], d[next] = d[next], d[index] - self.modified() + if 0 < index < len(self): + data = self.data + data[index - 1], data[index] = data[index], data[index - 1] + self.reorder(data) def move_down(self, index): - if index >= 0: - prev = index + 1 - d = self._data - d[index], d[prev] = d[prev], d[index] - self.modified() + if 0 <= index < len(self) - 1: + data = self.data + data[index], data[index + 1] = data[index + 1], data[index] + self.reorder(data) diff --git a/src/View/Geometry/Window.py b/src/View/Geometry/Window.py index 0827a094..645a7c07 100644 --- a/src/View/Geometry/Window.py +++ b/src/View/Geometry/Window.py @@ -102,6 +102,7 @@ class GeometryWindow(PamhyrWindow): self.setup_plots() self.setup_statusbar() self.setup_connections() + self.update_meshing_action() def setup_table(self): if self._study.is_read_only(): @@ -231,7 +232,16 @@ class GeometryWindow(PamhyrWindow): def update_redraw(self): self._update(redraw=True) + def update_meshing_action(self): + enabled = ( + not self._study.is_read_only() + and self._reach.number_profiles > 0 + ) + self.find(QAction, "action_meshing").setEnabled(enabled) + def _update(self, redraw=False, propagate=True): + self.update_meshing_action() + if redraw: self._plot_xy.redraw(data=self._reach) self._plot_rkc.redraw(data=self._reach) @@ -302,6 +312,9 @@ class GeometryWindow(PamhyrWindow): self.tableView.model().blockSignals(False) def edit_meshing(self): + if self._reach.number_profiles == 0: + return + rows = list( set( (i.row() for i in self.tableView.selectedIndexes()) diff --git a/src/View/WeatherParameters/Edit/Table.py b/src/View/WeatherParameters/Edit/Table.py index 996ef4b7..a77b0e2d 100644 --- a/src/View/WeatherParameters/Edit/Table.py +++ b/src/View/WeatherParameters/Edit/Table.py @@ -145,35 +145,24 @@ class TableModel(PamhyrTableModel): if row <= 0: return - target = row + 2 - - self.beginMoveRows(parent, row - 1, row - 1, parent, target) - - self._undo_stack.push( + self.layoutAboutToBeChanged.emit() + self._undo.push( MoveCommand( self._data, "up", row ) ) - - self.endMoveRows() self.update() - def move_down(self, index, parent=QModelIndex()): - row = index.row() - if row >= len(self._data): + def move_down(self, row, parent=QModelIndex()): + if row < 0 or row >= len(self._data) - 1: return - target = row - - self.beginMoveRows(parent, row + 1, row + 1, parent, target) - - self._undo_stack.push( + self.layoutAboutToBeChanged.emit() + self._undo.push( MoveCommand( self._data, "down", row ) ) - - self.endMoveRows() self.update() def paste(self, row, header, data): diff --git a/src/View/WeatherParameters/Edit/UndoCommand.py b/src/View/WeatherParameters/Edit/UndoCommand.py index f18eab7f..a8164fac 100644 --- a/src/View/WeatherParameters/Edit/UndoCommand.py +++ b/src/View/WeatherParameters/Edit/UndoCommand.py @@ -31,17 +31,17 @@ class SetDataCommand(QUndoCommand): QUndoCommand.__init__(self) self._data = data - self._index = index + self._value = self._data.get_i(index) self._column = column - self._old = self._data.get_i(self._index)[self._column] + self._old = self._value[self._column] _type = self._data.get_type_column(self._column) self._new = _type(new_value) def undo(self): - self._data._set_i_c_v(self._index, self._column, self._old) + self._data.set_data_value(self._value, self._column, self._old) def redo(self): - self._data._set_i_c_v(self._index, self._column, self._new) + self._data.set_data_value(self._value, self._column, self._new) class AddCommand(QUndoCommand): @@ -53,13 +53,13 @@ class AddCommand(QUndoCommand): self._new = None def undo(self): - self._data.delete_i([self._index]) + self._data.set_deleted([self._new], True) def redo(self): if self._new is None: self._new = self._data.add(self._index) else: - self._data.insert(self._index, self._new) + self._data.set_deleted([self._new], False) class DelCommand(QUndoCommand): @@ -75,10 +75,10 @@ class DelCommand(QUndoCommand): self._wp.sort() def undo(self): - self._data.set_as_not_deleted_i(self._rows) + self._data.set_deleted([wp for row, wp in self._wp], False) def redo(self): - self._data.set_as_deleted_i(self._rows) + self._data.set_deleted([wp for row, wp in self._wp], True) class SortCommand(QUndoCommand): @@ -89,27 +89,17 @@ class SortCommand(QUndoCommand): self._reverse = _reverse self._old = self._data.data - self._indexes = None + self._new = sorted( + self._old, + key=lambda value: value[0], + reverse=self._reverse + ) def undo(self): - ll = self._data.data - self._data.sort( - key=lambda x: self._indexes[ll.index(x)] - ) + self._data.reorder(self._old) def redo(self): - self._data.sort( - _reverse=self._reverse, - key=lambda x: x[0] - ) - if self._indexes is None: - self._indexes = list( - map( - lambda p: self._old.index(p), - self._data.data - ) - ) - self._old = None + self._data.reorder(self._new) class MoveCommand(QUndoCommand): @@ -117,20 +107,16 @@ class MoveCommand(QUndoCommand): QUndoCommand.__init__(self) self._data = data - self._up = up == "up" - self._i = i + self._old = self._data.data + self._new = self._old.copy() + other = i - 1 if up == "up" else i + 1 + self._new[i], self._new[other] = self._new[other], self._new[i] def undo(self): - if self._up: - self._data.move_up(self._i) - else: - self._data.move_down(self._i) + self._data.reorder(self._old) def redo(self): - if self._up: - self._data.move_up(self._i) - else: - self._data.move_down(self._i) + self._data.reorder(self._new) class PasteCommand(QUndoCommand): @@ -141,15 +127,18 @@ class PasteCommand(QUndoCommand): self._row = row self._wps = list(wps) self._wps.reverse() + self._inserted = False def undo(self): - self._data.delete_i( - range(self._row, self._row + len(self._wps)) - ) + self._data.set_deleted(self._wps, True) def redo(self): - for wp in self._wps: - self._data.insert(self._row, wp) + if not self._inserted: + for wp in self._wps: + self._data.insert(self._row, wp) + self._inserted = True + else: + self._data.set_deleted(self._wps, False) class DuplicateCommand(QUndoCommand): @@ -160,10 +149,15 @@ class DuplicateCommand(QUndoCommand): self._rows = rows self._wp = deepcopy(bc) self._wp.reverse() + self._inserted = False def undo(self): - self._data.delete(self._wp) + self._data.set_deleted(self._wp, True) def redo(self): - for bc in self._wp: - self._data.insert(self._rows[0], bc) + if not self._inserted: + for bc in self._wp: + self._data.insert(self._rows[0], bc) + self._inserted = True + else: + self._data.set_deleted(self._wp, False) From afe0217655381594f0c7cda2a79e15a42711b5c6 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Tue, 28 Jul 2026 15:53:34 +0200 Subject: [PATCH 02/12] PEP8 --- src/View/RunSolver/Window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/RunSolver/Window.py b/src/View/RunSolver/Window.py index fe4b2b3f..48654128 100644 --- a/src/View/RunSolver/Window.py +++ b/src/View/RunSolver/Window.py @@ -83,7 +83,7 @@ class SelectSolverWindow(PamhyrDialog): # solvers mage solvers = list(filter( lambda x: "adists" not in x._type - and "adistt" not in x._type, + and "adistt" not in x._type, self._config.solvers )) solvers_name = list( From 252ac257fe3b7430c814a42cf696883b11a9e129 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Tue, 28 Jul 2026 17:33:56 +0200 Subject: [PATCH 03/12] AdisTS AdisTT: fix initial conditions in scenario mode, now correctly saved in database, and load correctly, not creating duplicata when two datas have same ID with different scenario_id --- .../InitialConditionsAdisTSSpec.py | 31 ++++----- .../InitialConditionsTemperatureSpec.py | 21 +++--- src/View/InitialConditionsAdisTS/Table.py | 5 +- .../InitialConditionsAdisTS/UndoCommand.py | 64 +++++-------------- .../InitialConditionsTemperature/Table.py | 23 +++---- .../UndoCommand.py | 36 ++++++----- 6 files changed, 73 insertions(+), 107 deletions(-) diff --git a/src/Model/InitialConditionsAdisTS/InitialConditionsAdisTSSpec.py b/src/Model/InitialConditionsAdisTS/InitialConditionsAdisTSSpec.py index 8cd963d1..2c68ad78 100644 --- a/src/Model/InitialConditionsAdisTS/InitialConditionsAdisTSSpec.py +++ b/src/Model/InitialConditionsAdisTS/InitialConditionsAdisTSSpec.py @@ -31,10 +31,11 @@ class ICAdisTSSpec(SQLSubModel): _sub_classes = [] def __init__(self, id: int = -1, name: str = "", - status=None, owner_scenario=None): - super(ICAdisTSSpec, self).__init__() - - self._status = status + status=None, owner_scenario=-1): + super(ICAdisTSSpec, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) self._name_section = name self._reach = None @@ -190,7 +191,7 @@ class ICAdisTSSpec(SQLSubModel): new_spec.rate = rate new_spec.enabled = enabled - # loaded.add(pid) + loaded.add(id) new.append(new_spec) data["scenario"] = scenario.parent @@ -234,7 +235,7 @@ class ICAdisTSSpec(SQLSubModel): @name.setter def name(self, name): self._name_section = name - self._status.modified() + self.modified() @property def reach(self): @@ -243,7 +244,7 @@ class ICAdisTSSpec(SQLSubModel): @reach.setter def reach(self, reach): self._reach = reach - self._status.modified() + self.modified() @property def start_rk(self): @@ -252,7 +253,7 @@ class ICAdisTSSpec(SQLSubModel): @start_rk.setter def start_rk(self, start_rk): self._start_rk = start_rk - self._status.modified() + self.modified() @property def end_rk(self): @@ -261,7 +262,7 @@ class ICAdisTSSpec(SQLSubModel): @end_rk.setter def end_rk(self, end_rk): self._end_rk = end_rk - self._status.modified() + self.modified() @property def concentration(self): @@ -270,7 +271,7 @@ class ICAdisTSSpec(SQLSubModel): @concentration.setter def concentration(self, concentration): self._concentration = concentration - self._status.modified() + self.modified() @property def eg(self): @@ -279,7 +280,7 @@ class ICAdisTSSpec(SQLSubModel): @eg.setter def eg(self, eg): self._eg = eg - self._status.modified() + self.modified() @property def em(self): @@ -288,7 +289,7 @@ class ICAdisTSSpec(SQLSubModel): @em.setter def em(self, em): self._em = em - self._status.modified() + self.modified() @property def ed(self): @@ -297,7 +298,7 @@ class ICAdisTSSpec(SQLSubModel): @ed.setter def ed(self, ed): self._ed = ed - self._status.modified() + self.modified() @property def rate(self): @@ -306,7 +307,7 @@ class ICAdisTSSpec(SQLSubModel): @rate.setter def rate(self, rate): self._rate = rate - self._status.modified() + self.modified() @property def enabled(self): @@ -315,4 +316,4 @@ class ICAdisTSSpec(SQLSubModel): @enabled.setter def enabled(self, enabled): self._enabled = enabled - self._status.modified() + self.modified() diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py index 3682b275..cb46ce35 100644 --- a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py @@ -31,10 +31,11 @@ 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 + status=None, owner_scenario=-1): + super(ICTemperatureSpec, self).__init__( + id=id, status=status, + owner_scenario=owner_scenario + ) self._name_section = name self._reach = None @@ -122,7 +123,7 @@ class ICTemperatureSpec(SQLSubModel): new_spec.end_rk = end_rk new_spec.temperature = temperature - # loaded.add(pid) + loaded.add(id) new.append(new_spec) data["scenario"] = scenario.parent @@ -163,7 +164,7 @@ class ICTemperatureSpec(SQLSubModel): @name.setter def name(self, name): self._name_section = name - self._status.modified() + self.modified() @property def reach(self): @@ -172,7 +173,7 @@ class ICTemperatureSpec(SQLSubModel): @reach.setter def reach(self, reach): self._reach = reach - self._status.modified() + self.modified() @property def start_rk(self): @@ -181,7 +182,7 @@ class ICTemperatureSpec(SQLSubModel): @start_rk.setter def start_rk(self, start_rk): self._start_rk = start_rk - self._status.modified() + self.modified() @property def end_rk(self): @@ -190,7 +191,7 @@ class ICTemperatureSpec(SQLSubModel): @end_rk.setter def end_rk(self, end_rk): self._end_rk = end_rk - self._status.modified() + self.modified() @property def temperature(self): @@ -199,4 +200,4 @@ class ICTemperatureSpec(SQLSubModel): @temperature.setter def temperature(self, temperature): self._temperature = temperature - self._status.modified() + self.modified() diff --git a/src/View/InitialConditionsAdisTS/Table.py b/src/View/InitialConditionsAdisTS/Table.py index 70316e20..6594cef9 100644 --- a/src/View/InitialConditionsAdisTS/Table.py +++ b/src/View/InitialConditionsAdisTS/Table.py @@ -198,13 +198,14 @@ class InitialConditionTableModel(PamhyrTableModel): if self._headers[column] != "reach": self._undo.push( SetCommandSpec( - self._lst, row, self._headers[column], value + self._data, self._lst, row, + self._headers[column], value ) ) elif self._headers[column] == "reach": self._undo.push( SetCommandSpec( - self._lst, row, self._headers[column], + self._data, self._lst, row, self._headers[column], self._river.edge(value).id ) ) diff --git a/src/View/InitialConditionsAdisTS/UndoCommand.py b/src/View/InitialConditionsAdisTS/UndoCommand.py index d5defc0a..af17232e 100644 --- a/src/View/InitialConditionsAdisTS/UndoCommand.py +++ b/src/View/InitialConditionsAdisTS/UndoCommand.py @@ -80,31 +80,31 @@ class SetCommand(QUndoCommand): class SetCommandSpec(QUndoCommand): - def __init__(self, data, row, column, new_value): + def __init__(self, parent, data, row, column, new_value): QUndoCommand.__init__(self) - self._data = data - self._row = row + self._parent = parent + self._value = data[row] self._column = column if self._column == "name": - self._old = self._data[self._row].name + self._old = self._value.name elif self._column == "reach": - self._old = self._data[self._row].reach + self._old = self._value.reach elif self._column == "start_rk": - self._old = self._data[self._row].start_rk + self._old = self._value.start_rk elif self._column == "end_rk": - self._old = self._data[self._row].end_rk + self._old = self._value.end_rk elif self._column == "concentration": - self._old = self._data[self._row].concentration + self._old = self._value.concentration elif self._column == "eg": - self._old = self._data[self._row].eg + self._old = self._value.eg elif self._column == "em": - self._old = self._data[self._row].em + self._old = self._value.em elif self._column == "ed": - self._old = self._data[self._row].ed + self._old = self._value.ed elif self._column == "rate": - self._old = self._data[self._row].rate + self._old = self._value.rate _type = float if column == "name": @@ -115,44 +115,12 @@ class SetCommandSpec(QUndoCommand): 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 + setattr(self._value, self._column, self._old) + self._parent.modified() 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 + setattr(self._value, self._column, self._new) + self._parent.modified() class AddCommand(QUndoCommand): diff --git a/src/View/InitialConditionsTemperature/Table.py b/src/View/InitialConditionsTemperature/Table.py index 037d0d27..8fda2b25 100644 --- a/src/View/InitialConditionsTemperature/Table.py +++ b/src/View/InitialConditionsTemperature/Table.py @@ -36,7 +36,7 @@ from PyQt5.QtWidgets import ( from View.Tools.PamhyrTable import PamhyrTableModel from View.InitialConditionsTemperature.UndoCommand import ( - SetCommand, AddCommand, SetCommandSpec, + AddCommand, SetCommandSpec, DelCommand, ) @@ -214,22 +214,15 @@ class InitialConditionTableModel(PamhyrTableModel): 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 - ) + self._undo.push( + SetCommandSpec( + self._data, 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()) diff --git a/src/View/InitialConditionsTemperature/UndoCommand.py b/src/View/InitialConditionsTemperature/UndoCommand.py index 83f93265..92b0f244 100644 --- a/src/View/InitialConditionsTemperature/UndoCommand.py +++ b/src/View/InitialConditionsTemperature/UndoCommand.py @@ -62,21 +62,21 @@ class SetCommand(QUndoCommand): class SetCommandSpec(QUndoCommand): - def __init__(self, data, row, column, new_value): + def __init__(self, parent, data, row, column, new_value): QUndoCommand.__init__(self) - self._data = data - self._row = row + self._parent = parent + self._value = data[row] self._column = column if self._column == "name": - self._old = self._data[self._row].name + self._old = self._value.name elif self._column == "reach": - self._old = self._data[self._row].reach + self._old = self._value.reach elif self._column == "rk": - self._old = self._data[self._row].start_rk + self._old = self._value.start_rk elif self._column == "temperature": - self._old = self._data[self._row].temperature + self._old = self._value.temperature _type = float if column == "name": @@ -88,25 +88,27 @@ class SetCommandSpec(QUndoCommand): def undo(self): if self._column == "name": - self._data[self._row].name = self._old + self._value.name = self._old elif self._column == "reach": - self._data[self._row].reach = self._old + self._value.reach = self._old elif self._column == "rk": - self._data[self._row].start_rk = self._old - self._data[self._row].end_rk = self._old + self._value.start_rk = self._old + self._value.end_rk = self._old elif self._column == "temperature": - self._data[self._row].temperature = self._old + self._value.temperature = self._old + self._parent.modified() def redo(self): if self._column == "name": - self._data[self._row].name = self._new + self._value.name = self._new elif self._column == "reach": - self._data[self._row].reach = self._new + self._value.reach = self._new elif self._column == "rk": - self._data[self._row].start_rk = self._new - self._data[self._row].end_rk = self._new + self._value.start_rk = self._new + self._value.end_rk = self._new elif self._column == "temperature": - self._data[self._row].temperature = self._new + self._value.temperature = self._new + self._parent.modified() class AddCommand(QUndoCommand): From c0000bb3321612334e8c2eee1dee8c03fa752fac Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Tue, 11 Aug 2026 11:47:34 +0200 Subject: [PATCH 04/12] Database: upgrade version for temperature data --- .../BoundaryConditionTemperature.py | 4 ++-- .../InitialConditionsTemperature.py | 2 +- .../InitialConditionsTemperatureSpec.py | 2 +- src/Model/Study.py | 2 +- src/Model/WeatherParameters/WeatherParameters.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Model/BoundaryConditionsTemperature/BoundaryConditionTemperature.py b/src/Model/BoundaryConditionsTemperature/BoundaryConditionTemperature.py index c5b773f4..31b0d7f2 100644 --- a/src/Model/BoundaryConditionsTemperature/BoundaryConditionTemperature.py +++ b/src/Model/BoundaryConditionsTemperature/BoundaryConditionTemperature.py @@ -77,7 +77,7 @@ class Data(SQLSubModel): created = True if major == "0" and (int(minor) < 2 or - (int(minor) == 2 and int(release) < 7)): + (int(minor) == 2 and int(release) <= 7)): if not cls.is_table_exists(execute, "boundary_condition_data_temperature"): cls._db_create(execute) @@ -205,7 +205,7 @@ class BoundaryConditionTemperature(SQLSubModel): created = False if major == "0" and (int(minor) < 2 or ( - int(minor) == 2 and int(release) < 7)): + int(minor) == 2 and int(release) <= 7)): if not cls.is_table_exists(execute, "boundary_condition_temperature"): cls._db_create(execute) diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py index 350970c6..317ab33c 100644 --- a/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperature.py @@ -68,7 +68,7 @@ class InitialConditionsTemperature(SQLSubModel): major, minor, release = version.strip().split(".") if major == "0": - if int(minor) < 2 or (int(minor) == 2 and int(release) < 7): + 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) diff --git a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py index cb46ce35..1c3029f5 100644 --- a/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py +++ b/src/Model/InitialConditionsTemperature/InitialConditionsTemperatureSpec.py @@ -70,7 +70,7 @@ class ICTemperatureSpec(SQLSubModel): major, minor, release = version.strip().split(".") if major == "0": - if int(minor) < 2 or (int(minor) == 2 and int(release) < 7): + 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) diff --git a/src/Model/Study.py b/src/Model/Study.py index ded212ba..8b80efb8 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -46,7 +46,7 @@ logger = logging.getLogger() class Study(SQLModel): - _version = "0.2.7" + _version = "0.2.8" _sub_classes = [ Scenario, diff --git a/src/Model/WeatherParameters/WeatherParameters.py b/src/Model/WeatherParameters/WeatherParameters.py index eb3a2068..4bb3b497 100644 --- a/src/Model/WeatherParameters/WeatherParameters.py +++ b/src/Model/WeatherParameters/WeatherParameters.py @@ -73,7 +73,7 @@ class Data(SQLSubModel): major, minor, release = version.strip().split(".") if major == "0" and (int(minor) < 2 or - (int(minor) == 2 and int(release) < 7)): + (int(minor) == 2 and int(release) <= 7)): if not cls.is_table_exists(execute, "air_temperature_data"): cls._db_create(execute) @@ -334,7 +334,7 @@ class WeatherParameters(SQLSubModel): created = False if major == "0" and (int(minor) < 2 or - (int(minor) == 2 and int(release) < 7)): + (int(minor) == 2 and int(release) <= 7)): if not cls.is_table_exists(execute, "air_temperature"): cls._db_create(execute) created = True From 9309aa9d2d0e615527b69241a0a3676a3b1c96f2 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Wed, 12 Aug 2026 14:03:17 +0200 Subject: [PATCH 05/12] Overlapping intervals: we cannot select rk that is already selected, to avoid the confusion of multiple definitions over the same interval --- .../InitialConditionsTemperature/Table.py | 44 ++++++++++++++++--- .../InitialConditionsTemperature/translate.py | 10 +++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/View/InitialConditionsTemperature/Table.py b/src/View/InitialConditionsTemperature/Table.py index 8fda2b25..bbe38581 100644 --- a/src/View/InitialConditionsTemperature/Table.py +++ b/src/View/InitialConditionsTemperature/Table.py @@ -30,7 +30,7 @@ from PyQt5.QtWidgets import ( QDialogButtonBox, QPushButton, QLineEdit, QFileDialog, QTableView, QAbstractItemView, QUndoStack, QShortcut, QAction, QItemDelegate, - QComboBox, + QComboBox, QMessageBox, ) from View.Tools.PamhyrTable import PamhyrTableModel @@ -212,15 +212,28 @@ class InitialConditionTableModel(PamhyrTableModel): row = index.row() column = index.column() + column_name = self._headers[column] try: + new_value = ( + self._river.edge(value).id + if column_name == "reach" + else value + ) + if (column_name in ("reach", "rk") + and self._overlaps_existing_rk( + row, column_name, new_value + )): + QMessageBox.warning( + self._table_view, + self._trad["msg_rk_overlap_title"], + self._trad["msg_rk_overlap_text"] + ) + return False + self._undo.push( SetCommandSpec( - self._data, self._lst, row, self._headers[column], - (self._river.edge(value).id - if self._headers[column] == "reach" - else value - ) + self._data, self._lst, row, column_name, new_value ) ) except Exception as e: @@ -230,6 +243,25 @@ class InitialConditionTableModel(PamhyrTableModel): self.dataChanged.emit(index, index) return True + def _overlaps_existing_rk(self, row, column, value): + current = self._lst[row] + reach = value if column == "reach" else current.reach + rk = value if column == "rk" else current.start_rk + if reach in (None, -1) or rk is None: + return False + + rk = float(rk) + return any( + other is not current + and not other.is_deleted() + and other.reach == reach + and other.start_rk is not None + and other.end_rk is not None + and min(other.start_rk, other.end_rk) <= rk + <= max(other.start_rk, other.end_rk) + for other in self._data._data + ) + def add(self, row, parent=QModelIndex()): self.beginInsertRows(parent, row, row - 1) diff --git a/src/View/InitialConditionsTemperature/translate.py b/src/View/InitialConditionsTemperature/translate.py index d8517008..b856b7cd 100644 --- a/src/View/InitialConditionsTemperature/translate.py +++ b/src/View/InitialConditionsTemperature/translate.py @@ -32,6 +32,16 @@ class IcTemperatureTranslate(MainTranslate): self._dict["rk"] = self._dict["unit_rk"] + self._dict["msg_rk_overlap_title"] = _translate( + "InitialConditionTemperature", + "Chainage already used" + ) + self._dict["msg_rk_overlap_text"] = _translate( + "InitialConditionTemperature", + "An initial temperature condition is already defined at this " + "chainage on the selected reach." + ) + self._sub_dict["table_headers"] = { "name": self._dict["name"], "temperature": self._dict["unit_temperature"], From 50fa4f7bd830fd6c5dfa9b7bd8722ec9ebd5a5ce Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Wed, 12 Aug 2026 14:21:50 +0200 Subject: [PATCH 06/12] Overlapping intervals: we cannot select rk if the interval overlaps anohter one, to avoid the confusion of multiple definitions over the same interval for WeatherParameters --- src/View/WeatherParameters/Table.py | 59 ++++++++++++++++++++++++- src/View/WeatherParameters/translate.py | 8 ++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/View/WeatherParameters/Table.py b/src/View/WeatherParameters/Table.py index 7ce653d8..ef0b16af 100644 --- a/src/View/WeatherParameters/Table.py +++ b/src/View/WeatherParameters/Table.py @@ -30,7 +30,7 @@ from PyQt5.QtWidgets import ( QDialogButtonBox, QPushButton, QLineEdit, QFileDialog, QTableView, QAbstractItemView, QUndoStack, QShortcut, QAction, QItemDelegate, - QComboBox, + QComboBox, QMessageBox, ) from View.Tools.PamhyrTable import PamhyrTableModel @@ -222,6 +222,10 @@ class WeatherParametersTableModel(PamhyrTableModel): p for p in _edge.reach.profiles if p.pamhyr_id == value ) + if self._overlaps_existing_interval( + row, begin_section=_begin_rk): + self._show_overlap_warning() + return False self._undo.push( SetBeginCommand( self._data, global_row, _begin_rk @@ -233,15 +237,24 @@ class WeatherParametersTableModel(PamhyrTableModel): p for p in _edge.reach.profiles if p.pamhyr_id == value ) + if self._overlaps_existing_interval( + row, end_section=_end_rk): + self._show_overlap_warning() + return False self._undo.push( SetEndCommand( self._data, global_row, _end_rk ) ) elif self._headers[column] == "reach": + new_reach = self._river.edge(value) + if self._overlaps_existing_interval( + row, reach=new_reach): + self._show_overlap_warning() + return False self._undo.push( SetEdgeCommand( - self._data, global_row, self._river.edge(value) + self._data, global_row, new_reach ) ) except Exception as e: @@ -251,6 +264,48 @@ class WeatherParametersTableModel(PamhyrTableModel): self.dataChanged.emit(index, index) return True + def _overlaps_existing_interval(self, row, reach=None, + begin_section=None, end_section=None): + current = self._lst[row] + reach = current.reach if reach is None else reach + if reach is None: + return False + + if begin_section is None: + begin_section = ( + reach.reach.profiles[0] + if reach is not current.reach and reach.reach.profiles + else current.begin_section + ) + if end_section is None: + end_section = ( + reach.reach.profiles[-1] + if reach is not current.reach and reach.reach.profiles + else current.end_section + ) + if begin_section is None or end_section is None: + return False + + lower, upper = sorted((begin_section.rk, end_section.rk)) + return any( + other is not current + and not other.is_deleted() + and other.type == current.type + and other.reach is reach + and other.begin_section is not None + and other.end_section is not None + and max(lower, min(other.begin_rk, other.end_rk)) + < min(upper, max(other.begin_rk, other.end_rk)) + for other in self._data.lst + ) + + def _show_overlap_warning(self): + QMessageBox.warning( + self._table_view, + self._trad["msg_rk_overlap_title"], + self._trad["msg_rk_overlap_text"] + ) + def add(self, row, parent=QModelIndex()): self.beginInsertRows(parent, row, row) diff --git a/src/View/WeatherParameters/translate.py b/src/View/WeatherParameters/translate.py index 7f4fc107..0b8b3162 100644 --- a/src/View/WeatherParameters/translate.py +++ b/src/View/WeatherParameters/translate.py @@ -41,6 +41,14 @@ class WeatherParametersTranslate(MainTranslate): "These values are applied to sections that have not been " "defined using time series." ) + self._dict["msg_rk_overlap_title"] = _translate( + "WeatherParameters", "Overlapping interval" + ) + self._dict["msg_rk_overlap_text"] = _translate( + "WeatherParameters", + "Two weather-parameter intervals of the same type cannot " + "overlap on the same reach." + ) self._dict["rk"] = self._dict["unit_rk"] From 0fdb36231321d645f4fb283271bef938e83dd6c2 Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Wed, 12 Aug 2026 14:49:30 +0200 Subject: [PATCH 07/12] Ovelapping intervals: setting default values when creating a parameter --- src/View/WeatherParameters/Table.py | 11 ++++++++--- src/View/WeatherParameters/UndoCommand.py | 10 +++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/View/WeatherParameters/Table.py b/src/View/WeatherParameters/Table.py index ef0b16af..02828b35 100644 --- a/src/View/WeatherParameters/Table.py +++ b/src/View/WeatherParameters/Table.py @@ -248,13 +248,18 @@ class WeatherParametersTableModel(PamhyrTableModel): ) elif self._headers[column] == "reach": new_reach = self._river.edge(value) - if self._overlaps_existing_interval( - row, reach=new_reach): + overlaps = self._overlaps_existing_interval( + row, reach=new_reach + ) + current = self._lst[row] + clear_interval = overlaps and current.reach is None + if overlaps and not clear_interval: self._show_overlap_warning() return False self._undo.push( SetEdgeCommand( - self._data, global_row, new_reach + self._data, global_row, new_reach, + clear_interval=clear_interval ) ) except Exception as e: diff --git a/src/View/WeatherParameters/UndoCommand.py b/src/View/WeatherParameters/UndoCommand.py index 18727d99..1f5ed23a 100644 --- a/src/View/WeatherParameters/UndoCommand.py +++ b/src/View/WeatherParameters/UndoCommand.py @@ -120,19 +120,27 @@ class SetEndCommand(QUndoCommand): class SetEdgeCommand(QUndoCommand): - def __init__(self, wps, index, edge): + def __init__(self, wps, index, edge, clear_interval=False): QUndoCommand.__init__(self) self._wps = wps self._index = index self._old = self._wps.get(self._index).reach + self._old_begin = self._wps.get(self._index).begin_section + self._old_end = self._wps.get(self._index).end_section self._new = edge + self._clear_interval = clear_interval def undo(self): self._wps.get(self._index).reach = self._old + self._wps.get(self._index).begin_section = self._old_begin + self._wps.get(self._index).end_section = self._old_end def redo(self): self._wps.get(self._index).reach = self._new + if self._clear_interval: + self._wps.get(self._index).begin_section = None + self._wps.get(self._index).end_section = None class AddCommand(QUndoCommand): From a44dddb51b54082d1f49e45416c7861639fbeabc Mon Sep 17 00:00:00 2001 From: Dylan Jeannin Date: Thu, 13 Aug 2026 13:00:49 +0200 Subject: [PATCH 08/12] Temperature results: display of temperature representation along the reach, with a scale of color --- src/View/Results/PlotTemperature.py | 215 ++++++++++++++++++++++++++++ src/View/Results/WindowAdisTT.py | 34 +++++ src/View/Results/translate.py | 6 + 3 files changed, 255 insertions(+) create mode 100644 src/View/Results/PlotTemperature.py diff --git a/src/View/Results/PlotTemperature.py b/src/View/Results/PlotTemperature.py new file mode 100644 index 00000000..b9ef76e0 --- /dev/null +++ b/src/View/Results/PlotTemperature.py @@ -0,0 +1,215 @@ +# PlotTemperature.py -- Pamhyr +# Copyright (C) 2026 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. + +# -*- coding: utf-8 -*- + +import numpy as np + +from matplotlib.cm import ScalarMappable +from matplotlib.collections import PolyCollection +from matplotlib.colors import Normalize + +from View.Results.PlotXY import PlotXY + + +class PlotTemperature(PlotXY): + def __init__(self, canvas=None, trad=None, toolbar=None, + results=None, reach_id=0, profile_id=0, + pol_id=1, parent=None): + super(PlotTemperature, self).__init__( + canvas=canvas, + trad=trad, + toolbar=toolbar, + results=results, + reach_id=reach_id, + profile_id=profile_id, + res_id=[0], + parent=parent, + ) + + self._current_pol_id = pol_id + self._global_ranges = {} + self._temperature_zones = None + self._colorbar = None + self._auto_relim_update = False + self._autoscale_update = False + + @property + def results(self): + return self.data + + @results.setter + def results(self, results): + self.data = results + self._timestamps = sorted(results.get("timestamps")) + self._current_timestamp = self._timestamps[-1] + self._global_ranges.clear() + + def draw(self, highlight=None): + if self._colorbar is not None: + self._colorbar.remove() + self._colorbar = None + self.init_axes() + + reach = self.results.river.reach(self._current_reach_id) + if reach.geometry.number_profiles == 0: + self._init = False + return + + temperatures = self._temperatures(reach) + norm = self._temperature_norm() + + self.draw_profiles(reach, self.results.river.reachs) + self.draw_guide_lines(reach) + self._draw_temperature_zones(reach, temperatures, norm) + self.draw_current(reach) + + mappable = self._temperature_zones + if mappable is None: + mappable = ScalarMappable(norm=norm, cmap="coolwarm") + self._colorbar = self.canvas.figure.colorbar( + mappable, ax=self.canvas.axes + ) + self._colorbar.set_label(self._trad["unit_temperature"]) + # self.canvas.axes.set_title( + # f"{self._trad['temperature_map']} — {reach.name}" + # ) + self.canvas.axes.set_aspect("auto") + self._zoom_to_reach_bbox(reach) + self.canvas.draw_idle() + self.toolbar_update() + self._init = True + + def _draw_temperature_zones(self, reach, temperatures, norm): + profiles = reach.profiles + polygons = [] + for index in range(len(profiles) - 1): + current = profiles[index].geometry + following = profiles[index + 1].geometry + if current.number_points == 0 or following.number_points == 0: + continue + polygons.append([ + (current.x()[0], current.y()[0]), + (current.x()[-1], current.y()[-1]), + (following.x()[-1], following.y()[-1]), + (following.x()[0], following.y()[0]), + ]) + + if not polygons: + self._temperature_zones = None + return + + self._temperature_zones = PolyCollection( + polygons, + cmap="coolwarm", + norm=norm, + edgecolors="none", + alpha=0.8, + zorder=1, + ) + self._temperature_zones.set_array( + self._segment_temperatures(temperatures) + ) + self.canvas.axes.add_collection(self._temperature_zones) + + def update(self): + if not self._init or self._temperature_zones is None: + self.draw() + return + + reach = self.results.river.reach(self._current_reach_id) + self._temperature_zones.set_array( + self._segment_temperatures(self._temperatures(reach)) + ) + self.canvas.draw_idle() + + def set_reach(self, reach_id): + self._current_reach_id = reach_id + self._current_profile_id = 0 + self.draw() + + def set_profile(self, profile_id): + self._current_profile_id = profile_id + reach = self.results.river.reach(self._current_reach_id) + profile = reach.profile(profile_id) + self.plot_selected.set_data( + profile.geometry.x(), profile.geometry.y() + ) + self.canvas.draw_idle() + + def set_pollutant(self, pol_id): + self._current_pol_id = pol_id + self.draw() + + def set_timestamp(self, timestamp): + self._current_timestamp = timestamp + self.update() + + def _temperatures(self, reach): + return np.asarray([ + profile.get_ts_key(self._current_timestamp, "pols")[ + self._current_pol_id + ][0] + for profile in reach.profiles + ], dtype=float) + + @staticmethod + def _segment_temperatures(temperatures): + return (temperatures[:-1] + temperatures[1:]) / 2.0 + + def _temperature_norm(self): + minimum, maximum = self._global_temperature_range() + if minimum == maximum: + maximum = minimum + 1.0 + return Normalize(vmin=minimum, vmax=maximum) + + def _zoom_to_reach_bbox(self, reach): + x_values = [ + np.asarray(profile.geometry.x(), dtype=float) + for profile in reach.profiles + if len(profile.geometry.x()) != 0 + ] + y_values = [ + np.asarray(profile.geometry.y(), dtype=float) + for profile in reach.profiles + if len(profile.geometry.y()) != 0 + ] + if not x_values or not y_values: + return + x = np.concatenate(x_values) + y = np.concatenate(y_values) + + x_min, x_max = float(np.min(x)), float(np.max(x)) + y_min, y_max = float(np.min(y)), float(np.max(y)) + x_margin = max((x_max - x_min) * 0.05, 1.0) + y_margin = max((y_max - y_min) * 0.05, 1.0) + self.canvas.axes.set_xlim(x_min - x_margin, x_max + x_margin) + self.canvas.axes.set_ylim(y_min - y_margin, y_max + y_margin) + + def _global_temperature_range(self): + pol_id = self._current_pol_id + if pol_id in self._global_ranges: + return self._global_ranges[pol_id] + + temperatures = [] + for reach in self.results.river.reachs: + for profile in reach.profiles: + for timestamp in self._timestamps: + values = profile.get_ts_key(timestamp, "pols") + if values is not None: + temperatures.append(values[pol_id][0]) + + temperatures = np.asarray(temperatures, dtype=float) + temperatures = temperatures[np.isfinite(temperatures)] + value_range = ( + (float(np.min(temperatures)), float(np.max(temperatures))) + if temperatures.size + else (0.0, 1.0) + ) + self._global_ranges[pol_id] = value_range + return value_range diff --git a/src/View/Results/WindowAdisTT.py b/src/View/Results/WindowAdisTT.py index 7ef9a5c5..cbe7b1d4 100644 --- a/src/View/Results/WindowAdisTT.py +++ b/src/View/Results/WindowAdisTT.py @@ -47,6 +47,7 @@ from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar from View.Results.PlotSedAdis import PlotAdis_dx, PlotAdis_dt +from View.Results.PlotTemperature import PlotTemperature from View.Results.CustomPlot.Plot import CustomPlot from View.Results.CustomExport.CustomExportAdis import ( @@ -225,6 +226,33 @@ class ResultsWindowAdisTT(PamhyrWindow): ) self.plot_cdx.draw() + self.canvas_temperature_map = MplCanvas(width=5, height=4, dpi=100) + self.canvas_temperature_map.setObjectName("canvas_temperature_map") + self.toolbar_temperature_map = PamhyrPlotToolbar( + self.canvas_temperature_map, self, items=[ + "home", "move", "zoom", "save", "iso", "back/forward" + ] + ) + temperature_map_tab = QWidget() + temperature_map_layout = QVBoxLayout(temperature_map_tab) + temperature_map_layout.addWidget(self.toolbar_temperature_map) + temperature_map_layout.addWidget(self.canvas_temperature_map) + self.find(QTabWidget, "tabWidget_c").addTab( + temperature_map_tab, + self._trad["temperature_map"] + ) + self.plot_temperature = PlotTemperature( + canvas=self.canvas_temperature_map, + results=self._results, + reach_id=self._reach_id, + profile_id=self._profile_id, + pol_id=self._current_pol_id[0], + trad=self._trad, + toolbar=self.toolbar_temperature_map, + parent=self, + ) + self.plot_temperature.draw() + # The AdisTT window only displays temperature plots. The code below # belongs to the sediment/pollutant result window and its layouts are # intentionally absent from ResultsAdisTT.ui. @@ -507,6 +535,7 @@ class ResultsWindowAdisTT(PamhyrWindow): self._reach_id = reach_id self.plot_cdt.set_reach(reach_id) self.plot_cdx.set_reach(reach_id) + self.plot_temperature.set_reach(reach_id) self.update_table_selection_reach(reach_id) self.update_table_selection_profile(0) @@ -515,6 +544,7 @@ class ResultsWindowAdisTT(PamhyrWindow): self._profile_id = profile_id self.plot_cdt.set_profile(profile_id) self.plot_cdx.set_profile(profile_id) + self.plot_temperature.set_profile(profile_id) self.update_table_selection_profile(profile_id) @@ -522,10 +552,12 @@ class ResultsWindowAdisTT(PamhyrWindow): self._current_pol_id = [p+1 for p in pol_id] # rm total_sediment self.plot_cdt.set_pollutant(self._current_pol_id) self.plot_cdx.set_pollutant(self._current_pol_id) + self.plot_temperature.set_pollutant(self._current_pol_id[0]) if timestamp is not None: self.plot_cdt.set_timestamp_preserve_view(timestamp) self.plot_cdx.set_timestamp_preserve_view(timestamp) + self.plot_temperature.set_timestamp(timestamp) self._table["raw_data"].set_timestamp(timestamp) @@ -574,6 +606,8 @@ class ResultsWindowAdisTT(PamhyrWindow): self.plot_cdt.draw() self.plot_cdx.draw() + self.plot_temperature.results = self._results + self.plot_temperature.draw() def _reload_slider(self): self._slider_time = self.find(QSlider, f"horizontalSlider_time") diff --git a/src/View/Results/translate.py b/src/View/Results/translate.py index be240b53..d4b103d8 100644 --- a/src/View/Results/translate.py +++ b/src/View/Results/translate.py @@ -46,6 +46,12 @@ class ResultsTranslate(MainTranslate): self._dict['solver'] = _translate("Results", "Solver") self._dict['x'] = _translate("Results", "X (m)") + self._dict["temperature_map"] = _translate( + "Results", "Reach temperature map" + ) + self._dict["temperature"] = _translate( + "Results", "Temperature" + ) self._dict['label_bottom'] = _translate("Results", "Bottom") self._dict['label_water'] = _translate("Results", "Water elevation") From e19eeedcc320c45150d15930cecb95793439c02e Mon Sep 17 00:00:00 2001 From: JeaDylan <101608960+JeaDylan@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:20:08 +0200 Subject: [PATCH 09/12] Export csv results: fix export flowrate as a function of time, on a study without sediments transport --- src/View/Results/Window.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index be07e615..2f03ec3c 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -1152,7 +1152,6 @@ class ResultsWindow(PamhyrWindow): z = table["Z"][:, profile.global_index] q = table["Q"][:, profile.global_index] v = table["V"][:, profile.global_index] - zfd = table["zfd"][:, profile.global_index] if self._current_results == 2: reach1 = self._results[0].river.reach(self._reach) @@ -1171,8 +1170,8 @@ class ResultsWindow(PamhyrWindow): v2 = table["V"][:, profile2.global_index] if "bed_elevation" in y: - if reach.has_bedload(): - z_min = zfd + if reach.has_bedload() and "zfd" in table: + z_min = table["zfd"][:, profile.global_index] else: z_min = [profile.geometry.z_min()] * len(self._timestamps) From 3458a5df9f38bf2b56eca9f6a9a0ff44fcb95d20 Mon Sep 17 00:00:00 2001 From: JeaDylan <101608960+JeaDylan@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:19:31 +0200 Subject: [PATCH 10/12] Import INI files: handle INI RK matching and refresh table after import --- .../InitialConditions/InitialConditions.py | 15 ++++++++- src/View/InitialConditions/Table.py | 33 ++++++++----------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/Model/InitialConditions/InitialConditions.py b/src/Model/InitialConditions/InitialConditions.py index d0ca7160..0d2a9abb 100644 --- a/src/Model/InitialConditions/InitialConditions.py +++ b/src/Model/InitialConditions/InitialConditions.py @@ -19,6 +19,7 @@ import logging from copy import copy, deepcopy +from math import isclose from tools import trace, timer from functools import reduce from numpy import interp @@ -440,14 +441,26 @@ class InitialConditions(SQLSubModel): def new_from_data(self, rk, discharge, elevation): n = Data(reach=self._reach, status=self._status) + # Values read from Mage INI files are strings, whereas profile RK + # values are stored as floats. Normalize the imported value before + # looking up its associated profile. + rk = float(rk) + section = reduce( lambda acc, s: ( - s if s.rk == rk else acc + s if isclose(s.rk, rk, rel_tol=1e-9, abs_tol=1e-9) + else acc ), self._reach.reach.profiles, None ) + if section is None: + raise ValueError( + f"No profile with RK {rk} exists in reach " + f"{self._reach.name}" + ) + n['section'] = section n['discharge'] = discharge n['elevation'] = elevation diff --git a/src/View/InitialConditions/Table.py b/src/View/InitialConditions/Table.py index 0290f817..c8da8e45 100644 --- a/src/View/InitialConditions/Table.py +++ b/src/View/InitialConditions/Table.py @@ -290,8 +290,6 @@ class InitialConditionTableModel(PamhyrTableModel): logger.error("No results data") return - self.layoutAboutToBeChanged.emit() - ts = max(results.get("timestamps")) res_reach = results.river.get_reach_by_geometry( self._reach.reach @@ -307,19 +305,15 @@ class InitialConditionTableModel(PamhyrTableModel): ) ) - self._undo.push( - ReplaceDataCommand( - self._lst, - list( - map( - lambda d: self._lst.new_from_data(*d), - data - ) - ) + new_data = list( + map( + lambda d: self._lst.new_from_data(*d), + data ) ) self.layoutAboutToBeChanged.emit() + self._undo.push(ReplaceDataCommand(self._lst, new_data)) self.layoutChanged.emit() def read_from_ini(self, file_name): @@ -355,18 +349,17 @@ class InitialConditionTableModel(PamhyrTableModel): line_split[2], line_split[3]]) - self._undo.push( - ReplaceDataCommand( - self._lst, - list( - map( - lambda d: self._lst.new_from_data(*d), - data - ) - ) + new_data = list( + map( + lambda d: self._lst.new_from_data(*d), + data ) ) + self.layoutAboutToBeChanged.emit() + self._undo.push(ReplaceDataCommand(self._lst, new_data)) + self.layoutChanged.emit() + def undo(self): self._undo.undo() self.layoutChanged.emit() From 707577521f8c33cfc1be2808783993739930d6bb Mon Sep 17 00:00:00 2001 From: JeaDylan <101608960+JeaDylan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:32:05 +0200 Subject: [PATCH 11/12] Import INI files: tolerate rounded RK values from INI files --- src/Model/InitialConditions/InitialConditions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Model/InitialConditions/InitialConditions.py b/src/Model/InitialConditions/InitialConditions.py index 0d2a9abb..f29cd663 100644 --- a/src/Model/InitialConditions/InitialConditions.py +++ b/src/Model/InitialConditions/InitialConditions.py @@ -440,7 +440,6 @@ class InitialConditions(SQLSubModel): def new_from_data(self, rk, discharge, elevation): n = Data(reach=self._reach, status=self._status) - # Values read from Mage INI files are strings, whereas profile RK # values are stored as floats. Normalize the imported value before # looking up its associated profile. @@ -448,7 +447,9 @@ class InitialConditions(SQLSubModel): section = reduce( lambda acc, s: ( - s if isclose(s.rk, rk, rel_tol=1e-9, abs_tol=1e-9) + # Mage INI files store RK values with two decimal places. + # Allow for the rounding introduced by that format. + s if isclose(s.rk, rk, rel_tol=0.0, abs_tol=0.0051) else acc ), self._reach.reach.profiles, From 63dfab66545a32fba39c263b08d3cbf39a926182 Mon Sep 17 00:00:00 2001 From: JeaDylan <101608960+JeaDylan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:49:22 +0200 Subject: [PATCH 12/12] Import INI files: non-blocking error if no profile associated with the RK is found in INI file, notifies the user of the relevant RK --- src/View/InitialConditions/Table.py | 29 +++++++++++++++++++------ src/View/InitialConditions/translate.py | 7 ++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/View/InitialConditions/Table.py b/src/View/InitialConditions/Table.py index c8da8e45..6ee0b9a3 100644 --- a/src/View/InitialConditions/Table.py +++ b/src/View/InitialConditions/Table.py @@ -31,7 +31,7 @@ from PyQt5.QtWidgets import ( QDialogButtonBox, QPushButton, QLineEdit, QFileDialog, QTableView, QAbstractItemView, QUndoStack, QShortcut, QAction, QItemDelegate, - QComboBox, + QComboBox, QMessageBox ) from View.Tools.PamhyrTable import PamhyrTableModel @@ -349,17 +349,32 @@ class InitialConditionTableModel(PamhyrTableModel): line_split[2], line_split[3]]) - new_data = list( - map( - lambda d: self._lst.new_from_data(*d), - data - ) - ) + new_data = [] + missing_rks = [] + for row in data: + rk = row[0].strip() + try: + new_data.append(self._lst.new_from_data(*row)) + except ValueError: + missing_rks.append(rk) + logger.warning( + f"No profile found for imported RK {rk} " + f"in reach {self._reach.name}" + ) self.layoutAboutToBeChanged.emit() self._undo.push(ReplaceDataCommand(self._lst, new_data)) self.layoutChanged.emit() + if missing_rks: + QMessageBox.warning( + self._table_view, + self._trad["missing_rk_title"], + self._trad["missing_rk_text"].format( + rks=", ".join(missing_rks) + ) + ) + def undo(self): self._undo.undo() self.layoutChanged.emit() diff --git a/src/View/InitialConditions/translate.py b/src/View/InitialConditions/translate.py index 89d7386e..f3679344 100644 --- a/src/View/InitialConditions/translate.py +++ b/src/View/InitialConditions/translate.py @@ -42,6 +42,13 @@ class ICTranslate(MainTranslate): "InitialCondition", "Mage initial conditions file (*.INI *.ini)") self._dict["file_all"] = _translate( "InitialCondition", "All files (*)") + self._dict["missing_rk_title"] = _translate( + "InitialCondition", "Profiles not found") + self._dict["missing_rk_text"] = _translate( + "InitialCondition", + "No profile was found for the following RK values: {rks}. " + "They were not imported and must be entered manually." + ) self._sub_dict["table_headers"] = { # "name": _translate("InitialCondition", "Name"),