new_design_pol
Dylan Jeannin 2026-08-31 13:26:01 +02:00
commit ab495efc65
23 changed files with 576 additions and 191 deletions

View File

@ -77,7 +77,7 @@ class Data(SQLSubModel):
created = True created = True
if major == "0" and (int(minor) < 2 or 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, if not cls.is_table_exists(execute,
"boundary_condition_data_temperature"): "boundary_condition_data_temperature"):
cls._db_create(execute) cls._db_create(execute)
@ -205,7 +205,7 @@ class BoundaryConditionTemperature(SQLSubModel):
created = False created = False
if major == "0" and (int(minor) < 2 or ( 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, if not cls.is_table_exists(execute,
"boundary_condition_temperature"): "boundary_condition_temperature"):
cls._db_create(execute) cls._db_create(execute)

View File

@ -19,6 +19,7 @@
import logging import logging
from copy import copy, deepcopy from copy import copy, deepcopy
from math import isclose
from tools import trace, timer from tools import trace, timer
from functools import reduce from functools import reduce
from numpy import interp from numpy import interp
@ -439,15 +440,28 @@ class InitialConditions(SQLSubModel):
def new_from_data(self, rk, discharge, elevation): def new_from_data(self, rk, discharge, elevation):
n = Data(reach=self._reach, status=self._status) 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( section = reduce(
lambda acc, s: ( lambda acc, s: (
s if s.rk == rk else acc # 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, self._reach.reach.profiles,
None None
) )
if section is None:
raise ValueError(
f"No profile with RK {rk} exists in reach "
f"{self._reach.name}"
)
n['section'] = section n['section'] = section
n['discharge'] = discharge n['discharge'] = discharge
n['elevation'] = elevation n['elevation'] = elevation

View File

@ -192,7 +192,7 @@ class ICAdisTSSpec(SQLSubModel):
new_spec.rate = rate new_spec.rate = rate
new_spec.enabled = enabled new_spec.enabled = enabled
# loaded.add(pid) loaded.add(id)
new.append(new_spec) new.append(new_spec)
data["scenario"] = scenario.parent data["scenario"] = scenario.parent
@ -237,6 +237,7 @@ class ICAdisTSSpec(SQLSubModel):
def name(self, name): def name(self, name):
self._name_section = name self._name_section = name
self.modified() self.modified()
self.modified()
@property @property
def reach(self): def reach(self):
@ -246,6 +247,7 @@ class ICAdisTSSpec(SQLSubModel):
def reach(self, reach): def reach(self, reach):
self._reach = reach self._reach = reach
self.modified() self.modified()
self.modified()
@property @property
def start_rk(self): def start_rk(self):
@ -255,6 +257,7 @@ class ICAdisTSSpec(SQLSubModel):
def start_rk(self, start_rk): def start_rk(self, start_rk):
self._start_rk = start_rk self._start_rk = start_rk
self.modified() self.modified()
self.modified()
@property @property
def end_rk(self): def end_rk(self):
@ -264,6 +267,7 @@ class ICAdisTSSpec(SQLSubModel):
def end_rk(self, end_rk): def end_rk(self, end_rk):
self._end_rk = end_rk self._end_rk = end_rk
self.modified() self.modified()
self.modified()
@property @property
def concentration(self): def concentration(self):
@ -273,6 +277,7 @@ class ICAdisTSSpec(SQLSubModel):
def concentration(self, concentration): def concentration(self, concentration):
self._concentration = concentration self._concentration = concentration
self.modified() self.modified()
self.modified()
@property @property
def eg(self): def eg(self):
@ -282,6 +287,7 @@ class ICAdisTSSpec(SQLSubModel):
def eg(self, eg): def eg(self, eg):
self._eg = eg self._eg = eg
self.modified() self.modified()
self.modified()
@property @property
def em(self): def em(self):
@ -291,6 +297,7 @@ class ICAdisTSSpec(SQLSubModel):
def em(self, em): def em(self, em):
self._em = em self._em = em
self.modified() self.modified()
self.modified()
@property @property
def ed(self): def ed(self):
@ -300,6 +307,7 @@ class ICAdisTSSpec(SQLSubModel):
def ed(self, ed): def ed(self, ed):
self._ed = ed self._ed = ed
self.modified() self.modified()
self.modified()
@property @property
def rate(self): def rate(self):
@ -309,6 +317,7 @@ class ICAdisTSSpec(SQLSubModel):
def rate(self, rate): def rate(self, rate):
self._rate = rate self._rate = rate
self.modified() self.modified()
self.modified()
@property @property
def enabled(self): def enabled(self):

View File

@ -68,7 +68,7 @@ class InitialConditionsTemperature(SQLSubModel):
major, minor, release = version.strip().split(".") major, minor, release = version.strip().split(".")
if major == "0": 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" table_name = "initial_conditions_temperature"
if not cls.is_table_exists(execute, table_name): if not cls.is_table_exists(execute, table_name):
cls._db_create(execute) cls._db_create(execute)

View File

@ -31,10 +31,11 @@ class ICTemperatureSpec(SQLSubModel):
_sub_classes = [] _sub_classes = []
def __init__(self, id: int = -1, name: str = "", def __init__(self, id: int = -1, name: str = "",
status=None, owner_scenario=None): status=None, owner_scenario=-1):
super(ICTemperatureSpec, self).__init__() super(ICTemperatureSpec, self).__init__(
id=id, status=status,
self._status = status owner_scenario=owner_scenario
)
self._name_section = name self._name_section = name
self._reach = None self._reach = None
@ -69,7 +70,7 @@ class ICTemperatureSpec(SQLSubModel):
major, minor, release = version.strip().split(".") major, minor, release = version.strip().split(".")
if major == "0": 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" table_name = "initial_conditions_temperature_spec"
if not cls.is_table_exists(execute, table_name): if not cls.is_table_exists(execute, table_name):
cls._db_create(execute) cls._db_create(execute)
@ -122,7 +123,7 @@ class ICTemperatureSpec(SQLSubModel):
new_spec.end_rk = end_rk new_spec.end_rk = end_rk
new_spec.temperature = temperature new_spec.temperature = temperature
# loaded.add(pid) loaded.add(id)
new.append(new_spec) new.append(new_spec)
data["scenario"] = scenario.parent data["scenario"] = scenario.parent
@ -163,7 +164,7 @@ class ICTemperatureSpec(SQLSubModel):
@name.setter @name.setter
def name(self, name): def name(self, name):
self._name_section = name self._name_section = name
self._status.modified() self.modified()
@property @property
def reach(self): def reach(self):
@ -172,7 +173,7 @@ class ICTemperatureSpec(SQLSubModel):
@reach.setter @reach.setter
def reach(self, reach): def reach(self, reach):
self._reach = reach self._reach = reach
self._status.modified() self.modified()
@property @property
def start_rk(self): def start_rk(self):
@ -181,7 +182,7 @@ class ICTemperatureSpec(SQLSubModel):
@start_rk.setter @start_rk.setter
def start_rk(self, start_rk): def start_rk(self, start_rk):
self._start_rk = start_rk self._start_rk = start_rk
self._status.modified() self.modified()
@property @property
def end_rk(self): def end_rk(self):
@ -190,7 +191,7 @@ class ICTemperatureSpec(SQLSubModel):
@end_rk.setter @end_rk.setter
def end_rk(self, end_rk): def end_rk(self, end_rk):
self._end_rk = end_rk self._end_rk = end_rk
self._status.modified() self.modified()
@property @property
def temperature(self): def temperature(self):

View File

@ -73,7 +73,7 @@ class Data(SQLSubModel):
major, minor, release = version.strip().split(".") major, minor, release = version.strip().split(".")
if major == "0" and (int(minor) < 2 or 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"): if not cls.is_table_exists(execute, "air_temperature_data"):
cls._db_create(execute) cls._db_create(execute)
@ -341,7 +341,7 @@ class WeatherParameters(SQLSubModel):
created = False created = False
if major == "0" and (int(minor) < 2 or 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"): if not cls.is_table_exists(execute, "air_temperature"):
cls._db_create(execute) cls._db_create(execute)
created = True created = True
@ -652,14 +652,38 @@ class WeatherParameters(SQLSubModel):
def add(self, index: int): def add(self, index: int):
value = Data(self._default_0, self._default_1, status=self._status) value = Data(self._default_0, self._default_1, status=self._status)
self._data.insert(index, value) self.insert(index, value)
self.modified()
return value return value
def insert(self, index: int, 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._data.insert(index, value)
self.modified() 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): def delete_i(self, indexes):
self._data = list( self._data = list(
map( map(
@ -708,10 +732,7 @@ class WeatherParameters(SQLSubModel):
return lst return lst
def _set_i_c_v(self, index, column, value): def _set_i_c_v(self, index, column, value):
v = self._data[index] self.set_data_value(self.get_i(index), column, value)
v[column] = self._types[column](value)
self._data[index] = v
self.modified()
def set_i_0(self, index: int, value): def set_i_0(self, index: int, value):
self._set_i_c_v(index, 0, value) self._set_i_c_v(index, 0, value)
@ -742,15 +763,13 @@ class WeatherParameters(SQLSubModel):
return new return new
def move_up(self, index): def move_up(self, index):
if index < len(self): if 0 < index < len(self):
next = index - 1 data = self.data
d = self._data data[index - 1], data[index] = data[index], data[index - 1]
d[index], d[next] = d[next], d[index] self.reorder(data)
self.modified()
def move_down(self, index): def move_down(self, index):
if index >= 0: if 0 <= index < len(self) - 1:
prev = index + 1 data = self.data
d = self._data data[index], data[index + 1] = data[index + 1], data[index]
d[index], d[prev] = d[prev], d[index] self.reorder(data)
self.modified()

View File

@ -103,6 +103,7 @@ class GeometryWindow(PamhyrWindow):
self.setup_plots() self.setup_plots()
self.setup_statusbar() self.setup_statusbar()
self.setup_connections() self.setup_connections()
self.update_meshing_action()
def setup_table(self): def setup_table(self):
if self._study.is_read_only(): if self._study.is_read_only():
@ -239,7 +240,16 @@ class GeometryWindow(PamhyrWindow):
def update_redraw(self): def update_redraw(self):
self._update(redraw=True) 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): def _update(self, redraw=False, propagate=True):
self.update_meshing_action()
if redraw: if redraw:
self._plot_xy.redraw(data=self._reach) self._plot_xy.redraw(data=self._reach)
self._plot_rkc.redraw(data=self._reach) self._plot_rkc.redraw(data=self._reach)
@ -311,6 +321,9 @@ class GeometryWindow(PamhyrWindow):
self.tableView.model().blockSignals(False) self.tableView.model().blockSignals(False)
def edit_meshing(self): def edit_meshing(self):
if self._reach.number_profiles == 0:
return
rows = list( rows = list(
set( set(
(i.row() for i in self.tableView.selectedIndexes()) (i.row() for i in self.tableView.selectedIndexes())

View File

@ -31,7 +31,7 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QPushButton, QLineEdit, QDialogButtonBox, QPushButton, QLineEdit,
QFileDialog, QTableView, QAbstractItemView, QFileDialog, QTableView, QAbstractItemView,
QUndoStack, QShortcut, QAction, QItemDelegate, QUndoStack, QShortcut, QAction, QItemDelegate,
QComboBox, QComboBox, QMessageBox
) )
from View.Tools.PamhyrTable import PamhyrTableModel from View.Tools.PamhyrTable import PamhyrTableModel
@ -290,8 +290,6 @@ class InitialConditionTableModel(PamhyrTableModel):
logger.error("No results data") logger.error("No results data")
return return
self.layoutAboutToBeChanged.emit()
ts = max(results.get("timestamps")) ts = max(results.get("timestamps"))
res_reach = results.river.get_reach_by_geometry( res_reach = results.river.get_reach_by_geometry(
self._reach.reach self._reach.reach
@ -307,19 +305,15 @@ class InitialConditionTableModel(PamhyrTableModel):
) )
) )
self._undo.push( new_data = list(
ReplaceDataCommand( map(
self._lst, lambda d: self._lst.new_from_data(*d),
list( data
map(
lambda d: self._lst.new_from_data(*d),
data
)
)
) )
) )
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push(ReplaceDataCommand(self._lst, new_data))
self.layoutChanged.emit() self.layoutChanged.emit()
def read_from_ini(self, file_name): def read_from_ini(self, file_name):
@ -355,17 +349,31 @@ class InitialConditionTableModel(PamhyrTableModel):
line_split[2], line_split[2],
line_split[3]]) line_split[3]])
self._undo.push( new_data = []
ReplaceDataCommand( missing_rks = []
self._lst, for row in data:
list( rk = row[0].strip()
map( try:
lambda d: self._lst.new_from_data(*d), new_data.append(self._lst.new_from_data(*row))
data 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): def undo(self):
self._undo.undo() self._undo.undo()

View File

@ -42,6 +42,13 @@ class ICTranslate(MainTranslate):
"InitialCondition", "Mage initial conditions file (*.INI *.ini)") "InitialCondition", "Mage initial conditions file (*.INI *.ini)")
self._dict["file_all"] = _translate( self._dict["file_all"] = _translate(
"InitialCondition", "All files (*)") "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"] = { self._sub_dict["table_headers"] = {
# "name": _translate("InitialCondition", "Name"), # "name": _translate("InitialCondition", "Name"),

View File

@ -205,13 +205,14 @@ class InitialConditionTableModel(PamhyrTableModel):
if self._headers[column] != "reach": if self._headers[column] != "reach":
self._undo.push( self._undo.push(
SetCommandSpec( SetCommandSpec(
self._lst, row, self._headers[column], value self._data, self._lst, row,
self._headers[column], value
) )
) )
elif self._headers[column] == "reach": elif self._headers[column] == "reach":
self._undo.push( self._undo.push(
SetCommandSpec( SetCommandSpec(
self._lst, row, self._headers[column], self._data, self._lst, row, self._headers[column],
self._river.edge(value).id self._river.edge(value).id
) )
) )

View File

@ -80,31 +80,31 @@ class SetCommand(QUndoCommand):
class SetCommandSpec(QUndoCommand): class SetCommandSpec(QUndoCommand):
def __init__(self, data, row, column, new_value): def __init__(self, parent, data, row, column, new_value):
QUndoCommand.__init__(self) QUndoCommand.__init__(self)
self._data = data self._parent = parent
self._row = row self._value = data[row]
self._column = column self._column = column
if self._column == "name": if self._column == "name":
self._old = self._data[self._row].name self._old = self._value.name
elif self._column == "reach": elif self._column == "reach":
self._old = self._data[self._row].reach self._old = self._value.reach
elif self._column == "start_rk": elif self._column == "start_rk":
self._old = self._data[self._row].start_rk self._old = self._value.start_rk
elif self._column == "end_rk": elif self._column == "end_rk":
self._old = self._data[self._row].end_rk self._old = self._value.end_rk
elif self._column == "concentration": elif self._column == "concentration":
self._old = self._data[self._row].concentration self._old = self._value.concentration
elif self._column == "eg": elif self._column == "eg":
self._old = self._data[self._row].eg self._old = self._value.eg
elif self._column == "em": elif self._column == "em":
self._old = self._data[self._row].em self._old = self._value.em
elif self._column == "ed": elif self._column == "ed":
self._old = self._data[self._row].ed self._old = self._value.ed
elif self._column == "rate": elif self._column == "rate":
self._old = self._data[self._row].rate self._old = self._value.rate
_type = float _type = float
if column == "name": if column == "name":
@ -115,44 +115,12 @@ class SetCommandSpec(QUndoCommand):
self._new = _type(new_value) self._new = _type(new_value)
def undo(self): def undo(self):
if self._column == "name": setattr(self._value, self._column, self._old)
self._data[self._row].name = self._old self._parent.modified()
elif self._column == "reach":
self._data[self._row].reach = self._old
elif self._column == "start_rk":
self._data[self._row].start_rk = self._old
elif self._column == "end_rk":
self._data[self._row].end_rk = self._old
elif self._column == "concentration":
self._data[self._row].concentration = self._old
elif self._column == "eg":
self._data[self._row].eg = self._old
elif self._column == "em":
self._data[self._row].em = self._old
elif self._column == "ed":
self._data[self._row].ed = self._old
elif self._column == "rate":
self._data[self._row].rate = self._old
def redo(self): def redo(self):
if self._column == "name": setattr(self._value, self._column, self._new)
self._data[self._row].name = self._new self._parent.modified()
elif self._column == "reach":
self._data[self._row].reach = self._new
elif self._column == "start_rk":
self._data[self._row].start_rk = self._new
elif self._column == "end_rk":
self._data[self._row].end_rk = self._new
elif self._column == "concentration":
self._data[self._row].concentration = self._new
elif self._column == "eg":
self._data[self._row].eg = self._new
elif self._column == "em":
self._data[self._row].em = self._new
elif self._column == "ed":
self._data[self._row].ed = self._new
elif self._column == "rate":
self._data[self._row].rate = self._new
class AddCommand(QUndoCommand): class AddCommand(QUndoCommand):

View File

@ -30,13 +30,13 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QPushButton, QLineEdit, QDialogButtonBox, QPushButton, QLineEdit,
QFileDialog, QTableView, QAbstractItemView, QFileDialog, QTableView, QAbstractItemView,
QUndoStack, QShortcut, QAction, QItemDelegate, QUndoStack, QShortcut, QAction, QItemDelegate,
QComboBox, QComboBox, QMessageBox,
) )
from View.Tools.PamhyrTable import PamhyrTableModel from View.Tools.PamhyrTable import PamhyrTableModel
from View.InitialConditionsTemperature.UndoCommand import ( from View.InitialConditionsTemperature.UndoCommand import (
SetCommand, AddCommand, SetCommandSpec, AddCommand, SetCommandSpec,
DelCommand, DelCommand,
) )
@ -229,13 +229,22 @@ class InitialConditionTableModel(PamhyrTableModel):
row = index.row() row = index.row()
column = index.column() column = index.column()
column_name = self._headers[column]
try: try:
if self._headers[column] in ["name", "temperature"]: new_value = (
self._undo.push( self._river.edge(value).id
SetCommand( if column_name == "reach"
self._lst, row, self._headers[column], value 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"]
) )
else: else:
new_value = value new_value = value
@ -265,6 +274,25 @@ class InitialConditionTableModel(PamhyrTableModel):
self.dataChanged.emit(index, index) self.dataChanged.emit(index, index)
return True 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()): def add(self, row, parent=QModelIndex()):
self.beginInsertRows(parent, row, row - 1) self.beginInsertRows(parent, row, row - 1)

View File

@ -62,23 +62,23 @@ class SetCommand(QUndoCommand):
class SetCommandSpec(QUndoCommand): class SetCommandSpec(QUndoCommand):
def __init__(self, data, row, column, new_value): def __init__(self, parent, data, row, column, new_value):
QUndoCommand.__init__(self) QUndoCommand.__init__(self)
self._data = data self._parent = parent
self._row = row self._value = data[row]
self._column = column self._column = column
if self._column == "name": if self._column == "name":
self._old = self._data[self._row].name self._old = self._value.name
elif self._column == "reach": elif self._column == "reach":
self._old = self._data[self._row].reach self._old = self._data[self._row].reach
self._old_start_rk = self._data[self._row].start_rk self._old_start_rk = self._data[self._row].start_rk
self._old_end_rk = self._data[self._row].end_rk self._old_end_rk = self._data[self._row].end_rk
elif self._column == "rk": elif self._column == "rk":
self._old = self._data[self._row].start_rk self._old = self._value.start_rk
elif self._column == "temperature": elif self._column == "temperature":
self._old = self._data[self._row].temperature self._old = self._value.temperature
_type = float _type = float
if column == "name": if column == "name":
@ -94,29 +94,31 @@ class SetCommandSpec(QUndoCommand):
def undo(self): def undo(self):
if self._column == "name": if self._column == "name":
self._data[self._row].name = self._old self._value.name = self._old
elif self._column == "reach": elif self._column == "reach":
self._data[self._row].reach = self._old self._data[self._row].reach = self._old
self._data[self._row].start_rk = self._old_start_rk self._data[self._row].start_rk = self._old_start_rk
self._data[self._row].end_rk = self._old_end_rk self._data[self._row].end_rk = self._old_end_rk
elif self._column == "rk": elif self._column == "rk":
self._data[self._row].start_rk = self._old self._value.start_rk = self._old
self._data[self._row].end_rk = self._old self._value.end_rk = self._old
elif self._column == "temperature": elif self._column == "temperature":
self._data[self._row].temperature = self._old self._value.temperature = self._old
self._parent.modified()
def redo(self): def redo(self):
if self._column == "name": if self._column == "name":
self._data[self._row].name = self._new self._value.name = self._new
elif self._column == "reach": elif self._column == "reach":
self._data[self._row].reach = self._new self._data[self._row].reach = self._new
self._data[self._row].start_rk = self._new_rk self._data[self._row].start_rk = self._new_rk
self._data[self._row].end_rk = self._new_rk self._data[self._row].end_rk = self._new_rk
elif self._column == "rk": elif self._column == "rk":
self._data[self._row].start_rk = self._new self._value.start_rk = self._new
self._data[self._row].end_rk = self._new self._value.end_rk = self._new
elif self._column == "temperature": elif self._column == "temperature":
self._data[self._row].temperature = self._new self._value.temperature = self._new
self._parent.modified()
class AddCommand(QUndoCommand): class AddCommand(QUndoCommand):

View File

@ -32,6 +32,16 @@ class IcTemperatureTranslate(MainTranslate):
self._dict["rk"] = self._dict["unit_rk"] 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"] = { self._sub_dict["table_headers"] = {
"name": self._dict["name"], "name": self._dict["name"],
"temperature": self._dict["unit_temperature"], "temperature": self._dict["unit_temperature"],

View File

@ -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

View File

@ -1152,7 +1152,6 @@ class ResultsWindow(PamhyrWindow):
z = table["Z"][:, profile.global_index] z = table["Z"][:, profile.global_index]
q = table["Q"][:, profile.global_index] q = table["Q"][:, profile.global_index]
v = table["V"][:, profile.global_index] v = table["V"][:, profile.global_index]
zfd = table["zfd"][:, profile.global_index]
if self._current_results == 2: if self._current_results == 2:
reach1 = self._results[0].river.reach(self._reach) reach1 = self._results[0].river.reach(self._reach)
@ -1171,8 +1170,8 @@ class ResultsWindow(PamhyrWindow):
v2 = table["V"][:, profile2.global_index] v2 = table["V"][:, profile2.global_index]
if "bed_elevation" in y: if "bed_elevation" in y:
if reach.has_bedload(): if reach.has_bedload() and "zfd" in table:
z_min = zfd z_min = table["zfd"][:, profile.global_index]
else: else:
z_min = [profile.geometry.z_min()] * len(self._timestamps) z_min = [profile.geometry.z_min()] * len(self._timestamps)

View File

@ -47,6 +47,7 @@ from View.Tools.Plot.PamhyrCanvas import MplCanvas
from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar
from View.Results.PlotSedAdis import PlotAdis_dx, PlotAdis_dt 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.CustomPlot.Plot import CustomPlot
from View.Results.CustomExport.CustomExportAdis import ( from View.Results.CustomExport.CustomExportAdis import (
@ -225,6 +226,33 @@ class ResultsWindowAdisTT(PamhyrWindow):
) )
self.plot_cdx.draw() 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 # The AdisTT window only displays temperature plots. The code below
# belongs to the sediment/pollutant result window and its layouts are # belongs to the sediment/pollutant result window and its layouts are
# intentionally absent from ResultsAdisTT.ui. # intentionally absent from ResultsAdisTT.ui.
@ -507,6 +535,7 @@ class ResultsWindowAdisTT(PamhyrWindow):
self._reach_id = reach_id self._reach_id = reach_id
self.plot_cdt.set_reach(reach_id) self.plot_cdt.set_reach(reach_id)
self.plot_cdx.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_reach(reach_id)
self.update_table_selection_profile(0) self.update_table_selection_profile(0)
@ -515,6 +544,7 @@ class ResultsWindowAdisTT(PamhyrWindow):
self._profile_id = profile_id self._profile_id = profile_id
self.plot_cdt.set_profile(profile_id) self.plot_cdt.set_profile(profile_id)
self.plot_cdx.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) 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._current_pol_id = [p+1 for p in pol_id] # rm total_sediment
self.plot_cdt.set_pollutant(self._current_pol_id) self.plot_cdt.set_pollutant(self._current_pol_id)
self.plot_cdx.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: if timestamp is not None:
self.plot_cdt.set_timestamp_preserve_view(timestamp) self.plot_cdt.set_timestamp_preserve_view(timestamp)
self.plot_cdx.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) self._table["raw_data"].set_timestamp(timestamp)
@ -574,6 +606,8 @@ class ResultsWindowAdisTT(PamhyrWindow):
self.plot_cdt.draw() self.plot_cdt.draw()
self.plot_cdx.draw() self.plot_cdx.draw()
self.plot_temperature.results = self._results
self.plot_temperature.draw()
def _reload_slider(self): def _reload_slider(self):
self._slider_time = self.find(QSlider, f"horizontalSlider_time") self._slider_time = self.find(QSlider, f"horizontalSlider_time")

View File

@ -46,6 +46,12 @@ class ResultsTranslate(MainTranslate):
self._dict['solver'] = _translate("Results", "Solver") self._dict['solver'] = _translate("Results", "Solver")
self._dict['x'] = _translate("Results", "X (m)") 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_bottom'] = _translate("Results", "Bottom")
self._dict['label_water'] = _translate("Results", "Water elevation") self._dict['label_water'] = _translate("Results", "Water elevation")

View File

@ -145,35 +145,24 @@ class TableModel(PamhyrTableModel):
if row <= 0: if row <= 0:
return return
target = row + 2 self.layoutAboutToBeChanged.emit()
self._undo.push(
self.beginMoveRows(parent, row - 1, row - 1, parent, target)
self._undo_stack.push(
MoveCommand( MoveCommand(
self._data, "up", row self._data, "up", row
) )
) )
self.endMoveRows()
self.update() self.update()
def move_down(self, index, parent=QModelIndex()): def move_down(self, row, parent=QModelIndex()):
row = index.row() if row < 0 or row >= len(self._data) - 1:
if row >= len(self._data):
return return
target = row self.layoutAboutToBeChanged.emit()
self._undo.push(
self.beginMoveRows(parent, row + 1, row + 1, parent, target)
self._undo_stack.push(
MoveCommand( MoveCommand(
self._data, "down", row self._data, "down", row
) )
) )
self.endMoveRows()
self.update() self.update()
def paste(self, row, header, data): def paste(self, row, header, data):

View File

@ -31,17 +31,17 @@ class SetDataCommand(QUndoCommand):
QUndoCommand.__init__(self) QUndoCommand.__init__(self)
self._data = data self._data = data
self._index = index self._value = self._data.get_i(index)
self._column = column 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) _type = self._data.get_type_column(self._column)
self._new = _type(new_value) self._new = _type(new_value)
def undo(self): 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): 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): class AddCommand(QUndoCommand):
@ -53,13 +53,13 @@ class AddCommand(QUndoCommand):
self._new = None self._new = None
def undo(self): def undo(self):
self._data.delete_i([self._index]) self._data.set_deleted([self._new], True)
def redo(self): def redo(self):
if self._new is None: if self._new is None:
self._new = self._data.add(self._index) self._new = self._data.add(self._index)
else: else:
self._data.insert(self._index, self._new) self._data.set_deleted([self._new], False)
class DelCommand(QUndoCommand): class DelCommand(QUndoCommand):
@ -75,10 +75,10 @@ class DelCommand(QUndoCommand):
self._wp.sort() self._wp.sort()
def undo(self): 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): 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): class SortCommand(QUndoCommand):
@ -89,27 +89,17 @@ class SortCommand(QUndoCommand):
self._reverse = _reverse self._reverse = _reverse
self._old = self._data.data self._old = self._data.data
self._indexes = None self._new = sorted(
self._old,
key=lambda value: value[0],
reverse=self._reverse
)
def undo(self): def undo(self):
ll = self._data.data self._data.reorder(self._old)
self._data.sort(
key=lambda x: self._indexes[ll.index(x)]
)
def redo(self): def redo(self):
self._data.sort( self._data.reorder(self._new)
_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
class MoveCommand(QUndoCommand): class MoveCommand(QUndoCommand):
@ -117,20 +107,16 @@ class MoveCommand(QUndoCommand):
QUndoCommand.__init__(self) QUndoCommand.__init__(self)
self._data = data self._data = data
self._up = up == "up" self._old = self._data.data
self._i = i 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): def undo(self):
if self._up: self._data.reorder(self._old)
self._data.move_up(self._i)
else:
self._data.move_down(self._i)
def redo(self): def redo(self):
if self._up: self._data.reorder(self._new)
self._data.move_up(self._i)
else:
self._data.move_down(self._i)
class PasteCommand(QUndoCommand): class PasteCommand(QUndoCommand):
@ -141,15 +127,18 @@ class PasteCommand(QUndoCommand):
self._row = row self._row = row
self._wps = list(wps) self._wps = list(wps)
self._wps.reverse() self._wps.reverse()
self._inserted = False
def undo(self): def undo(self):
self._data.delete_i( self._data.set_deleted(self._wps, True)
range(self._row, self._row + len(self._wps))
)
def redo(self): def redo(self):
for wp in self._wps: if not self._inserted:
self._data.insert(self._row, wp) 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): class DuplicateCommand(QUndoCommand):
@ -160,10 +149,15 @@ class DuplicateCommand(QUndoCommand):
self._rows = rows self._rows = rows
self._wp = deepcopy(bc) self._wp = deepcopy(bc)
self._wp.reverse() self._wp.reverse()
self._inserted = False
def undo(self): def undo(self):
self._data.delete(self._wp) self._data.set_deleted(self._wp, True)
def redo(self): def redo(self):
for bc in self._wp: if not self._inserted:
self._data.insert(self._rows[0], bc) for bc in self._wp:
self._data.insert(self._rows[0], bc)
self._inserted = True
else:
self._data.set_deleted(self._wp, False)

View File

@ -30,7 +30,7 @@ from PyQt5.QtWidgets import (
QDialogButtonBox, QPushButton, QLineEdit, QDialogButtonBox, QPushButton, QLineEdit,
QFileDialog, QTableView, QAbstractItemView, QFileDialog, QTableView, QAbstractItemView,
QUndoStack, QShortcut, QAction, QItemDelegate, QUndoStack, QShortcut, QAction, QItemDelegate,
QComboBox, QComboBox, QMessageBox,
) )
from View.Tools.PamhyrTable import PamhyrTableModel from View.Tools.PamhyrTable import PamhyrTableModel
@ -245,6 +245,10 @@ class WeatherParametersTableModel(PamhyrTableModel):
p for p in _edge.reach.profiles p for p in _edge.reach.profiles
if p.pamhyr_id == value if p.pamhyr_id == value
) )
if self._overlaps_existing_interval(
row, begin_section=_begin_rk):
self._show_overlap_warning()
return False
self._undo.push( self._undo.push(
SetBeginCommand( SetBeginCommand(
self._data, global_row, _begin_rk self._data, global_row, _begin_rk
@ -256,18 +260,32 @@ class WeatherParametersTableModel(PamhyrTableModel):
p for p in _edge.reach.profiles p for p in _edge.reach.profiles
if p.pamhyr_id == value if p.pamhyr_id == value
) )
if self._overlaps_existing_interval(
row, end_section=_end_rk):
self._show_overlap_warning()
return False
self._undo.push( self._undo.push(
SetEndCommand( SetEndCommand(
self._data, global_row, _end_rk self._data, global_row, _end_rk
) )
) )
elif self._headers[column] == "reach": elif self._headers[column] == "reach":
new_reach = self._river.edge(value)
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
edge = self._river.edge(value) edge = self._river.edge(value)
if not edge.reach.profiles: if not edge.reach.profiles:
return False return False
self._undo.push( self._undo.push(
SetEdgeCommand( SetEdgeCommand(
self._data, global_row, edge self._data, global_row, new_reach,
clear_interval=clear_interval
) )
) )
except Exception as e: except Exception as e:
@ -283,6 +301,48 @@ class WeatherParametersTableModel(PamhyrTableModel):
self.dataChanged.emit(index, index) self.dataChanged.emit(index, index)
return True 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()): def add(self, row, parent=QModelIndex()):
self.beginInsertRows(parent, row, row) self.beginInsertRows(parent, row, row)

View File

@ -120,7 +120,7 @@ class SetEndCommand(QUndoCommand):
class SetEdgeCommand(QUndoCommand): class SetEdgeCommand(QUndoCommand):
def __init__(self, wps, index, edge): def __init__(self, wps, index, edge, clear_interval=False):
QUndoCommand.__init__(self) QUndoCommand.__init__(self)
self._wps = wps self._wps = wps

View File

@ -41,6 +41,14 @@ class WeatherParametersTranslate(MainTranslate):
"These values are applied to sections that have not been " "These values are applied to sections that have not been "
"defined using time series." "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"] self._dict["rk"] = self._dict["unit_rk"]