From b77aed23952d5bc0f8634d6d5a3f086c5ca1da67 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 16 Oct 2023 15:52:29 +0200 Subject: [PATCH 01/36] results: Add custom plot dialog scheme. --- .../CustomPlotValuesSelectionDialog.py | 87 +++++++++++++++++ src/View/Results/Window.py | 21 +++++ .../ui/CustomPlotValuesSelectionDialog.ui | 93 +++++++++++++++++++ src/View/ui/Results.ui | 24 +++++ 4 files changed, 225 insertions(+) create mode 100644 src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py create mode 100644 src/View/ui/CustomPlotValuesSelectionDialog.ui diff --git a/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py b/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py new file mode 100644 index 00000000..d2e3a3e8 --- /dev/null +++ b/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py @@ -0,0 +1,87 @@ +# CustomPlotValueSelectionDialog.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from View.Tools.PamhyrWindow import PamhyrDialog + +from PyQt5.QtWidgets import ( + QRadioButton, QCheckBox, QVBoxLayout, +) + +class CustomPlotValuesSelectionDialog(PamhyrDialog): + _pamhyr_ui = "CustomPlotValuesSelectionDialog" + _pamhyr_name = "Custom Plot Selection" + + def __init__(self, parent=None): + super(CustomPlotValuesSelectionDialog, self).__init__( + title=self._pamhyr_name, + options=[], + parent=parent + ) + + self._available_values_x = ['foo', 'bar'] + self._available_values_y = ['bar', 'baz'] + + self.setup_radio_buttons() + self.setup_check_boxs() + + self.value = None + + def setup_radio_buttons(self): + self._radio = [] + layout = self.find(QVBoxLayout, "verticalLayout_x") + + for value in self._available_values_x: + btn = QRadioButton(value, parent=self) + self._radio.append(btn) + layout.addWidget(btn) + + self._radio[0].setChecked(True) + layout.addStretch() + + def setup_check_boxs(self): + self._check = [] + layout = self.find(QVBoxLayout, "verticalLayout_y") + + for value in self._available_values_y: + btn = QCheckBox(value, parent=self) + self._check.append(btn) + layout.addWidget(btn) + + self._check[0].setChecked(True) + layout.addStretch() + + def accept(self): + x = next( + filter( + lambda r: r.isChecked(), + self._radio + ) + ).text() + + y = list( + map( + lambda b: b.text(), + filter( + lambda b: b.isChecked(), + self._check + ) + ) + ) + + self.value = x, y + super().accept() diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 15167fd3..353a036c 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -51,6 +51,10 @@ from View.Results.PlotH import PlotH from View.Results.PlotSedReach import PlotSedReach from View.Results.PlotSedProfile import PlotSedProfile +from View.Results.CustomPlot.CustomPlotValuesSelectionDialog import ( + CustomPlotValuesSelectionDialog, +) + from View.Results.Table import TableModel from View.Results.translate import ResultsTranslate from View.Stricklers.Window import StricklersWindow @@ -272,6 +276,17 @@ class ResultsWindow(PamhyrWindow): self._status_label.setText(txt) def setup_connections(self): + # Action + actions = { + "action_add": self._add_custom_plot + } + + for action in actions: + self.find(QAction, action).triggered.connect( + actions[action] + ) + + # Table and Plot fun = { "reach": self._set_current_reach, "profile": self._set_current_profile, @@ -390,6 +405,12 @@ class ResultsWindow(PamhyrWindow): timestamp = self._timestamps[self._slider_time.value()] self.update(timestamp=timestamp) + def _add_custom_plot(self): + dlg = CustomPlotValuesSelectionDialog(parent=self) + if dlg.exec(): + value = dlg.value + logger.info(value) + def _copy(self): logger.info("TODO: copy") diff --git a/src/View/ui/CustomPlotValuesSelectionDialog.ui b/src/View/ui/CustomPlotValuesSelectionDialog.ui new file mode 100644 index 00000000..88ca363c --- /dev/null +++ b/src/View/ui/CustomPlotValuesSelectionDialog.ui @@ -0,0 +1,93 @@ + + + Dialog + + + + 0 + 0 + 414 + 482 + + + + Dialog + + + + + + Qt::Horizontal + + + + + + + X axis: + + + + + + + + + + + Y axis: + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/View/ui/Results.ui b/src/View/ui/Results.ui index 5b797724..08920821 100644 --- a/src/View/ui/Results.ui +++ b/src/View/ui/Results.ui @@ -140,6 +140,30 @@ + + + toolBar + + + TopToolBarArea + + + false + + + + + + + ressources/gtk-add.pngressources/gtk-add.png + + + Add + + + Add custom visualization + + From 98ee3ee6eca0b845320238d29766cb11e926dc9c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 16 Oct 2023 17:25:08 +0200 Subject: [PATCH 02/36] CustomPlot: Add translation. --- .../CustomPlotValuesSelectionDialog.py | 36 +++++++---- src/View/Results/CustomPlot/Translate.py | 61 +++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 src/View/Results/CustomPlot/Translate.py diff --git a/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py b/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py index d2e3a3e8..1ffd6732 100644 --- a/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py +++ b/src/View/Results/CustomPlot/CustomPlotValuesSelectionDialog.py @@ -1,4 +1,4 @@ -# CustomPlotValueSelectionDialog.py -- Pamhyr +# CustomPlotValuesSelectionDialog.py -- Pamhyr # Copyright (C) 2023 INRAE # # This program is free software: you can redistribute it and/or modify @@ -22,6 +22,9 @@ from PyQt5.QtWidgets import ( QRadioButton, QCheckBox, QVBoxLayout, ) +from View.Results.CustomPlot.Translate import CustomPlotTranslate + + class CustomPlotValuesSelectionDialog(PamhyrDialog): _pamhyr_ui = "CustomPlotValuesSelectionDialog" _pamhyr_name = "Custom Plot Selection" @@ -30,11 +33,12 @@ class CustomPlotValuesSelectionDialog(PamhyrDialog): super(CustomPlotValuesSelectionDialog, self).__init__( title=self._pamhyr_name, options=[], + trad=CustomPlotTranslate(), parent=parent ) - self._available_values_x = ['foo', 'bar'] - self._available_values_y = ['bar', 'baz'] + self._available_values_x = self._trad.get_dict("values_x") + self._available_values_y = self._trad.get_dict("values_y") self.setup_radio_buttons() self.setup_check_boxs() @@ -46,11 +50,14 @@ class CustomPlotValuesSelectionDialog(PamhyrDialog): layout = self.find(QVBoxLayout, "verticalLayout_x") for value in self._available_values_x: - btn = QRadioButton(value, parent=self) - self._radio.append(btn) + btn = QRadioButton( + self._available_values_x[value], + parent=self + ) + self._radio.append((value, btn)) layout.addWidget(btn) - self._radio[0].setChecked(True) + self._radio[0][1].setChecked(True) layout.addStretch() def setup_check_boxs(self): @@ -58,26 +65,29 @@ class CustomPlotValuesSelectionDialog(PamhyrDialog): layout = self.find(QVBoxLayout, "verticalLayout_y") for value in self._available_values_y: - btn = QCheckBox(value, parent=self) - self._check.append(btn) + btn = QCheckBox( + self._available_values_y[value], + parent=self + ) + self._check.append((value, btn)) layout.addWidget(btn) - self._check[0].setChecked(True) + self._check[0][1].setChecked(True) layout.addStretch() def accept(self): x = next( filter( - lambda r: r.isChecked(), + lambda r: r[1].isChecked(), self._radio ) - ).text() + )[0] y = list( map( - lambda b: b.text(), + lambda b: b[0], filter( - lambda b: b.isChecked(), + lambda b: b[1].isChecked(), self._check ) ) diff --git a/src/View/Results/CustomPlot/Translate.py b/src/View/Results/CustomPlot/Translate.py new file mode 100644 index 00000000..11c3187e --- /dev/null +++ b/src/View/Results/CustomPlot/Translate.py @@ -0,0 +1,61 @@ +# Translate.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from PyQt5.QtCore import QCoreApplication + +from View.Results.translate import ResultsTranslate + +_translate = QCoreApplication.translate + + +class CustomPlotTranslate(ResultsTranslate): + def __init__(self): + super(CustomPlotTranslate, self).__init__() + + self._dict['time'] = _translate( + "CustomPlot", "Time (sec)" + ) + + self._dict['kp'] = _translate( + "CustomPlot", "Kp (m)" + ) + + self._dict['elevation'] = _translate( + "CustomPlot", "Elevation (m)" + ) + + self._dict['water_elevation'] = _translate( + "CustomPlot", "Water elevation (m)" + ) + + self._dict['discharge'] = _translate( + "CustomPlot", "Discharge (m³/s)" + ) + + # SubDict + + self._sub_dict["values_x"] = { + "kp": self._dict["kp"], + "time": self._dict["time"], + } + + self._sub_dict["values_y"] = { + "elevation": self._dict["elevation"], + "water_elevation": self._dict["water_elevation"], + "discharge": self._dict["discharge"], + } From daaa9b193ee7397a927a699d5b63a25dc22e746e Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 17 Oct 2023 11:18:35 +0200 Subject: [PATCH 03/36] Resutls: CustomPlot: Prepare ploting mecanisme. --- src/View/Results/CustomPlot/Plot.py | 112 +++++++++++++++++++++++ src/View/Results/CustomPlot/Translate.py | 16 +++- src/View/Results/Window.py | 75 ++++++++++++++- 3 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 src/View/Results/CustomPlot/Plot.py diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py new file mode 100644 index 00000000..ea1e2001 --- /dev/null +++ b/src/View/Results/CustomPlot/Plot.py @@ -0,0 +1,112 @@ +# Plot.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import logging + +from functools import reduce + +from tools import timer +from View.Tools.PamhyrPlot import PamhyrPlot + +logger = logging.getLogger() + +unit = { + "elevation": "meter", + "water_elevation": "meter", + "discharge": "m3s", +} + + +class CustomPlot(PamhyrPlot): + def __init__(self, x, y, reach, profile, timestamp, + data=None, canvas=None, trad=None, + toolbar=None, parent=None): + super(CustomPlot, self).__init__( + canvas=canvas, + trad=trad, + data=data, + toolbar=toolbar, + parent=parent + ) + + self._x = x + self._y = y + self._reach = reach + self._profile = profile + self._timestamp = timestamp + + self._y_axis = list( + set( + map( + lambda y: self._trad[y], + self._y + ) + ) + ) + + @timer + def draw(self): + self.canvas.axes.cla() + self.canvas.axes.grid(color='grey', linestyle='--', linewidth=0.5) + + if self.data is None: + return + + self.canvas.axes.set_xlabel( + self._trad[self._x], + color='green', fontsize=12 + ) + self.canvas.axes.set_ylabel( + self._trad[self._y_axis[0]], + color='green', fontsize=12 + ) + + for axes in self._y_axis[1:]: + logger.info(axes) + ax_new = ax.twinx() + #ax_new.spines['right'].set_position(('axes', 1 + spacing * (n - 1))) + ax_new.set_ylabel( + self._trad[axes], + color='green', fontsize=12 + ) + + if self._x is "kp": + if "elevation" in self._y: + logging.info("TODO: kp/elevation") + if "water_elevation" in self._y: + logging.info("TODO: kp/water_elevation") + if "discharge" in self._y: + logging.info("TODO: kp/discharge") + elif self._x is "time": + if "elevation" in self._y: + logging.info("TODO: time/elevation") + if "water_elevation" in self._y: + logging.info("TODO: time/water_elevation") + if "discharge" in self._y: + logging.info("TODO: time/discharge") + + self.canvas.figure.tight_layout() + self.canvas.figure.canvas.draw_idle() + if self.toolbar is not None: + self.toolbar.update() + + @timer + def update(self, reach, profile, timestamp): + if not self._init: + self.draw() + return diff --git a/src/View/Results/CustomPlot/Translate.py b/src/View/Results/CustomPlot/Translate.py index 11c3187e..aee28e0d 100644 --- a/src/View/Results/CustomPlot/Translate.py +++ b/src/View/Results/CustomPlot/Translate.py @@ -27,33 +27,39 @@ class CustomPlotTranslate(ResultsTranslate): def __init__(self): super(CustomPlotTranslate, self).__init__() + # Value type + self._dict['time'] = _translate( "CustomPlot", "Time (sec)" ) - self._dict['kp'] = _translate( "CustomPlot", "Kp (m)" ) - self._dict['elevation'] = _translate( "CustomPlot", "Elevation (m)" ) - self._dict['water_elevation'] = _translate( "CustomPlot", "Water elevation (m)" ) - self._dict['discharge'] = _translate( "CustomPlot", "Discharge (m³/s)" ) + # Unit corresponding long name (plot axes display) + + self._dict['meter'] = _translate( + "CustomPlot", "Elevation (m)" + ) + self._dict['m3s'] = _translate( + "CustomPlot", "Discharge (m³/s)" + ) + # SubDict self._sub_dict["values_x"] = { "kp": self._dict["kp"], "time": self._dict["time"], } - self._sub_dict["values_y"] = { "elevation": self._dict["elevation"], "water_elevation": self._dict["water_elevation"], diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 353a036c..eb8c5b1d 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -38,7 +38,7 @@ from PyQt5.QtWidgets import ( QFileDialog, QTableView, QAbstractItemView, QUndoStack, QShortcut, QAction, QItemDelegate, QComboBox, QVBoxLayout, QHeaderView, QTabWidget, - QSlider, QLabel, + QSlider, QLabel, QWidget, QGridLayout, ) from View.Tools.Plot.PamhyrCanvas import MplCanvas @@ -51,6 +51,7 @@ from View.Results.PlotH import PlotH from View.Results.PlotSedReach import PlotSedReach from View.Results.PlotSedProfile import PlotSedProfile +from View.Results.CustomPlot.Plot import CustomPlot from View.Results.CustomPlot.CustomPlotValuesSelectionDialog import ( CustomPlotValuesSelectionDialog, ) @@ -91,6 +92,8 @@ class ResultsWindow(PamhyrWindow): parent=parent ) + self._additional_plot = {} + self.setup_table() self.setup_plot() self.setup_slider() @@ -377,6 +380,27 @@ class ResultsWindow(PamhyrWindow): self.update_statusbar() + def _get_current_reach(self): + table = self.find(QTableView, f"tableView_reach") + indexes = table.selectedIndexes() + if len(indexes) == 0: + return 0 + + return indexes[0].row() + + def _get_current_profile(self): + table = self.find(QTableView, f"tableView_profile") + indexes = table.selectedIndexes() + if len(indexes) == 0: + return 0 + + return indexes[0].row() + + def _get_current_timestamp(self): + return self._timestamps[ + self._slider_time.value() + ] + def _set_current_reach(self): table = self.find(QTableView, f"tableView_reach") indexes = table.selectedIndexes() @@ -408,8 +432,53 @@ class ResultsWindow(PamhyrWindow): def _add_custom_plot(self): dlg = CustomPlotValuesSelectionDialog(parent=self) if dlg.exec(): - value = dlg.value - logger.info(value) + x, y = dlg.value + self.create_new_tab_custom_plot(x, y) + + def create_new_tab_custom_plot(self, x:str, y:list): + name = f"{x}: {','.join(y)}" + wname = f"tab_custom_{x}_{y}" + + tab_widget = self.find(QTabWidget, f"tabWidget") + + # This plot already exists + if name in self._additional_plot: + tab_widget.setCurrentWidget( + tab_widget.findChild(QWidget, wname) + ) + return + + widget = QWidget() + grid = QGridLayout() + + widget.setObjectName(wname) + + canvas = MplCanvas(width=5, height=4, dpi=100) + canvas.setObjectName(f"canvas_{x}_{y}") + toolbar = PamhyrPlotToolbar( + canvas, self + ) + + plot = CustomPlot( + x, y, + self._get_current_reach(), + self._get_current_profile(), + self._get_current_timestamp(), + data=self._results, + canvas=canvas, + toolbar=toolbar, + trad=self._trad, + parent=self, + ) + plot.draw() + + # Add plot to additional plot + self._additional_plot[name] = plot + + grid.addWidget(toolbar, 0, 0) + grid.addWidget(canvas, 1, 0) + widget.setLayout(grid) + tab_widget.addTab(widget, name) def _copy(self): logger.info("TODO: copy") From ae40097b5197268c4da7aa129e7f998a643e032a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 17 Oct 2023 11:27:00 +0200 Subject: [PATCH 04/36] Results: Fix pep8 format. --- src/View/Results/CustomPlot/Plot.py | 1 - src/View/Results/Window.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index ea1e2001..56fe6a99 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -79,7 +79,6 @@ class CustomPlot(PamhyrPlot): for axes in self._y_axis[1:]: logger.info(axes) ax_new = ax.twinx() - #ax_new.spines['right'].set_position(('axes', 1 + spacing * (n - 1))) ax_new.set_ylabel( self._trad[axes], color='green', fontsize=12 diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index eb8c5b1d..bc6e0500 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -435,7 +435,7 @@ class ResultsWindow(PamhyrWindow): x, y = dlg.value self.create_new_tab_custom_plot(x, y) - def create_new_tab_custom_plot(self, x:str, y:list): + def create_new_tab_custom_plot(self, x: str, y: list): name = f"{x}: {','.join(y)}" wname = f"tab_custom_{x}_{y}" From 4605950b94e91558aaf57bdaf0de40b29f4efd28 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 17 Oct 2023 17:10:58 +0200 Subject: [PATCH 05/36] Results: CustomPlot: Minor change. --- src/View/Results/CustomPlot/Plot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 56fe6a99..384116cd 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -50,6 +50,13 @@ class CustomPlot(PamhyrPlot): self._profile = profile self._timestamp = timestamp + logger.debug( + "Create custom plot for: " + + f"{x} -> {','.join(y)}: " + + f"reach={reach}, profile={profile}, " + + f"timestamp={timestamp}" + ) + self._y_axis = list( set( map( From f73000056c48fad02d79880f153151af580cfe80 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 18 Oct 2023 10:53:57 +0200 Subject: [PATCH 06/36] Results: CustomPlot: Fix kp / elevation, water_elevation and discharge drawing. --- src/View/Results/CustomPlot/Plot.py | 93 ++++++++++++++++++++---- src/View/Results/CustomPlot/Translate.py | 4 +- src/View/Results/Window.py | 1 - 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 384116cd..6c6f2ec8 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -23,12 +23,14 @@ from functools import reduce from tools import timer from View.Tools.PamhyrPlot import PamhyrPlot +from View.Results.CustomPlot.Translate import CustomPlotTranslate + logger = logging.getLogger() unit = { - "elevation": "meter", - "water_elevation": "meter", - "discharge": "m3s", + "elevation": "0-meter", + "water_elevation": "0-meter", + "discharge": "1-m3s", } @@ -38,7 +40,7 @@ class CustomPlot(PamhyrPlot): toolbar=None, parent=None): super(CustomPlot, self).__init__( canvas=canvas, - trad=trad, + trad=CustomPlotTranslate(), data=data, toolbar=toolbar, parent=parent @@ -57,10 +59,10 @@ class CustomPlot(PamhyrPlot): f"timestamp={timestamp}" ) - self._y_axis = list( + self._y_axes = sorted( set( map( - lambda y: self._trad[y], + lambda y: unit[y], self._y ) ) @@ -78,26 +80,91 @@ class CustomPlot(PamhyrPlot): self._trad[self._x], color='green', fontsize=12 ) + self.canvas.axes.set_ylabel( - self._trad[self._y_axis[0]], + self._trad[self._y_axes[0]], color='green', fontsize=12 ) - for axes in self._y_axis[1:]: - logger.info(axes) - ax_new = ax.twinx() + self._axes = {} + for axes in self._y_axes[1:]: + ax_new = self.canvas.axes.twinx() ax_new.set_ylabel( self._trad[axes], color='green', fontsize=12 ) + self._axes[axes] = ax_new if self._x is "kp": + results = self.data + reach = results.river.reach(self._reach) + kp = reach.geometry.get_kp() + z_min = reach.geometry.get_z_min() + + self.canvas.axes.set_xlim( + left=min(kp), right=max(kp) + ) + + meter_axes = self.canvas.axes + m3S_axes = self.canvas.axes + if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: + m3s_axes = self._axes["1-m3s"] + if "elevation" in self._y: - logging.info("TODO: kp/elevation") + meter_axes.set_ylim( + bottom=min(0, min(z_min)), + top=max(z_min) + 1 + ) + + meter_axes.plot( + kp, z_min, + color='grey', lw=1. + ) + if "water_elevation" in self._y: - logging.info("TODO: kp/water_elevation") + # Water elevation + water_z = list( + map( + lambda p: p.get_ts_key(self._timestamp, "Z"), + reach.profiles + ) + ) + + + meter_axes.set_ylim( + bottom=min(0, min(z_min)), + top=max(water_z) + 1 + ) + + meter_axes.plot( + kp, water_z, lw=1., + color='b', + ) + + if "elevation" in self._y: + meter_axes.fill_between( + kp, z_min, water_z, + color='blue', alpha=0.5, interpolate=True + ) + if "discharge" in self._y: - logging.info("TODO: kp/discharge") + q = list( + map( + lambda p: p.get_ts_key(self._timestamp, "Q"), + reach.profiles + ) + ) + + m3s_axes.set_ylim( + bottom=min(0, min(q)), + top=max(q) + 1 + ) + + m3s_axes.plot( + kp, q, lw=1., + color='r', + ) + elif self._x is "time": if "elevation" in self._y: logging.info("TODO: time/elevation") diff --git a/src/View/Results/CustomPlot/Translate.py b/src/View/Results/CustomPlot/Translate.py index aee28e0d..555950cd 100644 --- a/src/View/Results/CustomPlot/Translate.py +++ b/src/View/Results/CustomPlot/Translate.py @@ -47,10 +47,10 @@ class CustomPlotTranslate(ResultsTranslate): # Unit corresponding long name (plot axes display) - self._dict['meter'] = _translate( + self._dict['0-meter'] = _translate( "CustomPlot", "Elevation (m)" ) - self._dict['m3s'] = _translate( + self._dict['1-m3s'] = _translate( "CustomPlot", "Discharge (m³/s)" ) diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index bc6e0500..ca53d59d 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -467,7 +467,6 @@ class ResultsWindow(PamhyrWindow): data=self._results, canvas=canvas, toolbar=toolbar, - trad=self._trad, parent=self, ) plot.draw() From 04c8f1ae5f60e98967053ac12efe4b996b1018c6 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Wed, 18 Oct 2023 14:02:55 +0200 Subject: [PATCH 07/36] Results: CustomPlot: Minor change to fix warnings. --- src/View/Results/CustomPlot/Plot.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 6c6f2ec8..6bfe6aba 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -95,7 +95,7 @@ class CustomPlot(PamhyrPlot): ) self._axes[axes] = ax_new - if self._x is "kp": + if self._x == "kp": results = self.data reach = results.river.reach(self._reach) kp = reach.geometry.get_kp() @@ -130,7 +130,6 @@ class CustomPlot(PamhyrPlot): ) ) - meter_axes.set_ylim( bottom=min(0, min(z_min)), top=max(water_z) + 1 @@ -165,7 +164,7 @@ class CustomPlot(PamhyrPlot): color='r', ) - elif self._x is "time": + elif self._x == "time": if "elevation" in self._y: logging.info("TODO: time/elevation") if "water_elevation" in self._y: From 4c40f675b58623680fa980d3170f881200d02db9 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 09:25:19 +0200 Subject: [PATCH 08/36] IC: Fix #14. --- src/Model/InitialConditions/InitialConditions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/InitialConditions/InitialConditions.py b/src/Model/InitialConditions/InitialConditions.py index 38cb9646..c997960f 100644 --- a/src/Model/InitialConditions/InitialConditions.py +++ b/src/Model/InitialConditions/InitialConditions.py @@ -174,7 +174,7 @@ class Data(SQLSubModel): def _update_from_kp(self): min = self._update_get_min() - self._elevation = min - self._height + self._elevation = min + self._height def _update_from_elevation(self): min = self._update_get_min() From 8760cd364d9cb0baf6403236b9b0428a2108c393 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 10:48:07 +0200 Subject: [PATCH 09/36] Pamhyr: Implement window duplication deny mechanism with hash (#7). --- src/View/CheckList/Window.py | 3 + src/View/Frictions/Window.py | 3 + src/View/Geometry/Window.py | 3 + src/View/InitialConditions/Window.py | 3 + src/View/MainWindow.py | 235 ++++++++++++++++-------- src/View/SedimentLayers/Reach/Window.py | 10 +- src/View/Tools/ASubWindow.py | 4 +- src/View/Tools/ListedSubWindow.py | 46 +---- src/View/Tools/PamhyrWindow.py | 1 - 9 files changed, 186 insertions(+), 122 deletions(-) diff --git a/src/View/CheckList/Window.py b/src/View/CheckList/Window.py index 0004f3e9..d96994a8 100644 --- a/src/View/CheckList/Window.py +++ b/src/View/CheckList/Window.py @@ -68,6 +68,9 @@ class CheckListWindow(PamhyrWindow): parent=parent ) + # Add solver to hash computation data + self._hash_data.append(self._solver) + self._checker_list = ( self._study.checkers() + self._solver.checkers() diff --git a/src/View/Frictions/Window.py b/src/View/Frictions/Window.py index 2558ab43..b6dd6afa 100644 --- a/src/View/Frictions/Window.py +++ b/src/View/Frictions/Window.py @@ -83,6 +83,9 @@ class FrictionsWindow(PamhyrWindow): parent=parent ) + # Add reach to hash computation data + self._hash_data.append(self._reach) + self.setup_table() self.setup_graph() self.setup_connections() diff --git a/src/View/Geometry/Window.py b/src/View/Geometry/Window.py index eca958b4..ac021ad1 100644 --- a/src/View/Geometry/Window.py +++ b/src/View/Geometry/Window.py @@ -73,6 +73,9 @@ class GeometryWindow(PamhyrWindow): parent=parent ) + # Add reach to hash computation data + self._hash_data.append(self._reach) + self._tablemodel = None self._profile_window = [] diff --git a/src/View/InitialConditions/Window.py b/src/View/InitialConditions/Window.py index 6fb29ca2..a0911c4b 100644 --- a/src/View/InitialConditions/Window.py +++ b/src/View/InitialConditions/Window.py @@ -87,6 +87,9 @@ class InitialConditionsWindow(PamhyrWindow): parent=parent ) + # Add reach to hash computation data + self._hash_data.append(self._reach) + self._ics = study.river.initial_conditions.get(self._reach) self.setup_table() diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 2c77ed71..11604503 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -480,6 +480,30 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # SUBWINDOW # ############# + def _sub_window_exists(self, cls, + data=None): + """Check if window already exists + + Check if window already exists, used to deni window open + duplication + + Args: + cls: Window class, must inerit to PamhyrWindow or + PamhyrDialog + data: Data used for hash computation of cls + + Returns: + The window if hash already exists on sub window dictionary, + otherelse None + """ + hash = cls._hash(data) + if self.sub_win_exists(hash): + win = self.get_sub_win(hash) + win.activateWindow() + return True + else: + return False + def open_configure(self): """Open configure window @@ -488,6 +512,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Returns: Nothing """ + if self._sub_window_exists( + ConfigureWindow, + data=[None, self.conf] + ): + return + self.config = ConfigureWindow(config=self.conf, parent=self) self.config.show() @@ -499,6 +529,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Returns: Nothing """ + if self._sub_window_exists( + AboutWindow, + data=[None, None] + ): + return + self.about = AboutWindow(parent=self) self.about.show() @@ -527,6 +563,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is None: + if self._sub_window_exists( + NewStudyWindow, + data=[None, None] + ): + return + self.new_study = NewStudyWindow(parent=self) self.new_study.show() @@ -537,6 +579,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is not None: + if self._sub_window_exists( + NewStudyWindow, + data=[self._study, None] + ): + return + self.new_study = NewStudyWindow(study=self._study, parent=self) self.new_study.show() @@ -547,11 +595,14 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is not None: - if not self.sub_win_exists("River network"): - self.network = NetworkWindow(study=self._study, parent=self) - self.network.show() - else: - self.network.activateWindow() + if self._sub_window_exists( + NetworkWindow, + data=[self._study, None] + ): + return + + self.network = NetworkWindow(study=self._study, parent=self) + self.network.show() def open_geometry(self): """Open geometry window @@ -560,115 +611,117 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if (self._study is not None and self._study.river.has_current_reach()): - geometry = self.sub_win_filter_first( - "Geometry", - contain=[self._study.river.current_reach().name] - ) + reach = self._study.river.current_reach().reach - if geometry is None: - geometry = GeometryWindow( - study=self._study, config=self.conf, parent=self) - geometry.show() - else: - geometry.activateWindow() + if self._sub_window_exists( + GeometryWindow, + data=[self._study, self.conf, reach] + ): + return + + geometry = GeometryWindow( + study=self._study, + config=self.conf, + reach=reach, + parent=self + ) + geometry.show() else: self.msg_select_reach() def open_boundary_cond(self): - bound = self.sub_win_filter_first( - "Boundary conditions", - contain=[] - ) + if self._sub_window_exists( + BoundaryConditionWindow, + data=[self._study, None] + ): + return - if bound is None: - bound = BoundaryConditionWindow(study=self._study, parent=self) - bound.show() - else: - bound.activateWindow() + bound = BoundaryConditionWindow(study=self._study, parent=self) + bound.show() def open_lateral_contrib(self): - lateral = self.sub_win_filter_first( - "Lateral contribution", - contain=[] - ) + if self._sub_window_exists( + LateralContributionWindow, + data=[self._study, None] + ): + return - if lateral is None: - lateral = LateralContributionWindow(study=self._study, parent=self) - lateral.show() - else: - lateral.activateWindow() + lateral = LateralContributionWindow(study=self._study, parent=self) + lateral.show() def open_stricklers(self): - strick = self.sub_win_filter_first( - "Stricklers", - contain=[] - ) + if self._sub_window_exists( + StricklersWindow, + data=[self._study, self.conf] + ): + return - if strick is None: - strick = StricklersWindow( - study=self._study, - config=self.conf, - parent=self - ) - strick.show() - else: - strick.activateWindow() + strick = StricklersWindow( + study=self._study, + config=self.conf, + parent=self + ) + strick.show() def open_frictions(self): - if (self._study is not None and - self._study.river.has_current_reach()): + if self._study is not None: + if self._study.river.has_current_reach(): + reach = self._study.river.current_reach() - frictions = self.sub_win_filter_first( - "Frictions", - contain=[self._study.river.current_reach().name] - ) + if self._sub_window_exists( + FrictionsWindow, + data=[self._study, None, reach] + ): + return - if frictions is None: frictions = FrictionsWindow( study=self._study, parent=self ) frictions.show() else: - frictions.activateWindow() - else: - self.msg_select_reach() + self.msg_select_reach() def open_initial_conditions(self): if self._study.river.has_current_reach(): - initial = self.sub_win_filter_first( - "Initial condition", - contain=[self._study.river.current_reach().name] - ) + reach = self._study.river.current_reach() - if initial is None: - initial = InitialConditionsWindow( - study=self._study, - config=self.conf, - parent=self - ) - initial.show() - else: - initial.activateWindow() + if self._sub_window_exists( + InitialConditionsWindow, + data=[self._study, self.conf, reach] + ): + return + + initial = InitialConditionsWindow( + study=self._study, + config=self.conf, + reach=reach, + parent=self + ) + initial.show() else: self.msg_select_reach() def open_solver_parameters(self): - params = self.sub_win_filter_first( - "Solver parameters", - contain=[] - ) + if self._sub_window_exists( + SolverParametersWindow, + data=[self._study, None] + ): + return - if params is None: - params = SolverParametersWindow( - study=self._study, - parent=self - ) - params.show() - else: - params.activateWindow() + params = SolverParametersWindow( + study=self._study, + parent=self + ) + params.show() def open_sediment_layers(self): + if self._sub_window_exists( + SedimentLayersWindow, + data=[self._study, None] + ): + return + sl = SedimentLayersWindow( study=self._study, parent=self @@ -676,8 +729,17 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): sl.show() def open_reach_sediment_layers(self): + reach = self._study.river.current_reach().reach + + if self._sub_window_exists( + ReachSedimentLayersWindow, + data=[self._study, None, reach] + ): + return + sl = ReachSedimentLayersWindow( study=self._study, + reach=reach, parent=self ) sl.show() @@ -693,6 +755,17 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): ) if run.exec(): solver = run.solver + + if self._sub_window_exists( + CheckListWindow, + data=[ + self._study, + self.conf, + solver + ] + ): + return + check = CheckListWindow( study=self._study, config=self.conf, diff --git a/src/View/SedimentLayers/Reach/Window.py b/src/View/SedimentLayers/Reach/Window.py index 6c4cad96..854e06a5 100644 --- a/src/View/SedimentLayers/Reach/Window.py +++ b/src/View/SedimentLayers/Reach/Window.py @@ -46,9 +46,12 @@ class ReachSedimentLayersWindow(PamhyrWindow): _pamhyr_ui = "ReachSedimentLayers" _pamhyr_name = "Reach sediment layers" - def __init__(self, study=None, config=None, parent=None): + def __init__(self, study=None, config=None, reach=None, parent=None): self._sediment_layers = study.river.sediment_layers - self._reach = study.river.current_reach().reach + if reach is None: + self._reach = study.river.current_reach().reach + else: + self._reach = reach name = ( self._pamhyr_name + " - " + @@ -64,6 +67,9 @@ class ReachSedimentLayersWindow(PamhyrWindow): parent=parent ) + # Add reach to hash computation data + self._hash_data.append(self._reach) + self.setup_table() self.setup_plot() self.setup_connections() diff --git a/src/View/Tools/ASubWindow.py b/src/View/Tools/ASubWindow.py index a2e8fc88..0a3ca2ae 100644 --- a/src/View/Tools/ASubWindow.py +++ b/src/View/Tools/ASubWindow.py @@ -483,7 +483,7 @@ class ASubMainWindow(QMainWindow, ASubWindowFeatures, WindowToolKit): def closeEvent(self, event): if self.parent is not None: - self.parent.sub_win_del(self.name) + self.parent.sub_win_del(self.hash()) def find(self, qtype, name): """Find an ui component @@ -520,7 +520,7 @@ class ASubWindow(QDialog, ASubWindowFeatures, WindowToolKit): def closeEvent(self, event): if self.parent is not None: - self.parent.sub_win_del(self.name) + self.parent.sub_win_del(self.hash()) def find(self, qtype, name): """Find an ui component diff --git a/src/View/Tools/ListedSubWindow.py b/src/View/Tools/ListedSubWindow.py index b548cb31..5c3bf1dd 100644 --- a/src/View/Tools/ListedSubWindow.py +++ b/src/View/Tools/ListedSubWindow.py @@ -42,59 +42,33 @@ class ListedSubWindow(object): f"Open window: {name}: {self.sub_win_cnt}: {win.hash()}") except Exception: logger.info(f"Open window: {name}: {self.sub_win_cnt}: X") + logger.warning(f"Sub window without hash method !") - def sub_win_del(self, name): + def sub_win_del(self, h): self.sub_win_list = list( filter( - lambda x: x[0] != name, + lambda x: x[1].hash() != h, self.sub_win_list ) ) self.sub_win_cnt = len(self.sub_win_list) - logger.info(f"Close window: {name}: {self.sub_win_cnt}") + logger.info(f"Close window: {h}: {self.sub_win_cnt}") - def _sub_win_exists(self, name): + def _sub_win_exists(self, h): return reduce( - lambda acc, n: (acc or (n[0] == name)), + lambda acc, el: (acc or (h == (el[1].hash()))), self.sub_win_list, False ) - def _sub_win_exists_with_contain(self, name, contain): - return reduce( - lambda acc, n: ( - acc or - ( - (n[0] == name) and - reduce( - lambda acc, c: acc and (c in n[1]._title), - contain, - True - ) - ) - ), - self.sub_win_list, - False - ) + def sub_win_exists(self, h): + return self._sub_win_exists(h) - def sub_win_exists(self, name, contain=[]): - if contain == []: - return self._sub_win_exists(name) - else: - return self._sub_win_exists_with_contain(name, contain) - - def sub_win_filter_first(self, name, contain): + def get_sub_win(self, h): try: return next( filter( - lambda n: ( - (name in n[0]) and - reduce( - lambda acc, c: acc and (c in n[1]._title), - contain, - True - ) - ), + lambda el: (h == el[1].hash()), self.sub_win_list, ) )[1] diff --git a/src/View/Tools/PamhyrWindow.py b/src/View/Tools/PamhyrWindow.py index a3c7d4c3..74756167 100644 --- a/src/View/Tools/PamhyrWindow.py +++ b/src/View/Tools/PamhyrWindow.py @@ -122,7 +122,6 @@ class PamhyrWindowTools(object): hash_str += repr(el) h = hash(hash_str) - logger.debug(f"Compute hash = {h} for window {cls._pamhyr_name}") return h From e4386e20a3d6ee8582f36b8ddffc9fdf0e2ba36f Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 11:45:28 +0200 Subject: [PATCH 10/36] Network: Fix item selection. --- src/View/Network/GraphWidget.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/View/Network/GraphWidget.py b/src/View/Network/GraphWidget.py index 9c24b4f0..ade0eaff 100644 --- a/src/View/Network/GraphWidget.py +++ b/src/View/Network/GraphWidget.py @@ -442,7 +442,7 @@ class GraphWidget(QGraphicsView): Nothing """ for i in self.texts: - if i is NodeItem: + if type(i) is NodeItem: self.texts[i].rename() def enable_edge(self, edge, prev): @@ -699,11 +699,11 @@ class GraphWidget(QGraphicsView): self._selected_new_edge_src_node = None items = self.items(event.pos()) - if items and items[0] is EdgeItem: + if items and type(items[0]) is EdgeItem: edge = items[0] if edge: self.set_current_edge(edge) - elif items and items[0] is NodeItem: + elif items and type(items[0]) is NodeItem: self._mouse_origin_x = pos.x() self._mouse_origin_y = pos.y() self._current_moved_node = items[0] @@ -711,7 +711,7 @@ class GraphWidget(QGraphicsView): # Add nodes and edges elif self._state == "add": items = self.items(event.pos()) - nodes = list(filter(lambda i: i is NodeItem, items)) + nodes = list(filter(lambda i: type(i) is NodeItem, items)) if not nodes: self.add_node(pos) else: @@ -725,15 +725,15 @@ class GraphWidget(QGraphicsView): self._selected_new_edge_src_node = None items = list( filter( - lambda i: i is NodeItem or i is EdgeItem, + lambda i: type(i) is NodeItem or type(i) is EdgeItem, self.items(event.pos()) ) ) if len(items) > 0: item = items[0] - if item is NodeItem: + if type(item) is NodeItem: self.del_node(item) - elif item is EdgeItem: + elif type(item) is EdgeItem: self.del_edge(item) self.update() @@ -767,7 +767,7 @@ class GraphWidget(QGraphicsView): items = self.items(event.pos()) selectable_items = list( filter( - lambda i: (i is NodeItem or i is EdgeItem), + lambda i: (type(i) is NodeItem or type(i) is EdgeItem), items ) ) @@ -858,7 +858,7 @@ class GraphWidget(QGraphicsView): menu = QMenu(self) if len(items) == 0: self._menu_default(event, pos, items, menu) - elif items[0] is NodeItem: + elif type(items[0]) is NodeItem: self._menu_node(event, pos, items, menu) - elif items[0] is EdgeItem: + elif type(items[0]) is EdgeItem: self._menu_edge(event, pos, items, menu) From 021971816a1410a1e1e8fb4640ee2d7283e0b0b7 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 15:09:24 +0200 Subject: [PATCH 11/36] Results: CustomPlot: Implement plot with time on y axes. --- src/View/Results/CustomPlot/Plot.py | 245 +++++++++++++++++++--------- src/View/Results/Window.py | 17 +- 2 files changed, 178 insertions(+), 84 deletions(-) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 6bfe6aba..a539112a 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -68,6 +68,151 @@ class CustomPlot(PamhyrPlot): ) ) + self._axes = {} + + def _draw_kp(self): + results = self.data + reach = results.river.reach(self._reach) + kp = reach.geometry.get_kp() + z_min = reach.geometry.get_z_min() + + self.canvas.axes.set_xlim( + left=min(kp), right=max(kp) + ) + + meter_axes = self.canvas.axes + m3S_axes = self.canvas.axes + if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: + m3s_axes = self._axes["1-m3s"] + + if "elevation" in self._y: + meter_axes.set_ylim( + bottom=min(0, min(z_min)), + top=max(z_min) + 1 + ) + + meter_axes.plot( + kp, z_min, + color='grey', lw=1. + ) + + if "water_elevation" in self._y: + # Water elevation + water_z = list( + map( + lambda p: p.get_ts_key(self._timestamp, "Z"), + reach.profiles + ) + ) + + meter_axes.set_ylim( + bottom=min(0, min(z_min)), + top=max(water_z) + 1 + ) + + meter_axes.plot( + kp, water_z, lw=1., + color='b', + ) + + if "elevation" in self._y: + meter_axes.fill_between( + kp, z_min, water_z, + color='blue', alpha=0.5, interpolate=True + ) + + if "discharge" in self._y: + q = list( + map( + lambda p: p.get_ts_key(self._timestamp, "Q"), + reach.profiles + ) + ) + + m3s_axes.set_ylim( + bottom=min(0, min(q)), + top=max(q) + 1 + ) + + m3s_axes.plot( + kp, q, lw=1., + color='r', + ) + + def _draw_time(self): + results = self.data + reach = results.river.reach(self._reach) + profile = reach.profile(self._profile) + + meter_axes = self.canvas.axes + m3S_axes = self.canvas.axes + if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: + m3s_axes = self._axes["1-m3s"] + + ts = list(results.get("timestamps")) + ts.sort() + + self.canvas.axes.set_xlim( + left=min(ts), right=max(ts) + ) + + x = ts + if "elevation" in self._y: + # Z min is constant in time + z_min = profile.geometry.z_min() + ts_z_min = list( + map( + lambda ts: z_min, + ts + ) + ) + + meter_axes.plot( + ts, ts_z_min, + color='grey', lw=1. + ) + + if "water_elevation" in self._y: + # Water elevation + z = profile.get_key("Z") + + meter_axes.set_ylim( + bottom=min(0, min(z)), + top=max(z) + 1 + ) + + meter_axes.plot( + ts, z, lw=1., + color='b', + ) + + if "elevation" in self._y: + z_min = profile.geometry.z_min() + ts_z_min = list( + map( + lambda ts: z_min, + ts + ) + ) + + meter_axes.fill_between( + ts, ts_z_min, z, + color='blue', alpha=0.5, interpolate=True + ) + + if "discharge" in self._y: + q = profile.get_key("Q") + + m3s_axes.set_ylim( + bottom=min(0, min(q)), + top=max(q) + 1 + ) + + m3s_axes.plot( + ts, q, lw=1., + color='r', + ) + @timer def draw(self): self.canvas.axes.cla() @@ -86,8 +231,10 @@ class CustomPlot(PamhyrPlot): color='green', fontsize=12 ) - self._axes = {} for axes in self._y_axes[1:]: + if axes in self._axes: + continue + ax_new = self.canvas.axes.twinx() ax_new.set_ylabel( self._trad[axes], @@ -96,81 +243,9 @@ class CustomPlot(PamhyrPlot): self._axes[axes] = ax_new if self._x == "kp": - results = self.data - reach = results.river.reach(self._reach) - kp = reach.geometry.get_kp() - z_min = reach.geometry.get_z_min() - - self.canvas.axes.set_xlim( - left=min(kp), right=max(kp) - ) - - meter_axes = self.canvas.axes - m3S_axes = self.canvas.axes - if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: - m3s_axes = self._axes["1-m3s"] - - if "elevation" in self._y: - meter_axes.set_ylim( - bottom=min(0, min(z_min)), - top=max(z_min) + 1 - ) - - meter_axes.plot( - kp, z_min, - color='grey', lw=1. - ) - - if "water_elevation" in self._y: - # Water elevation - water_z = list( - map( - lambda p: p.get_ts_key(self._timestamp, "Z"), - reach.profiles - ) - ) - - meter_axes.set_ylim( - bottom=min(0, min(z_min)), - top=max(water_z) + 1 - ) - - meter_axes.plot( - kp, water_z, lw=1., - color='b', - ) - - if "elevation" in self._y: - meter_axes.fill_between( - kp, z_min, water_z, - color='blue', alpha=0.5, interpolate=True - ) - - if "discharge" in self._y: - q = list( - map( - lambda p: p.get_ts_key(self._timestamp, "Q"), - reach.profiles - ) - ) - - m3s_axes.set_ylim( - bottom=min(0, min(q)), - top=max(q) + 1 - ) - - m3s_axes.plot( - kp, q, lw=1., - color='r', - ) - + self._draw_kp() elif self._x == "time": - if "elevation" in self._y: - logging.info("TODO: time/elevation") - if "water_elevation" in self._y: - logging.info("TODO: time/water_elevation") - if "discharge" in self._y: - logging.info("TODO: time/discharge") + self._draw_time() self.canvas.figure.tight_layout() self.canvas.figure.canvas.draw_idle() @@ -178,7 +253,25 @@ class CustomPlot(PamhyrPlot): self.toolbar.update() @timer - def update(self, reach, profile, timestamp): + def update(self): if not self._init: self.draw() return + + def set_reach(self, reach_id): + self._reach = reach_id + self._profile = 0 + + self.update() + + def set_profile(self, profile_id): + self._profile = profile_id + + if self._x != "kp": + self.update() + + def set_timestamp(self, timestamp): + self._timestamp = timestamp + + if self._x != "time": + self.update() diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index ca53d59d..eb6f7aaa 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -345,6 +345,9 @@ class ResultsWindow(PamhyrWindow): self.plot_sed_reach.set_reach(reach_id) self.plot_sed_profile.set_reach(reach_id) + for plot in self._additional_plot: + self._additional_plot[plot].set_reach(reach_id) + self.update_table_selection_reach(reach_id) self.update_table_selection_profile(0) @@ -358,7 +361,11 @@ class ResultsWindow(PamhyrWindow): self.plot_sed_reach.set_profile(profile_id) self.plot_sed_profile.set_profile(profile_id) + for plot in self._additional_plot: + self._additional_plot[plot].set_profile(profile_id) + self.update_table_selection_profile(profile_id) + if timestamp is not None: self.plot_xy.set_timestamp(timestamp) self.plot_ac.set_timestamp(timestamp) @@ -369,14 +376,8 @@ class ResultsWindow(PamhyrWindow): self.plot_sed_reach.set_timestamp(timestamp) self.plot_sed_profile.set_timestamp(timestamp) - self.plot_xy.draw() - self.plot_ac.draw() - self.plot_kpc.draw() - self.plot_h.draw() - - if self._study.river.has_sediment(): - self.plot_sed_reach.draw() - self.plot_sed_profile.draw() + for plot in self._additional_plot: + self._additional_plot[plot].set_timestamp(timestamp) self.update_statusbar() From cdfb867037a39c10400e6e58e66d71f2f47e7561 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 16:27:07 +0200 Subject: [PATCH 12/36] Results: CustomPlot: Add custom time axes. --- src/View/Results/CustomPlot/Plot.py | 43 +++++++++++++++++++++++++++++ src/View/Results/translate.py | 7 +++++ 2 files changed, 50 insertions(+) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index a539112a..4712c45b 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -19,6 +19,7 @@ import logging from functools import reduce +from datetime import datetime from tools import timer from View.Tools.PamhyrPlot import PamhyrPlot @@ -139,6 +140,46 @@ class CustomPlot(PamhyrPlot): color='r', ) + def _customize_x_axes_time(self, ts, mode="time"): + # Custom time display + nb = len(ts) + mod = int(nb / 5) + mod = mod if mod > 0 else nb + + fx = list( + map( + lambda x: x[1], + filter( + lambda x: x[0] % mod == 0, + enumerate(ts) + ) + ) + ) + + if mode == "time": + t0 = datetime.fromtimestamp(0) + xt = list( + map( + lambda v: ( + str( + datetime.fromtimestamp(v) - t0 + ).split(",")[0] + .replace("days", self._trad["days"]) + .replace("day", self._trad["day"]) + ), + fx + ) + ) + else: + xt = list( + map( + lambda v: str(datetime.fromtimestamp(v).date()), + fx + ) + ) + + self.canvas.axes.set_xticks(ticks=fx, labels=xt, rotation=45) + def _draw_time(self): results = self.data reach = results.river.reach(self._reach) @@ -213,6 +254,8 @@ class CustomPlot(PamhyrPlot): color='r', ) + self._customize_x_axes_time(ts) + @timer def draw(self): self.canvas.axes.cla() diff --git a/src/View/Results/translate.py b/src/View/Results/translate.py index ae9dcad1..4de61cc7 100644 --- a/src/View/Results/translate.py +++ b/src/View/Results/translate.py @@ -27,6 +27,13 @@ class ResultsTranslate(PamhyrTranslate): def __init__(self): super(ResultsTranslate, self).__init__() + self._dict['day'] = _translate( + "Results", "day" + ) + self._dict['days'] = _translate( + "Results", "days" + ) + self._sub_dict["table_headers_reach"] = { "name": _translate("Results", "Reach name"), } From 32aa5bece59f9bc8a059a2b3dc7ef511ef0fd120 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 16:48:21 +0200 Subject: [PATCH 13/36] Results: CustomPlot: Add legend and change axes label fontsize for each plot. --- src/View/Geometry/PlotAC.py | 8 +-- src/View/Geometry/PlotKPZ.py | 4 +- src/View/Geometry/PlotXY.py | 4 +- src/View/Results/CustomPlot/Plot.py | 50 +++++++++++++++---- src/View/Results/PlotH.py | 4 +- src/View/Results/PlotSedProfile.py | 4 +- src/View/Results/PlotSedReach.py | 4 +- src/View/Results/PlotXY.py | 4 +- src/View/SedimentLayers/Edit/Plot.py | 2 +- src/View/SedimentLayers/Reach/Plot.py | 4 +- src/View/SedimentLayers/Reach/Profile/Plot.py | 4 +- 11 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/View/Geometry/PlotAC.py b/src/View/Geometry/PlotAC.py index 1d7bf8d8..7cc75184 100644 --- a/src/View/Geometry/PlotAC.py +++ b/src/View/Geometry/PlotAC.py @@ -72,11 +72,11 @@ class PlotAC(PamhyrPlot): self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "Transverse abscissa (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Height (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.figure.tight_layout() @@ -176,11 +176,11 @@ class PlotAC(PamhyrPlot): self.canvas.axes.grid(color='grey', linestyle='--', linewidth=0.5) self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "Abscisse en travers (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Cote (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.figure.tight_layout() diff --git a/src/View/Geometry/PlotKPZ.py b/src/View/Geometry/PlotKPZ.py index 41b08a8f..79fbfe05 100644 --- a/src/View/Geometry/PlotKPZ.py +++ b/src/View/Geometry/PlotKPZ.py @@ -64,11 +64,11 @@ class PlotKPZ(PamhyrPlot): self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "Kp (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Height (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) kp = self.data.get_kp() diff --git a/src/View/Geometry/PlotXY.py b/src/View/Geometry/PlotXY.py index a851cc82..fcd55ecc 100644 --- a/src/View/Geometry/PlotXY.py +++ b/src/View/Geometry/PlotXY.py @@ -65,11 +65,11 @@ class PlotXY(PamhyrPlot): # Axes self.canvas.axes.set_xlabel( _translate("Geometry", "X (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("Geometry", "Y (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.axis("equal") diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 4712c45b..9733448f 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -86,16 +86,19 @@ class CustomPlot(PamhyrPlot): if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: m3s_axes = self._axes["1-m3s"] + + lines = {} if "elevation" in self._y: meter_axes.set_ylim( bottom=min(0, min(z_min)), top=max(z_min) + 1 ) - meter_axes.plot( + line = meter_axes.plot( kp, z_min, - color='grey', lw=1. + color='grey', lw=1., ) + lines["elevation"] = line if "water_elevation" in self._y: # Water elevation @@ -111,10 +114,11 @@ class CustomPlot(PamhyrPlot): top=max(water_z) + 1 ) - meter_axes.plot( + line = meter_axes.plot( kp, water_z, lw=1., - color='b', + color='blue', ) + lines["water_elevation"] = line if "elevation" in self._y: meter_axes.fill_between( @@ -135,10 +139,20 @@ class CustomPlot(PamhyrPlot): top=max(q) + 1 ) - m3s_axes.plot( + line = m3s_axes.plot( kp, q, lw=1., color='r', ) + lines["discharge"] = line + + # Legend + lns = reduce( + lambda acc, l: acc + l, + map(lambda l: lines[l], lines), + [] + ) + labs = list(map(lambda l: self._trad[l], lines)) + self.canvas.axes.legend(lns, labs, loc="lower left") def _customize_x_axes_time(self, ts, mode="time"): # Custom time display @@ -198,6 +212,7 @@ class CustomPlot(PamhyrPlot): ) x = ts + lines = {} if "elevation" in self._y: # Z min is constant in time z_min = profile.geometry.z_min() @@ -208,10 +223,11 @@ class CustomPlot(PamhyrPlot): ) ) - meter_axes.plot( + line = meter_axes.plot( ts, ts_z_min, color='grey', lw=1. ) + lines["elevation"] = line if "water_elevation" in self._y: # Water elevation @@ -222,10 +238,11 @@ class CustomPlot(PamhyrPlot): top=max(z) + 1 ) - meter_axes.plot( + line = meter_axes.plot( ts, z, lw=1., color='b', ) + lines["water_elevation"] = line if "elevation" in self._y: z_min = profile.geometry.z_min() @@ -249,13 +266,24 @@ class CustomPlot(PamhyrPlot): top=max(q) + 1 ) - m3s_axes.plot( + line = m3s_axes.plot( ts, q, lw=1., color='r', ) + lines["discharge"] = line + self._customize_x_axes_time(ts) + # Legend + lns = reduce( + lambda acc, l: acc + l, + map(lambda l: lines[l], lines), + [] + ) + labs = list(map(lambda l: self._trad[l], lines)) + self.canvas.axes.legend(lns, labs, loc="lower left") + @timer def draw(self): self.canvas.axes.cla() @@ -266,12 +294,12 @@ class CustomPlot(PamhyrPlot): self.canvas.axes.set_xlabel( self._trad[self._x], - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( self._trad[self._y_axes[0]], - color='green', fontsize=12 + color='green', fontsize=10 ) for axes in self._y_axes[1:]: @@ -281,7 +309,7 @@ class CustomPlot(PamhyrPlot): ax_new = self.canvas.axes.twinx() ax_new.set_ylabel( self._trad[axes], - color='green', fontsize=12 + color='green', fontsize=10 ) self._axes[axes] = ax_new diff --git a/src/View/Results/PlotH.py b/src/View/Results/PlotH.py index dd5a332b..2af2ea41 100644 --- a/src/View/Results/PlotH.py +++ b/src/View/Results/PlotH.py @@ -77,11 +77,11 @@ class PlotH(PamhyrPlot): # Axes self.canvas.axes.set_xlabel( _translate("Results", "Time (s)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("Results", "Discharge (m³/s)"), - color='green', fontsize=12 + color='green', fontsize=10 ) ts = list(self.results.get("timestamps")) diff --git a/src/View/Results/PlotSedProfile.py b/src/View/Results/PlotSedProfile.py index d16fb0b8..013480d9 100644 --- a/src/View/Results/PlotSedProfile.py +++ b/src/View/Results/PlotSedProfile.py @@ -122,11 +122,11 @@ class PlotSedProfile(PamhyrPlot): self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "X (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Height (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) x = profile.geometry.get_station() diff --git a/src/View/Results/PlotSedReach.py b/src/View/Results/PlotSedReach.py index 5cdc4ad7..9e49c076 100644 --- a/src/View/Results/PlotSedReach.py +++ b/src/View/Results/PlotSedReach.py @@ -203,11 +203,11 @@ class PlotSedReach(PamhyrPlot): self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "Kp (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Height (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) kp = reach.geometry.get_kp() diff --git a/src/View/Results/PlotXY.py b/src/View/Results/PlotXY.py index 5a513322..384e2813 100644 --- a/src/View/Results/PlotXY.py +++ b/src/View/Results/PlotXY.py @@ -78,11 +78,11 @@ class PlotXY(PamhyrPlot): # Axes self.canvas.axes.set_xlabel( _translate("Results", "X (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("Results", "Y (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.axis("equal") diff --git a/src/View/SedimentLayers/Edit/Plot.py b/src/View/SedimentLayers/Edit/Plot.py index 87cfe644..69e58f83 100644 --- a/src/View/SedimentLayers/Edit/Plot.py +++ b/src/View/SedimentLayers/Edit/Plot.py @@ -35,7 +35,7 @@ class Plot(PamhyrPlot): self.canvas.axes.axes.get_xaxis().set_visible(False) self.canvas.axes.set_ylabel( self._trad["height"], - color='green', fontsize=12 + color='green', fontsize=10 ) if self.data is None: diff --git a/src/View/SedimentLayers/Reach/Plot.py b/src/View/SedimentLayers/Reach/Plot.py index 3664081c..1bea651f 100644 --- a/src/View/SedimentLayers/Reach/Plot.py +++ b/src/View/SedimentLayers/Reach/Plot.py @@ -46,11 +46,11 @@ class Plot(PamhyrPlot): self.canvas.axes.set_xlabel( self._trad["kp"], - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( self._trad["height"], - color='green', fontsize=12 + color='green', fontsize=10 ) kp = self.data.get_kp() diff --git a/src/View/SedimentLayers/Reach/Profile/Plot.py b/src/View/SedimentLayers/Reach/Profile/Plot.py index 38adb990..5000ca9b 100644 --- a/src/View/SedimentLayers/Reach/Profile/Plot.py +++ b/src/View/SedimentLayers/Reach/Profile/Plot.py @@ -46,11 +46,11 @@ class Plot(PamhyrPlot): self.canvas.axes.set_xlabel( _translate("MainWindow_reach", "X (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) self.canvas.axes.set_ylabel( _translate("MainWindow_reach", "Height (m)"), - color='green', fontsize=12 + color='green', fontsize=10 ) x = self.data.get_station() From 4d1eb0fecc5ad5fdfd4adf9ceed450b6bc33a452 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 16:55:06 +0200 Subject: [PATCH 14/36] View: Fix results window open. --- src/View/MainWindow.py | 31 +++++++++++++++++-------------- src/View/Results/Window.py | 3 +++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 11604503..27e09ad1 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -793,21 +793,24 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): return # Windows already opened - res = self.sub_win_filter_first( - "Results", - contain=[solver.name, results.date] - ) + if self._sub_window_exists( + ResultsWindow, + data=[ + self._study, + None, # No config + solver, + results + ] + ): + return - if res is None: - res = ResultsWindow( - study=self._study, - solver=solver, - results=results, - parent=self - ) - res.show() - else: - res.activateWindow() + res = ResultsWindow( + study=self._study, + solver=solver, + results=results, + parent=self + ) + res.show() def open_last_results(self): if self._last_solver is None or self._last_results is None: diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py index 9d5d0f95..d4115507 100644 --- a/src/View/Results/Window.py +++ b/src/View/Results/Window.py @@ -91,6 +91,9 @@ class ResultsWindow(PamhyrWindow): parent=parent ) + self._hash_data.append(self._solver) + self._hash_data.append(self._results) + self._additional_plot = {} self.setup_table() From f074e1fdf254fc592d52ac8a1284a38b605cfc9a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 17:01:28 +0200 Subject: [PATCH 15/36] Geometry: Fix profile sub window open. --- src/View/Geometry/Profile/Window.py | 2 ++ src/View/Geometry/Window.py | 48 +++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/View/Geometry/Profile/Window.py b/src/View/Geometry/Profile/Window.py index dfce6d9d..9fd3f1aa 100644 --- a/src/View/Geometry/Profile/Window.py +++ b/src/View/Geometry/Profile/Window.py @@ -65,6 +65,8 @@ class ProfileWindow(PamhyrWindow): parent=parent ) + self._hash_data.append(profile) + self.setup_table() self.setup_plot() self.setup_connections() diff --git a/src/View/Geometry/Window.py b/src/View/Geometry/Window.py index ac021ad1..459bc95d 100644 --- a/src/View/Geometry/Window.py +++ b/src/View/Geometry/Window.py @@ -216,6 +216,30 @@ class GeometryWindow(PamhyrWindow): self.plot_kpc() self.plot_ac() + def _sub_window_exists(self, cls, + data=None): + """Check if window already exists + + Check if window already exists, used to deni window open + duplication + + Args: + cls: Window class, must inerit to PamhyrWindow or + PamhyrDialog + data: Data used for hash computation of cls + + Returns: + The window if hash already exists on sub window dictionary, + otherelse None + """ + hash = cls._hash(data) + if self.sub_win_exists(hash): + win = self.get_sub_win(hash) + win.activateWindow() + return True + else: + return False + def edit_profile(self): self.tableView.model().blockSignals(True) @@ -228,20 +252,18 @@ class GeometryWindow(PamhyrWindow): for row in rows: profile = self._reach.profile(row) - win = self.sub_win_filter_first( - "Profile", - contain=[self._reach.name, str(profile.kp)] - ) + if self._sub_window_exists( + ProfileWindow, + data=[None, None, profile] + ): + continue - if win is None: - win = ProfileWindow( - profile=profile, - parent=self, - ) - self._profile_window.append(win) - win.show() - else: - win.activateWindow() + win = ProfileWindow( + profile=profile, + parent=self, + ) + self._profile_window.append(win) + win.show() self.tableView.model().blockSignals(False) From e395f9f575b58fb8e22dee5e7b7adfc6b0f7268c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 17:16:37 +0200 Subject: [PATCH 16/36] BC, LC: Fix sub window open. --- src/View/BoundaryCondition/Edit/Window.py | 2 + src/View/BoundaryCondition/Window.py | 26 +++++----- src/View/Geometry/Window.py | 26 +--------- src/View/LateralContribution/Edit/Window.py | 2 + src/View/LateralContribution/Window.py | 26 +++++----- src/View/MainWindow.py | 56 ++++++--------------- src/View/Tools/ListedSubWindow.py | 28 ++++++++++- 7 files changed, 73 insertions(+), 93 deletions(-) diff --git a/src/View/BoundaryCondition/Edit/Window.py b/src/View/BoundaryCondition/Edit/Window.py index 162459ed..b2d9e61f 100644 --- a/src/View/BoundaryCondition/Edit/Window.py +++ b/src/View/BoundaryCondition/Edit/Window.py @@ -124,6 +124,8 @@ class EditBoundaryConditionWindow(PamhyrWindow): parent=parent ) + self._hash_data.append(data) + self.setup_table() self.setup_plot() self.setup_data() diff --git a/src/View/BoundaryCondition/Window.py b/src/View/BoundaryCondition/Window.py index 9d30532e..4489a509 100644 --- a/src/View/BoundaryCondition/Window.py +++ b/src/View/BoundaryCondition/Window.py @@ -214,17 +214,17 @@ class BoundaryConditionWindow(PamhyrWindow): tab = self.current_tab() rows = self.index_selected_rows() for row in rows: - win = self.sub_win_filter_first( - "Edit boundary condition", - contain=[f"({self._bcs.get(tab, row).id})"] - ) + data = self._bcs.get(tab, row) - if win is None: - win = EditBoundaryConditionWindow( - data=self._bcs.get(tab, row), - study=self._study, - parent=self - ) - win.show() - else: - win.activateWindow() + if self.sub_window_exists( + EditBoundaryConditionWindow, + data=[self._study, None, data] + ): + continue + + win = EditBoundaryConditionWindow( + data=data, + study=self._study, + parent=self + ) + win.show() diff --git a/src/View/Geometry/Window.py b/src/View/Geometry/Window.py index 459bc95d..3bc4ebd1 100644 --- a/src/View/Geometry/Window.py +++ b/src/View/Geometry/Window.py @@ -216,30 +216,6 @@ class GeometryWindow(PamhyrWindow): self.plot_kpc() self.plot_ac() - def _sub_window_exists(self, cls, - data=None): - """Check if window already exists - - Check if window already exists, used to deni window open - duplication - - Args: - cls: Window class, must inerit to PamhyrWindow or - PamhyrDialog - data: Data used for hash computation of cls - - Returns: - The window if hash already exists on sub window dictionary, - otherelse None - """ - hash = cls._hash(data) - if self.sub_win_exists(hash): - win = self.get_sub_win(hash) - win.activateWindow() - return True - else: - return False - def edit_profile(self): self.tableView.model().blockSignals(True) @@ -252,7 +228,7 @@ class GeometryWindow(PamhyrWindow): for row in rows: profile = self._reach.profile(row) - if self._sub_window_exists( + if self.sub_window_exists( ProfileWindow, data=[None, None, profile] ): diff --git a/src/View/LateralContribution/Edit/Window.py b/src/View/LateralContribution/Edit/Window.py index c813a3a9..72c59b4c 100644 --- a/src/View/LateralContribution/Edit/Window.py +++ b/src/View/LateralContribution/Edit/Window.py @@ -77,6 +77,8 @@ class EditLateralContributionWindow(PamhyrWindow): parent=parent ) + self._hash_data.append(data) + self.setup_table() self.setup_plot() self.setup_connections() diff --git a/src/View/LateralContribution/Window.py b/src/View/LateralContribution/Window.py index 72ca343d..8a9a2e8e 100644 --- a/src/View/LateralContribution/Window.py +++ b/src/View/LateralContribution/Window.py @@ -258,17 +258,17 @@ class LateralContributionWindow(PamhyrWindow): tab = self.current_tab() rows = self.index_selected_rows() for row in rows: - win = self.sub_win_filter_first( - "Edit lateral contribution", - contain=[f"({self._lcs.get(tab, row).id})"] - ) + data = self._lcs.get(tab, row) - if win is None: - win = EditLateralContributionWindow( - data=self._lcs.get(tab, row), - study=self._study, - parent=self - ) - win.show() - else: - win.activateWindow() + if self.sub_window_exists( + EditLateralContributionWindow, + data=[self._study, None, data] + ): + continue + + win = EditLateralContributionWindow( + data=data, + study=self._study, + parent=self + ) + win.show() diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py index 27e09ad1..92a3e8f5 100644 --- a/src/View/MainWindow.py +++ b/src/View/MainWindow.py @@ -480,30 +480,6 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): # SUBWINDOW # ############# - def _sub_window_exists(self, cls, - data=None): - """Check if window already exists - - Check if window already exists, used to deni window open - duplication - - Args: - cls: Window class, must inerit to PamhyrWindow or - PamhyrDialog - data: Data used for hash computation of cls - - Returns: - The window if hash already exists on sub window dictionary, - otherelse None - """ - hash = cls._hash(data) - if self.sub_win_exists(hash): - win = self.get_sub_win(hash) - win.activateWindow() - return True - else: - return False - def open_configure(self): """Open configure window @@ -512,7 +488,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Returns: Nothing """ - if self._sub_window_exists( + if self.sub_window_exists( ConfigureWindow, data=[None, self.conf] ): @@ -529,7 +505,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Returns: Nothing """ - if self._sub_window_exists( + if self.sub_window_exists( AboutWindow, data=[None, None] ): @@ -563,7 +539,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is None: - if self._sub_window_exists( + if self.sub_window_exists( NewStudyWindow, data=[None, None] ): @@ -579,7 +555,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is not None: - if self._sub_window_exists( + if self.sub_window_exists( NewStudyWindow, data=[self._study, None] ): @@ -595,7 +571,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): Nothing """ if self._study is not None: - if self._sub_window_exists( + if self.sub_window_exists( NetworkWindow, data=[self._study, None] ): @@ -613,7 +589,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if (self._study is not None and self._study.river.has_current_reach()): reach = self._study.river.current_reach().reach - if self._sub_window_exists( + if self.sub_window_exists( GeometryWindow, data=[self._study, self.conf, reach] ): @@ -630,7 +606,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self.msg_select_reach() def open_boundary_cond(self): - if self._sub_window_exists( + if self.sub_window_exists( BoundaryConditionWindow, data=[self._study, None] ): @@ -640,7 +616,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): bound.show() def open_lateral_contrib(self): - if self._sub_window_exists( + if self.sub_window_exists( LateralContributionWindow, data=[self._study, None] ): @@ -650,7 +626,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): lateral.show() def open_stricklers(self): - if self._sub_window_exists( + if self.sub_window_exists( StricklersWindow, data=[self._study, self.conf] ): @@ -668,7 +644,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if self._study.river.has_current_reach(): reach = self._study.river.current_reach() - if self._sub_window_exists( + if self.sub_window_exists( FrictionsWindow, data=[self._study, None, reach] ): @@ -686,7 +662,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if self._study.river.has_current_reach(): reach = self._study.river.current_reach() - if self._sub_window_exists( + if self.sub_window_exists( InitialConditionsWindow, data=[self._study, self.conf, reach] ): @@ -703,7 +679,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): self.msg_select_reach() def open_solver_parameters(self): - if self._sub_window_exists( + if self.sub_window_exists( SolverParametersWindow, data=[self._study, None] ): @@ -716,7 +692,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): params.show() def open_sediment_layers(self): - if self._sub_window_exists( + if self.sub_window_exists( SedimentLayersWindow, data=[self._study, None] ): @@ -731,7 +707,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): def open_reach_sediment_layers(self): reach = self._study.river.current_reach().reach - if self._sub_window_exists( + if self.sub_window_exists( ReachSedimentLayersWindow, data=[self._study, None, reach] ): @@ -756,7 +732,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): if run.exec(): solver = run.solver - if self._sub_window_exists( + if self.sub_window_exists( CheckListWindow, data=[ self._study, @@ -793,7 +769,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit): return # Windows already opened - if self._sub_window_exists( + if self.sub_window_exists( ResultsWindow, data=[ self._study, diff --git a/src/View/Tools/ListedSubWindow.py b/src/View/Tools/ListedSubWindow.py index 5c3bf1dd..7fe21bb7 100644 --- a/src/View/Tools/ListedSubWindow.py +++ b/src/View/Tools/ListedSubWindow.py @@ -39,7 +39,7 @@ class ListedSubWindow(object): self.sub_win_cnt += 1 try: logger.info( - f"Open window: {name}: {self.sub_win_cnt}: {win.hash()}") + f"Open window: {name}: {self.sub_win_cnt}") except Exception: logger.info(f"Open window: {name}: {self.sub_win_cnt}: X") logger.warning(f"Sub window without hash method !") @@ -52,7 +52,7 @@ class ListedSubWindow(object): ) ) self.sub_win_cnt = len(self.sub_win_list) - logger.info(f"Close window: {h}: {self.sub_win_cnt}") + logger.info(f"Close window: ({h}) {self.sub_win_cnt}") def _sub_win_exists(self, h): return reduce( @@ -74,3 +74,27 @@ class ListedSubWindow(object): )[1] except Exception: return None + + def sub_window_exists(self, cls, + data=None): + """Check if window already exists + + Check if window already exists, used to deni window open + duplication + + Args: + cls: Window class, must inerit to PamhyrWindow or + PamhyrDialog + data: Data used for hash computation of cls + + Returns: + The window if hash already exists on sub window dictionary, + otherelse None + """ + hash = cls._hash(data) + if self.sub_win_exists(hash): + win = self.get_sub_win(hash) + win.activateWindow() + return True + else: + return False From 0ae5c02ea03b33ef7ced1b4c18c667854d4df8ea Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 17:20:03 +0200 Subject: [PATCH 17/36] Friction: Fix open stricker edit. --- src/View/Frictions/Window.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/View/Frictions/Window.py b/src/View/Frictions/Window.py index b6dd6afa..95b41d8b 100644 --- a/src/View/Frictions/Window.py +++ b/src/View/Frictions/Window.py @@ -240,17 +240,15 @@ class FrictionsWindow(PamhyrWindow): self._table.redo() def edit_stricklers(self): - strick = self.sub_win_filter_first( - "Stricklers", - contain=[] - ) + if self.sub_window_exists( + StricklersWindow, + data=[self._study, self.parent.conf] + ): + return - if strick is None: - strick = StricklersWindow( - study=self._study, - config=self.parent.conf, - parent=self - ) - strick.show() - else: - strick.activateWindow() + strick = StricklersWindow( + study=self._study, + config=self.parent.conf, + parent=self + ) + strick.show() From d6924998d9dbf261989a61429b37489c450b8f15 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 20 Oct 2023 17:25:36 +0200 Subject: [PATCH 18/36] Results: CustomPlot: Fix pep8. --- src/View/Results/CustomPlot/Plot.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/View/Results/CustomPlot/Plot.py b/src/View/Results/CustomPlot/Plot.py index 9733448f..06f911a2 100644 --- a/src/View/Results/CustomPlot/Plot.py +++ b/src/View/Results/CustomPlot/Plot.py @@ -86,7 +86,6 @@ class CustomPlot(PamhyrPlot): if "0-meter" in self._y_axes and "1-m3s" in self._y_axes: m3s_axes = self._axes["1-m3s"] - lines = {} if "elevation" in self._y: meter_axes.set_ylim( @@ -147,11 +146,11 @@ class CustomPlot(PamhyrPlot): # Legend lns = reduce( - lambda acc, l: acc + l, - map(lambda l: lines[l], lines), + lambda acc, line: acc + line, + map(lambda line: lines[line], lines), [] ) - labs = list(map(lambda l: self._trad[l], lines)) + labs = list(map(lambda line: self._trad[line], lines)) self.canvas.axes.legend(lns, labs, loc="lower left") def _customize_x_axes_time(self, ts, mode="time"): @@ -272,16 +271,15 @@ class CustomPlot(PamhyrPlot): ) lines["discharge"] = line - self._customize_x_axes_time(ts) # Legend lns = reduce( - lambda acc, l: acc + l, - map(lambda l: lines[l], lines), + lambda acc, line: acc + line, + map(lambda line: lines[line], lines), [] ) - labs = list(map(lambda l: self._trad[l], lines)) + labs = list(map(lambda line: self._trad[line], lines)) self.canvas.axes.legend(lns, labs, loc="lower left") @timer From e3f01b575ad117b34a78bcc5c76d3b42e21edc1a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Mon, 23 Oct 2023 10:31:59 +0200 Subject: [PATCH 19/36] Solver: Mage: Fix minor in ST file export. --- src/Solver/Mage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Solver/Mage.py b/src/Solver/Mage.py index 4330736e..baf23a9b 100644 --- a/src/Solver/Mage.py +++ b/src/Solver/Mage.py @@ -208,7 +208,7 @@ class Mage(AbstractSolver): ) # Point line - f.write(f"{x} {y} {z}{n} {sediment}\n") + f.write(f"{x} {y} {z} {n} {sediment}\n") # Profile last line f.write(f" 999.9990 999.9990 999.9990\n") From afafbb0c5e546d85a007c2cb6d37ee1e151a26be Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 10:38:34 +0200 Subject: [PATCH 20/36] tests: Add unittest for Model and minor change. --- .gitlab-ci.yml | 43 +++++++--------- src/Model/Study.py | 8 +++ src/Model/__init__.py | 17 +++++++ src/Model/test_Model.py | 108 ++++++++++++++++++++++++++++++++++++++++ src/tools.py | 4 ++ 5 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 src/Model/__init__.py create mode 100644 src/Model/test_Model.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 863629f7..3331d414 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -246,6 +246,22 @@ build-windows: # TESTS # ######### +unittest: + stage: test + tags: + - linux + needs: + - job: set-version + artifacts: true + script: + - python3 -m venv venv + - . venv/bin/activate + - pip3 install -U pip + - pip3 install -r ../full-requirements.txt + - pip3 install -U -r ../full-requirements.txt + - cd src + - python3 -m unittest discover -t . + test-pep8: stage: test tags: @@ -253,6 +269,7 @@ test-pep8: needs: - job: set-version artifacts: true + - job: unittest script: - mkdir -p pep8 - cd pep8 @@ -266,32 +283,6 @@ test-pep8: - pycodestyle ../src allow_failure: true -# test-windows: -# stage: test -# tags: -# - wine -# needs: -# - job: set-version -# artifacts: true -# - job: build-windows -# artifacts: true -# script: -# - cd windows\pamhyr -# - pamhyr\pamhyr.exe hello - -# test-linux: -# stage: test -# tags: -# - linux -# needs: -# - job: set-version -# artifacts: true -# - job: build-linux -# artifacts: true -# script: -# - cd linux/pamhyr -# - ./Pamhyr2 hello - ############ # PACKAGES # ############ diff --git a/src/Model/Study.py b/src/Model/Study.py index 1369e269..c6b482e2 100644 --- a/src/Model/Study.py +++ b/src/Model/Study.py @@ -290,3 +290,11 @@ class Study(SQLModel): self._save_submodel([self._river]) self.commit() + + def close(self): + """Close db connection + + Returns: + Nothing. + """ + self._close() diff --git a/src/Model/__init__.py b/src/Model/__init__.py new file mode 100644 index 00000000..89e3fbe8 --- /dev/null +++ b/src/Model/__init__.py @@ -0,0 +1,17 @@ +# __init__.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- diff --git a/src/Model/test_Model.py b/src/Model/test_Model.py new file mode 100644 index 00000000..e8577494 --- /dev/null +++ b/src/Model/test_Model.py @@ -0,0 +1,108 @@ +# test_Model.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import unittest +import tempfile + +from Model.Saved import SavedStatus +from Model.Study import Study +from Model.River import River + +class StudyTestCase(unittest.TestCase): + def test_create_study(self): + study = Study.new("foo", "bar") + self.assertEqual(study.name, "foo") + self.assertEqual(study.description, "bar") + + def test_open_study(self): + study = Study.open("../tests_cases/Enlargement/Enlargement.pamhyr") + self.assertNotEqual(study, None) + self.assertEqual(study.name, "Enlargement") + + def test_save_open_study(self): + study = Study.new("foo", "bar") + dir = tempfile.mkdtemp() + f = os.path.join(dir, "foo.pamhyr") + + # Save study + study.filename = f + study.save() + study.close() + + # Reopen study + study = Study.open(f) + + # Check + self.assertNotEqual(study, None) + self.assertEqual(study.name, "foo") + self.assertEqual(study.description, "bar") + + def test_create_study_river(self): + study = Study.new("foo", "bar") + self.assertNotEqual(study.river, None) + +class RiverTestCase(unittest.TestCase): + def test_create_river(self): + status = SavedStatus() + river = River(status=status) + + self.assertNotEqual(river, None) + + def test_create_river_nodes(self): + status = SavedStatus() + river = River(status=status) + + self.assertNotEqual(river, None) + + # Add nodes + n0 = river.add_node() + n1 = river.add_node(x=1.0, y=0.0) + n2 = river.add_node(x=0.0, y=1.0) + + # Checks + self.assertEqual(river.nodes_counts(), 3) + + nodes = river.nodes() + self.assertEqual(nodes[0], n0) + self.assertEqual(nodes[1], n1) + self.assertEqual(nodes[2], n2) + + def test_create_river_edges(self): + status = SavedStatus() + river = River(status=status) + + self.assertNotEqual(river, None) + + # Add nodes + n0 = river.add_node() + n1 = river.add_node(x=1.0, y=0.0) + n2 = river.add_node(x=0.0, y=1.0) + + self.assertEqual(river.nodes_counts(), 3) + + # Add edges + e0 = river.add_edge(n0, n1) + e1 = river.add_edge(n1, n2) + + # Checks + self.assertEqual(river.edges_counts(), 2) + + edges = river.edges() + self.assertEqual(edges[0], e0) + self.assertEqual(edges[1], e1) diff --git a/src/tools.py b/src/tools.py index d286345a..218bc78d 100644 --- a/src/tools.py +++ b/src/tools.py @@ -272,6 +272,10 @@ class SQL(object): logger.debug("SQL - commit") self._db.commit() + def _close(self): + self.commit() + self._db.close() + def _fetch_string(self, s): return s.replace("'", "'") From d36a9cd3402055c1a3acf26628588b402070e1b1 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 10:43:33 +0200 Subject: [PATCH 21/36] tests: Fix test env path. --- .gitlab-ci.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3331d414..5ae32eca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -257,8 +257,8 @@ unittest: - python3 -m venv venv - . venv/bin/activate - pip3 install -U pip - - pip3 install -r ../full-requirements.txt - - pip3 install -U -r ../full-requirements.txt + - pip3 install -r ./full-requirements.txt + - pip3 install -U -r ./full-requirements.txt - cd src - python3 -m unittest discover -t . @@ -271,16 +271,14 @@ test-pep8: artifacts: true - job: unittest script: - - mkdir -p pep8 - - cd pep8 # Setup virtual env - python3 -m venv venv - . venv/bin/activate - pip3 install -U pip - - pip3 install -r ../requirements.txt - - pip3 install -U -r ../requirements.txt + - pip3 install -r ./requirements.txt + - pip3 install -U -r ./requirements.txt - pip3 install pycodestyle - - pycodestyle ../src + - pycodestyle ./src allow_failure: true ############ From 862ec88191750d47c7883f00748c6f34dc7c5ac6 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 10:48:54 +0200 Subject: [PATCH 22/36] tests: Add test script and fix pep8 in unittest. --- src/Model/test_Model.py | 2 ++ tests.sh | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100755 tests.sh diff --git a/src/Model/test_Model.py b/src/Model/test_Model.py index e8577494..2694366f 100644 --- a/src/Model/test_Model.py +++ b/src/Model/test_Model.py @@ -24,6 +24,7 @@ from Model.Saved import SavedStatus from Model.Study import Study from Model.River import River + class StudyTestCase(unittest.TestCase): def test_create_study(self): study = Study.new("foo", "bar") @@ -57,6 +58,7 @@ class StudyTestCase(unittest.TestCase): study = Study.new("foo", "bar") self.assertNotEqual(study.river, None) + class RiverTestCase(unittest.TestCase): def test_create_river(self): status = SavedStatus() diff --git a/tests.sh b/tests.sh new file mode 100755 index 00000000..2e4df6bc --- /dev/null +++ b/tests.sh @@ -0,0 +1,19 @@ +#! /bin/sh + +echo " Setup ENV" + +python3 -m venv venv +. venv/bin/activate +pip3 install -U pip +pip3 install -r ./full-requirements.txt +pip3 install -U -r ./full-requirements.txt + +echo " UNITTEST" + +cd src/ +python3 -m unittest discover -v -t . +cd .. + +echo " PEP8" + +pycodestyle ./src From 32684af29261a12e0ecf1cd8c727d8f25d2e5d7a Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 10:56:23 +0200 Subject: [PATCH 23/36] tests: Minor change. --- tests.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests.sh b/tests.sh index 2e4df6bc..06c8fd84 100755 --- a/tests.sh +++ b/tests.sh @@ -17,3 +17,10 @@ cd .. echo " PEP8" pycodestyle ./src + +if [ $? -eq 0 ] +then + echo "OK" +else + echo "WARNING" +fi From f44ac69d6844e091666e86da5db44f7fbee78d82 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 10:58:22 +0200 Subject: [PATCH 24/36] ci: Change stages order (tests before build). --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ae32eca..1ab04c30 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,8 +16,8 @@ stages: - downloads - - build - test + - build - package - release From 2d7681086de746e0d4f713e06c8d45689686cb8d Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 11:11:34 +0200 Subject: [PATCH 25/36] ci: Add configure stage. --- .gitlab-ci.yml | 93 ++++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1ab04c30..dd8178d4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,6 +16,7 @@ stages: - downloads + - configure - test - build - package @@ -59,12 +60,12 @@ dl-mage-windows: - mage-windows/mage_extraire.exe - mage-windows/mailleurPF.exe -######### -# BUILD # -######### +############# +# CONFIGURE # +############# set-version: - stage: build + stage: configure tags: - linux script: @@ -75,7 +76,7 @@ set-version: - VERSION build-lang: - stage: build + stage: configure tags: - linux script: @@ -85,6 +86,49 @@ build-lang: paths: - src/lang/*.qm +######### +# TESTS # +######### + +unittest: + stage: test + tags: + - linux + needs: + - job: set-version + artifacts: true + script: + - python3 -m venv venv + - . venv/bin/activate + - pip3 install -U pip + - pip3 install -r ./full-requirements.txt + - pip3 install -U -r ./full-requirements.txt + - cd src + - python3 -m unittest discover -t . + +test-pep8: + stage: test + tags: + - linux + needs: + - job: set-version + artifacts: true + - job: unittest + script: + # Setup virtual env + - python3 -m venv venv + - . venv/bin/activate + - pip3 install -U pip + - pip3 install -r ./requirements.txt + - pip3 install -U -r ./requirements.txt + - pip3 install pycodestyle + - pycodestyle ./src + allow_failure: true + +######### +# BUILD # +######### + build-users-doc: stage: build tags: @@ -242,45 +286,6 @@ build-windows: paths: - windows/pamhyr -######### -# TESTS # -######### - -unittest: - stage: test - tags: - - linux - needs: - - job: set-version - artifacts: true - script: - - python3 -m venv venv - - . venv/bin/activate - - pip3 install -U pip - - pip3 install -r ./full-requirements.txt - - pip3 install -U -r ./full-requirements.txt - - cd src - - python3 -m unittest discover -t . - -test-pep8: - stage: test - tags: - - linux - needs: - - job: set-version - artifacts: true - - job: unittest - script: - # Setup virtual env - - python3 -m venv venv - - . venv/bin/activate - - pip3 install -U pip - - pip3 install -r ./requirements.txt - - pip3 install -U -r ./requirements.txt - - pip3 install pycodestyle - - pycodestyle ./src - allow_failure: true - ############ # PACKAGES # ############ From 4833685e2e38b3eaf627a74c9fdb46c1a8a3466f Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 15:05:05 +0200 Subject: [PATCH 26/36] Script: Add run/export script. --- src/Scripts/Run.py | 106 ++++++++++++++++++++++++++++++++++++++++++ src/Solver/ASolver.py | 3 ++ src/pamhyr.py | 3 ++ 3 files changed, 112 insertions(+) create mode 100644 src/Scripts/Run.py diff --git a/src/Scripts/Run.py b/src/Scripts/Run.py new file mode 100644 index 00000000..2c26f4d2 --- /dev/null +++ b/src/Scripts/Run.py @@ -0,0 +1,106 @@ +# Run.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import logging + +from queue import Queue + +from PyQt5.QtCore import QProcess + +from Scripts.AScript import AScript +from Model.Study import Study + +logger = logging.getLogger() + + +class ScriptRun(AScript): + name = "Run" + description = "Run solver on Pamhyr2 a study" + + def usage(self): + logger.info(f"Usage : {self._args[0]} {self._args[1]} ") + + def run(self): + if len(self._args) < 4: + return 1 + + command = self._args[1] + solver_name = self._args[2] + study_file = os.path.abspath( + self._args[3] + ) + + try: + solver = next( + filter( + lambda solver: solver.name == solver_name, + self._conf.solvers + ) + ) + except Exception as e: + logger.error(f"No solver found: {e}") + return 2 + + study = Study.open(study_file) + + self._solver = solver + self._study = study + + logger.info(f"Run {solver.name} ({solver.type}) on study '{study.name}' ({study_file})") + + # Workdir + workdir = os.path.join( + os.path.dirname(study.filename), + "_PAMHYR_", + study.name.replace(" ", "_"), + solver.name.replace(" ", "_") + ) + os.makedirs(workdir, exist_ok=True) + logger.info(f"Set working dir to {workdir}") + + # Preparate process + p = QProcess(None) + p.setWorkingDirectory(workdir) + + self._q = Queue() + + # Export and Run + logger.info(f"~Export~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + solver.export(study, workdir, qlog=self._q) + + while self._q.qsize() != 0: + s = self._q.get() + logger.info(s) + + if command == "run": + logger.info(f"~Run~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + solver.run( + study, + process=p, + output_queue=self._q + ) + p.waitForFinished() + + logger.info(f"~End~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + + return 0 + +class ScriptExport(ScriptRun): + name = "Export" + description = "Export Pamhyr2 study for solver" diff --git a/src/Solver/ASolver.py b/src/Solver/ASolver.py index 0603a5ab..b461d602 100644 --- a/src/Solver/ASolver.py +++ b/src/Solver/ASolver.py @@ -295,6 +295,7 @@ class AbstractSolver(object): self._process.start( exe, args, ) + self._process.waitForStarted() self._status = STATUS.RUNNING return True @@ -340,6 +341,8 @@ class AbstractSolver(object): self._run_next(study) def run(self, study, process=None, output_queue=None): + self._study = study + if process is not None: self._process = process if output_queue is not None: diff --git a/src/pamhyr.py b/src/pamhyr.py index 2381da01..f2652686 100755 --- a/src/pamhyr.py +++ b/src/pamhyr.py @@ -37,6 +37,7 @@ from Model.Study import Study from Scripts.P3DST import Script3DST from Scripts.Hello import ScriptHello +from Scripts.Run import ScriptExport, ScriptRun from init import legal_info, debug_info, setup_lang @@ -44,6 +45,8 @@ logger = logging.getLogger() scripts = { "hello": ScriptHello, + "export": ScriptExport, + "run": ScriptRun, "3DST": Script3DST, } From e556bcff540f4a8d2deb8cb339044c0cb9b1c014 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 15:13:35 +0200 Subject: [PATCH 27/36] Script: Add script command list solver. --- src/Scripts/ListSolver.py | 38 ++++++++++++++++++++++++++++++++++++++ src/pamhyr.py | 2 ++ 2 files changed, 40 insertions(+) create mode 100644 src/Scripts/ListSolver.py diff --git a/src/Scripts/ListSolver.py b/src/Scripts/ListSolver.py new file mode 100644 index 00000000..fe50d8d9 --- /dev/null +++ b/src/Scripts/ListSolver.py @@ -0,0 +1,38 @@ +# ListSolver.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import logging + +from Scripts.AScript import AScript + +logger = logging.getLogger() + + +class ScriptListSolver(AScript): + name = "ListSolver" + description = "List configured solver(s) for Pamhyr2" + + def usage(self): + logger.info(f"Usage : {self._args[0]} {self._args[1]}") + + def run(self): + for solver in self._conf.solvers: + print(f"{solver.name:<16} ({solver.type}): {solver.description}") + + return 0 diff --git a/src/pamhyr.py b/src/pamhyr.py index f2652686..5d274166 100755 --- a/src/pamhyr.py +++ b/src/pamhyr.py @@ -37,6 +37,7 @@ from Model.Study import Study from Scripts.P3DST import Script3DST from Scripts.Hello import ScriptHello +from Scripts.ListSolver import ScriptListSolver from Scripts.Run import ScriptExport, ScriptRun from init import legal_info, debug_info, setup_lang @@ -45,6 +46,7 @@ logger = logging.getLogger() scripts = { "hello": ScriptHello, + "solvers": ScriptListSolver, "export": ScriptExport, "run": ScriptRun, "3DST": Script3DST, From 76e90f417e391241d96402640cb2f9ce028df473 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Tue, 24 Oct 2023 15:26:55 +0200 Subject: [PATCH 28/36] Script: Fix pep8. --- src/Scripts/Run.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Scripts/Run.py b/src/Scripts/Run.py index 2c26f4d2..7b70a910 100644 --- a/src/Scripts/Run.py +++ b/src/Scripts/Run.py @@ -34,7 +34,10 @@ class ScriptRun(AScript): description = "Run solver on Pamhyr2 a study" def usage(self): - logger.info(f"Usage : {self._args[0]} {self._args[1]} ") + logger.info( + f"Usage : {self._args[0]} {self._args[1]} " + + " " + ) def run(self): if len(self._args) < 4: @@ -62,7 +65,10 @@ class ScriptRun(AScript): self._solver = solver self._study = study - logger.info(f"Run {solver.name} ({solver.type}) on study '{study.name}' ({study_file})") + logger.info( + f"Run {solver.name} ({solver.type}) " + + "on study '{study.name}' ({study_file})" + ) # Workdir workdir = os.path.join( @@ -101,6 +107,7 @@ class ScriptRun(AScript): return 0 + class ScriptExport(ScriptRun): name = "Export" description = "Export Pamhyr2 study for solver" From 51c912f3a896272941490a0664d334f493c2bd15 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 09:33:32 +0200 Subject: [PATCH 29/36] Solver: Code refactoring. --- src/Solver/ASolver.py | 249 +-------------------------- src/Solver/CommandLine.py | 327 ++++++++++++++++++++++++++++++++++++ src/Solver/GenericSolver.py | 7 +- src/Solver/Mage.py | 4 +- 4 files changed, 337 insertions(+), 250 deletions(-) create mode 100644 src/Solver/CommandLine.py diff --git a/src/Solver/ASolver.py b/src/Solver/ASolver.py index b461d602..c4fa286a 100644 --- a/src/Solver/ASolver.py +++ b/src/Solver/ASolver.py @@ -59,17 +59,6 @@ class AbstractSolver(object): self._name = name self._description = "" - self._path_input = "" - self._path_solver = "" - self._path_output = "" - - self._cmd_input = "" - self._cmd_solver = "" - self._cmd_output = "" - - self._process = None - self._output = None - # Last study running self._study = None @@ -92,7 +81,6 @@ class AbstractSolver(object): ("all_init_time", "000:00:00:00"), ("all_final_time", "999:99:00:00"), ("all_timestep", "300.0"), - ("all_command_line_arguments", ""), ] return lst @@ -141,18 +129,6 @@ class AbstractSolver(object): def description(self, description): self._description = description - def set_input(self, path, cmd): - self._path_input = path - self._cmd_input = cmd - - def set_solver(self, path, cmd): - self._path_solver = path - self._cmd_solver = cmd - - def set_output(self, path, cmd): - self._path_output = path - self._cmd_output = cmd - ########## # Export # ########## @@ -160,41 +136,6 @@ class AbstractSolver(object): def export(self, study, repertory, qlog=None): raise NotImplementedMethodeError(self, self.export) - def cmd_args(self, study): - """Return solver command line arguments list - - Returns: - Command line arguments list - """ - params = study.river.get_params(self.type) - args = params.get_by_key("all_command_line_arguments") - - return args.split(" ") - - def input_param(self): - """Return input command line parameter(s) - - Returns: - Returns input parameter(s) string - """ - raise NotImplementedMethodeError(self, self.input_param) - - def output_param(self): - """Return output command line parameter(s) - - Returns: - Returns output parameter(s) string - """ - raise NotImplementedMethodeError(self, self.output_param) - - def log_file(self): - """Return log file name - - Returns: - Returns log file name as string - """ - raise NotImplementedMethodeError(self, self.log_file) - ########### # RESULTS # ########### @@ -208,195 +149,17 @@ class AbstractSolver(object): # Run # ####### - def _install_dir(self): - return os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "..", ".." - ) - ) - - def _format_command(self, study, cmd, path=""): - """Format command line - - Args: - cmd: The command line - path: Optional path string (replace @path in cmd) - - Returns: - The executable and list of arguments - """ - # HACK: Works in most case... Trust me i'm an engineer - cmd = cmd.replace("@install_dir", self._install_dir()) - cmd = cmd.replace("@path", path.replace(" ", "%20")) - cmd = cmd.replace("@input", self.input_param()) - cmd = cmd.replace("@output", self.output_param()) - cmd = cmd.replace("@dir", self._process.workingDirectory()) - cmd = cmd.replace("@args", " ".join(self.cmd_args(study))) - - logger.debug(f"! {cmd}") - - if cmd[0] == "\"": - # Command line executable path is between " char - cmd = cmd.split("\"") - exe = cmd[1].replace("%20", " ") - args = list( - filter( - lambda s: s != "", - "\"".join(cmd[2:]).split(" ")[1:] - ) - ) - else: - # We suppose the command line executable path as no space char - cmd = cmd.replace("\\ ", "&_&").split(" ") - exe = cmd[0].replace("&_&", " ") - args = list( - filter( - lambda s: s != "", - map(lambda s: s.replace("&_&", " "), cmd[1:]) - ) - ) - - logger.info(f"! {exe} {args}") - return exe, args - - def run_input_data_fomater(self, study): - if self._cmd_input == "": - self._run_next(study) - return True - - cmd = self._cmd_input - exe, args = self._format_command(study, cmd, self._path_input) - - if not os.path.exists(exe): - error = f"[ERROR] Path {exe} do not exists" - logger.info(error) - return error - - self._process.start( - exe, args, - ) - - return True - - def run_solver(self, study): - if self._cmd_solver == "": - self._run_next(study) - return True - - cmd = self._cmd_solver - exe, args = self._format_command(study, cmd, self._path_solver) - - if not os.path.exists(exe): - error = f"[ERROR] Path {exe} do not exists" - logger.info(error) - return error - - self._process.start( - exe, args, - ) - self._process.waitForStarted() - - self._status = STATUS.RUNNING - return True - - def run_output_data_fomater(self, study): - if self._cmd_output == "": - self._run_next(study) - return True - - cmd = self._cmd_output - exe, args = self._format_command(study, cmd, self._path_output) - - if not os.path.exists(exe): - error = f"[ERROR] Path {exe} do not exists" - logger.info(error) - return error - - self._process.start( - exe, args, - ) - - return True - - def _data_ready(self): - s = self._process.readAll().data().decode() - if self._output is not None: - for x in s.split('\n'): - self._output.put(x) - - def _run_next(self, study): - self._step += 1 - if self._step < len(self._runs): - res = self._runs[self._step](study) - if res is not True: - self._output.put(res) - else: - self._status = STATUS.STOPED - - def _finished(self, study, exit_code, exit_status): - if self._output is not None: - self._output.put(exit_code) - - self._run_next(study) - - def run(self, study, process=None, output_queue=None): - self._study = study - - if process is not None: - self._process = process - if output_queue is not None: - self._output = output_queue - - self._process.readyRead.connect(self._data_ready) - self._process.finished.connect( - lambda c, s: self._finished(study, c, s)) - - self._runs = [ - self.run_input_data_fomater, - self.run_solver, - self.run_output_data_fomater, - ] - self._step = 0 - # Run first step - res = self._runs[0](study) - if res is not True: - self._output.put(res) + def run(self, study): + raise NotImplementedMethodeError(self, self.run) def kill(self): - if self._process is None: - return True - - self._process.kill() - self._status = STATUS.STOPED - return True + raise NotImplementedMethodeError(self, self.kill) def start(self, study, process=None): - if _signal: - # Solver is PAUSED, so continue execution - if self._status == STATUS.PAUSED: - os.kill(self._process.pid(), SIGCONT) - self._status = STATUS.RUNNING - return True - - self.run(study, process) - return True + raise NotImplementedMethodeError(self, self.start) def pause(self): - if _signal: - if self._process is None: - return False - - # Send SIGSTOP to PAUSED solver - os.kill(self._process.pid(), SIGSTOP) - self._status = STATUS.PAUSED - return True - return False + raise NotImplementedMethodeError(self, self.pause) def stop(self): - if self._process is None: - return False - - self._process.terminate() - self._status = STATUS.STOPED - return True + raise NotImplementedMethodeError(self, self.stop) diff --git a/src/Solver/CommandLine.py b/src/Solver/CommandLine.py new file mode 100644 index 00000000..c922b86a --- /dev/null +++ b/src/Solver/CommandLine.py @@ -0,0 +1,327 @@ +# CommandLine.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import logging + +from tools import timer + +try: + # Installation allow Unix-like signal + from signal import SIGTERM, SIGSTOP, SIGCONT + _signal = True +except Exception: + _signal = False + +from enum import Enum + +from Model.Except import NotImplementedMethodeError + +from Model.Results.Results import Results +from Model.Results.River.River import River, Reach, Profile + +from Solver.ASolver import AbstractSolver, STATUS + +logger = logging.getLogger() + + +class CommandLineSolver(AbstractSolver): + _type = "" + + def __init__(self, name): + super(CommandLineSolver, self).__init__(name) + + self._current_process = None + self._status = STATUS.NOT_LAUNCHED + + self._path_input = "" + self._path_solver = "" + self._path_output = "" + + self._cmd_input = "" + self._cmd_solver = "" + self._cmd_output = "" + + self._process = None + self._output = None + + # Last study running + self._study = None + + @classmethod + def default_parameters(cls): + lst = super(CommandLineSolver, cls).default_parameters() + + lst += [ + ("all_command_line_arguments", ""), + ] + + return lst + + def set_input(self, path, cmd): + self._path_input = path + self._cmd_input = cmd + + def set_solver(self, path, cmd): + self._path_solver = path + self._cmd_solver = cmd + + def set_output(self, path, cmd): + self._path_output = path + self._cmd_output = cmd + + ########## + # Export # + ########## + + def cmd_args(self, study): + """Return solver command line arguments list + + Returns: + Command line arguments list + """ + params = study.river.get_params(self.type) + args = params.get_by_key("all_command_line_arguments") + + return args.split(" ") + + def input_param(self): + """Return input command line parameter(s) + + Returns: + Returns input parameter(s) string + """ + raise NotImplementedMethodeError(self, self.input_param) + + def output_param(self): + """Return output command line parameter(s) + + Returns: + Returns output parameter(s) string + """ + raise NotImplementedMethodeError(self, self.output_param) + + def log_file(self): + """Return log file name + + Returns: + Returns log file name as string + """ + raise NotImplementedMethodeError(self, self.log_file) + + ####### + # Run # + ####### + + def _install_dir(self): + return os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", ".." + ) + ) + + def _format_command(self, study, cmd, path=""): + """Format command line + + Args: + cmd: The command line + path: Optional path string (replace @path in cmd) + + Returns: + The executable and list of arguments + """ + # HACK: Works in most case... Trust me i'm an engineer + cmd = cmd.replace("@install_dir", self._install_dir()) + cmd = cmd.replace("@path", path.replace(" ", "%20")) + cmd = cmd.replace("@input", self.input_param()) + cmd = cmd.replace("@output", self.output_param()) + cmd = cmd.replace("@dir", self._process.workingDirectory()) + cmd = cmd.replace("@args", " ".join(self.cmd_args(study))) + + logger.debug(f"! {cmd}") + + if cmd[0] == "\"": + # Command line executable path is between " char + cmd = cmd.split("\"") + exe = cmd[1].replace("%20", " ") + args = list( + filter( + lambda s: s != "", + "\"".join(cmd[2:]).split(" ")[1:] + ) + ) + else: + # We suppose the command line executable path as no space char + cmd = cmd.replace("\\ ", "&_&").split(" ") + exe = cmd[0].replace("&_&", " ") + args = list( + filter( + lambda s: s != "", + map(lambda s: s.replace("&_&", " "), cmd[1:]) + ) + ) + + logger.info(f"! {exe} {args}") + return exe, args + + def run_input_data_fomater(self, study): + if self._cmd_input == "": + self._run_next(study) + return True + + cmd = self._cmd_input + exe, args = self._format_command(study, cmd, self._path_input) + + if not os.path.exists(exe): + error = f"[ERROR] Path {exe} do not exists" + logger.info(error) + return error + + self._process.start( + exe, args, + ) + + return True + + def run_solver(self, study): + if self._cmd_solver == "": + self._run_next(study) + return True + + cmd = self._cmd_solver + exe, args = self._format_command(study, cmd, self._path_solver) + + if not os.path.exists(exe): + error = f"[ERROR] Path {exe} do not exists" + logger.info(error) + return error + + self._process.start( + exe, args, + ) + self._process.waitForStarted() + + self._status = STATUS.RUNNING + return True + + def run_output_data_fomater(self, study): + if self._cmd_output == "": + self._run_next(study) + return True + + cmd = self._cmd_output + exe, args = self._format_command(study, cmd, self._path_output) + + if not os.path.exists(exe): + error = f"[ERROR] Path {exe} do not exists" + logger.info(error) + return error + + self._process.start( + exe, args, + ) + + return True + + def _data_ready(self): + # Read process output and put lines in queue + s = self._process.readAll().data().decode() + if self._output is not None: + for x in s.split('\n'): + self._output.put(x) + + def _run_next(self, study): + self._step += 1 + if self._step < len(self._runs): + res = self._runs[self._step](study) + if res is not True: + self._output.put(res) + else: + self._status = STATUS.STOPED + + def _finished(self, study, exit_code, exit_status): + if self._output is not None: + self._output.put(exit_code) + + self._run_next(study) + + def run(self, study, process=None, output_queue=None): + self._study = study + + # Replace old values if needed + if process is not None: + self._process = process + if output_queue is not None: + self._output = output_queue + + # Connect / reconnect signal + self._process.readyRead.connect(self._data_ready) + self._process.finished.connect( + lambda c, s: self._finished(study, c, s)) + + # Prepare running step + self._runs = [ + self.run_input_data_fomater, + self.run_solver, + self.run_output_data_fomater, + ] + self._step = 0 + + # Run first step + res = self._runs[0](study) + if res is not True: + self._output.put(res) + + def kill(self): + if self._process is None: + return True + + self._process.kill() + self._status = STATUS.STOPED + return True + + def start(self, study, process=None): + if _signal: + # Solver is PAUSED, so continue execution + if self._status == STATUS.PAUSED: + os.kill(self._process.pid(), SIGCONT) + self._status = STATUS.RUNNING + return True + + self.run(study, process) + return True + + def pause(self): + if _signal: + if self._process is None: + return False + + # Send SIGSTOP to PAUSED solver + os.kill(self._process.pid(), SIGSTOP) + self._status = STATUS.PAUSED + return True + return False + + def stop(self): + if self._process is None: + return False + + self._process.terminate() + self._status = STATUS.STOPED + return True diff --git a/src/Solver/GenericSolver.py b/src/Solver/GenericSolver.py index bc5a9f6b..9fb272e4 100644 --- a/src/Solver/GenericSolver.py +++ b/src/Solver/GenericSolver.py @@ -16,12 +16,9 @@ # -*- coding: utf-8 -*- -from Solver.ASolver import ( - AbstractSolver, STATUS -) +from Solver.CommandLine import CommandLineSolver - -class GenericSolver(AbstractSolver): +class GenericSolver(CommandLineSolver): _type = "generic" def __init__(self, name): diff --git a/src/Solver/Mage.py b/src/Solver/Mage.py index baf23a9b..52efc071 100644 --- a/src/Solver/Mage.py +++ b/src/Solver/Mage.py @@ -22,7 +22,7 @@ import numpy as np from tools import timer -from Solver.ASolver import AbstractSolver +from Solver.CommandLine import CommandLineSolver from Checker.Mage import MageNetworkGraphChecker from Model.Results.Results import Results @@ -41,7 +41,7 @@ def mage_file_open(filepath, mode): return f -class Mage(AbstractSolver): +class Mage(CommandLineSolver): _type = "mage" def __init__(self, name): From 6c794d2eb41440c0b50d504a8291fc1b90f3199f Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:05:16 +0200 Subject: [PATCH 30/36] Solver: Refactoring command line parser and add unittest. --- src/Solver/CommandLine.py | 28 ++------- src/Solver/GenericSolver.py | 1 + src/__init__.py | 17 ++++++ src/test_pamhyr.py | 100 +++++++++++++++++++++++++++++++++ src/tools.py | 109 ++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/test_pamhyr.py diff --git a/src/Solver/CommandLine.py b/src/Solver/CommandLine.py index c922b86a..1a06d81f 100644 --- a/src/Solver/CommandLine.py +++ b/src/Solver/CommandLine.py @@ -19,7 +19,7 @@ import os import logging -from tools import timer +from tools import timer, parse_command_line try: # Installation allow Unix-like signal @@ -146,9 +146,8 @@ class CommandLineSolver(AbstractSolver): Returns: The executable and list of arguments """ - # HACK: Works in most case... Trust me i'm an engineer cmd = cmd.replace("@install_dir", self._install_dir()) - cmd = cmd.replace("@path", path.replace(" ", "%20")) + cmd = cmd.replace("\"@path\"", path) cmd = cmd.replace("@input", self.input_param()) cmd = cmd.replace("@output", self.output_param()) cmd = cmd.replace("@dir", self._process.workingDirectory()) @@ -156,26 +155,9 @@ class CommandLineSolver(AbstractSolver): logger.debug(f"! {cmd}") - if cmd[0] == "\"": - # Command line executable path is between " char - cmd = cmd.split("\"") - exe = cmd[1].replace("%20", " ") - args = list( - filter( - lambda s: s != "", - "\"".join(cmd[2:]).split(" ")[1:] - ) - ) - else: - # We suppose the command line executable path as no space char - cmd = cmd.replace("\\ ", "&_&").split(" ") - exe = cmd[0].replace("&_&", " ") - args = list( - filter( - lambda s: s != "", - map(lambda s: s.replace("&_&", " "), cmd[1:]) - ) - ) + words = parse_command_line(cmd) + exe = words[0] + args = words[1:] logger.info(f"! {exe} {args}") return exe, args diff --git a/src/Solver/GenericSolver.py b/src/Solver/GenericSolver.py index 9fb272e4..f8ff31aa 100644 --- a/src/Solver/GenericSolver.py +++ b/src/Solver/GenericSolver.py @@ -18,6 +18,7 @@ from Solver.CommandLine import CommandLineSolver + class GenericSolver(CommandLineSolver): _type = "generic" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..89e3fbe8 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,17 @@ +# __init__.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- diff --git a/src/test_pamhyr.py b/src/test_pamhyr.py new file mode 100644 index 00000000..29ddd83c --- /dev/null +++ b/src/test_pamhyr.py @@ -0,0 +1,100 @@ +# test_pamhyr.py -- Pamhyr +# Copyright (C) 2023 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +import os +import unittest +import tempfile + +from tools import parse_command_line + + +class ToolsCMDParserTestCase(unittest.TestCase): + def test_trivial(self): + cmd = "foo" + expect = ["foo"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_unix_simple(self): + cmd = "/foo/bar a -b -c" + expect = ["/foo/bar", "a", '-b', "-c"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_unix_quoted(self): + cmd = "\"/foo/bar\" -a -b -c" + expect = ["/foo/bar", "-a", '-b', "-c"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_unix_quoted_with_space(self): + cmd = "\"/foo/bar baz\" -a -b -c" + expect = ["/foo/bar baz", "-a", '-b', "-c"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_unix_quoted_args(self): + cmd = "/foo/bar -a -b -c=\"baz\"" + expect = ["/foo/bar", "-a", '-b', "-c=\"baz\""] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_unix_quoted_args_with_space(self): + cmd = "/foo/bar -a -b -c=\"baz bazz\"" + expect = ["/foo/bar", "-a", '-b', "-c=\"baz bazz\""] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_windows_prog_files(self): + cmd = "\"C:\\Program Files (x86)\foo\bar\" a -b -c" + expect = ["C:\\Program Files (x86)\foo\bar", "a", '-b', "-c"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_windows_prog_files_args(self): + cmd = "\"C:\\Program Files (x86)\foo\bar\" a -b=\"baz bazz\" -c" + expect = [ + "C:\\Program Files (x86)\foo\bar", + "a", '-b=\"baz bazz\"', "-c" + ] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) diff --git a/src/tools.py b/src/tools.py index 218bc78d..d6e787b3 100644 --- a/src/tools.py +++ b/src/tools.py @@ -341,3 +341,112 @@ class SQL(object): def _load(self): logger.warning("TODO: LOAD") + + +####################### +# COMMAND LINE PARSER # +####################### + +parser_special_char = ["\"", "\'"] + + +def parse_command_line(cmd): + """Parse command line string and return list of string arguments + + Parse command line string and returns the list of separate + arguments as string, this function take in consideration space + separator and quoted expression + + Args: + cmd: The command line to parce + + Returns: + List of arguments as string + """ + words = [] + rest = cmd + + while True: + if len(rest) == 0: + break + + word, rest = _parse_next_word(rest) + words.append(word) + + return words + + +def _parse_next_word(words): + """Parse the next word in words string + + Args: + words: The words string + + Returns: + the next word and rests of words + """ + if len(words) == 1: + return words, "" + + # Remove useless space + words = words.strip() + + # Parse + if words[0] == "\"": + word, rest = _parse_word_up_to_next_sep(words, sep="\"") + elif words[0] == "\'": + word, rest = _parse_word_up_to_next_sep(words, sep="\'") + else: + word, rest = _parse_word_up_to_next_sep(words, sep=" ") + + return word, rest + + +def _parse_word_up_to_next_sep(words, sep=" "): + word = "" + + i = 0 if sep == " " else 1 + cur = words[i] + skip_next = False + while True: + # Exit conditions + if cur == "": + break + + if cur == sep: + if not skip_next: + break + + # Take in consideration escape char in case of \ + if cur == "\\": + # If previous char is a escape char, cancel next char + # skiping: + # \ -> skip as separator + # \\ -> do not skip + skip_next = not skip_next + else: + skip_next = False + + word += cur + + # Current word contain a word with different separator, + # typicaly, the string '-c="foo bar"' with ' ' seperator must + # be parse as one word. + # + # Correct: '-c="foo bar" baz' -> '-c="foo bar"', 'baz' + # Not correct: '-c="foo bar" baz' -> '-c="foo', 'bar" baz' + if cur in parser_special_char: + # Recursive call to parse this word + sub_word, rest = _parse_word_up_to_next_sep(words[i:], sep=cur) + i += len(sub_word) + 1 + word += sub_word + cur + + # Get next symbol + i += 1 + if i == len(words): + cur = "" + else: + cur = words[i] + + rest = words[i+1:] + return word, rest From 65c261887c12fc857b76f471761b6126be46486b Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:13:49 +0200 Subject: [PATCH 31/36] Tools: Command line parser: Add a test. --- src/test_pamhyr.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test_pamhyr.py b/src/test_pamhyr.py index 29ddd83c..d8d5cf9a 100644 --- a/src/test_pamhyr.py +++ b/src/test_pamhyr.py @@ -78,6 +78,16 @@ class ToolsCMDParserTestCase(unittest.TestCase): for i, s in enumerate(expect): self.assertEqual(res[i], s) + def test_unix_escape_space(self): + cmd = "/foo/bar\ baz -a -b -c" + expect = ["/foo/bar\ baz", "-a", '-b', "-c"] + + res = parse_command_line(cmd) + + for i, s in enumerate(expect): + self.assertEqual(res[i], s) + + def test_windows_prog_files(self): cmd = "\"C:\\Program Files (x86)\foo\bar\" a -b -c" expect = ["C:\\Program Files (x86)\foo\bar", "a", '-b', "-c"] From 66f6d3505b44d6e881a3bc5d21709e91d516ec3e Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:15:58 +0200 Subject: [PATCH 32/36] Solver: CommandLine: Fix path replace. --- src/Solver/CommandLine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Solver/CommandLine.py b/src/Solver/CommandLine.py index 1a06d81f..f7f5627c 100644 --- a/src/Solver/CommandLine.py +++ b/src/Solver/CommandLine.py @@ -147,7 +147,7 @@ class CommandLineSolver(AbstractSolver): The executable and list of arguments """ cmd = cmd.replace("@install_dir", self._install_dir()) - cmd = cmd.replace("\"@path\"", path) + cmd = cmd.replace("@path", "\"" + path + "\"") cmd = cmd.replace("@input", self.input_param()) cmd = cmd.replace("@output", self.output_param()) cmd = cmd.replace("@dir", self._process.workingDirectory()) From 4355be1a9c11b1bc8497b0c5b81bdb661e5a1a8c Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:31:20 +0200 Subject: [PATCH 33/36] Solver: Mage: Split ST export function. --- src/Solver/Mage.py | 133 ++++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 57 deletions(-) diff --git a/src/Solver/Mage.py b/src/Solver/Mage.py index 52efc071..b6e586f8 100644 --- a/src/Solver/Mage.py +++ b/src/Solver/Mage.py @@ -125,7 +125,6 @@ class Mage(CommandLineSolver): qlog.put("Export ST file") os.makedirs(os.path.join(repertory, "net"), exist_ok=True) - gra_file = f"{name}.GRA" # Write header edges = study.river.edges() @@ -149,72 +148,83 @@ class Mage(CommandLineSolver): cnt_num = 1 for profile in edge.reach.profiles: - num = f"{cnt_num:>6}" - c1 = f"{profile.code1:>6}" - c2 = f"{profile.code2:>6}" - t = f"{len(profile.points):>6}" - kp = f"{profile.kp:>12f}"[0:12] - pname = profile.name - if profile.name == "": - # Generate name from profile id prefixed with - # 'p' (and replace space char with '0' char) - pname = f"p{profile.id:>3}".replace(" ", "0") - name = f"{pname:<19}" - - # Generate sediment additional data if available - sediment = "" - if profile.sl is not None: - if not any(filter(lambda f: ".GRA" in f, files)): - files.append(gra_file) - - # Number of layers - nl = len(profile.sl) - sediment = f" {nl:>3}" - - # Layers data - for layer in profile.sl.layers: - sediment += ( - f" {layer.height:>10} {layer.d50:>10} " + - f"{layer.sigma:>10} " + - f"{layer.critical_constraint:>10}" - ) - - # Profile header line - f.write(f"{num}{c1}{c2}{t} {kp} {name} {sediment}\n") + self._export_ST_profile_header( + f, files, profile, cnt_num + ) cnt_num += 1 # Points for point in profile.points: - x = f"{point.x:<12f}"[0:12] - y = f"{point.y:<12f}"[0:12] - z = f"{point.z:<12f}"[0:12] - n = f"{point.name:<3}" - - # Generate sediment additional data if available - sediment = "" - prev = point.z - if point.sl is not None: - # Number of layers - nl = len(point.sl) - sediment = f"{nl:>3}" - - # Layers data - for layer in point.sl.layers: - prev = round(prev - layer.height, 5) - sediment += ( - f" {prev:>10} {layer.d50:>10} " + - f"{layer.sigma:>10} " + - f"{layer.critical_constraint:>10}" - ) - - # Point line - f.write(f"{x} {y} {z} {n} {sediment}\n") + self._export_ST_point_line( + f, files, point + ) # Profile last line f.write(f" 999.9990 999.9990 999.9990\n") return files + def _export_ST_profile_header(self, wfile, files, + profile, cnt): + num = f"{cnt:>6}" + c1 = f"{profile.code1:>6}" + c2 = f"{profile.code2:>6}" + t = f"{len(profile.points):>6}" + kp = f"{profile.kp:>12f}"[0:12] + pname = profile.name + if profile.name == "": + # Generate name from profile id prefixed with + # 'p' (and replace space char with '0' char) + pname = f"p{profile.id:>3}".replace(" ", "0") + name = f"{pname:<19}" + + # Generate sediment additional data if available + sediment = "" + if profile.sl is not None: + if not any(filter(lambda f: ".GRA" in f, files)): + files.append(self._gra_file) + + # Number of layers + nl = len(profile.sl) + sediment = f" {nl:>3}" + + # Layers data + for layer in profile.sl.layers: + sediment += ( + f" {layer.height:>10} {layer.d50:>10} " + + f"{layer.sigma:>10} " + + f"{layer.critical_constraint:>10}" + ) + + # Profile header line + wfile.write(f"{num}{c1}{c2}{t} {kp} {name} {sediment}\n") + + def _export_ST_point_line(self, wfile, files, point): + x = f"{point.x:<12f}"[0:12] + y = f"{point.y:<12f}"[0:12] + z = f"{point.z:<12f}"[0:12] + n = f"{point.name:<3}" + + # Generate sediment additional data if available + sediment = "" + prev = point.z + if point.sl is not None: + # Number of layers + nl = len(point.sl) + sediment = f"{nl:>3}" + + # Layers data + for layer in point.sl.layers: + prev = round(prev - layer.height, 5) + sediment += ( + f" {prev:>10} {layer.d50:>10} " + + f"{layer.sigma:>10} " + + f"{layer.critical_constraint:>10}" + ) + + # Point line + wfile.write(f"{x} {y} {z} {n} {sediment}\n") + @timer def _export_BC(self, t, bounds, repertory, qlog, name="0"): files = [] @@ -429,6 +439,10 @@ class Mage(CommandLineSolver): self._study = study name = study.name.replace(" ", "_") + # Define GRA file name + self._gra_file = f"{name}.GRA" + self._bin_file = f"{name}.BIN" + self._export_ST(study, repertory, qlog, name=name) return True @@ -622,6 +636,11 @@ class Mage8(Mage): self._study = study name = study.name.replace(" ", "_") + # Define GRA file name + self._gra_file = f"{name}.GRA" + self._bin_file = f"{name}.BIN" + + # Generate files files = [] files = self._export_ST(study, repertory, qlog, name=name) From fdac834d4851529733e1a8c046085a711cd546d2 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:34:11 +0200 Subject: [PATCH 34/36] Tests: Fix pep8. --- src/test_pamhyr.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test_pamhyr.py b/src/test_pamhyr.py index d8d5cf9a..32ec8cd6 100644 --- a/src/test_pamhyr.py +++ b/src/test_pamhyr.py @@ -79,15 +79,14 @@ class ToolsCMDParserTestCase(unittest.TestCase): self.assertEqual(res[i], s) def test_unix_escape_space(self): - cmd = "/foo/bar\ baz -a -b -c" - expect = ["/foo/bar\ baz", "-a", '-b', "-c"] + cmd = r"/foo/bar\ baz -a -b -c" + expect = [r"/foo/bar\ baz", "-a", '-b', "-c"] res = parse_command_line(cmd) for i, s in enumerate(expect): self.assertEqual(res[i], s) - def test_windows_prog_files(self): cmd = "\"C:\\Program Files (x86)\foo\bar\" a -b -c" expect = ["C:\\Program Files (x86)\foo\bar", "a", '-b', "-c"] From b79c5b30c0e54ae2120aaeb8ad451d1124011dd4 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:50:36 +0200 Subject: [PATCH 35/36] tests: Add tests for flatten function. --- src/test_pamhyr.py | 64 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/test_pamhyr.py b/src/test_pamhyr.py index 32ec8cd6..7066a653 100644 --- a/src/test_pamhyr.py +++ b/src/test_pamhyr.py @@ -20,7 +20,69 @@ import os import unittest import tempfile -from tools import parse_command_line +from tools import flatten, parse_command_line + + +class FlattenTestCase(unittest.TestCase): + def test_flatten_0(self): + input = [] + output = [] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) + + def test_flatten_1(self): + input = [['foo']] + output = ['foo'] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) + + def test_flatten_2(self): + input = [['foo', 'bar']] + output = ['foo', 'bar'] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) + + def test_flatten_3(self): + input = [['foo'], ['bar']] + output = ['foo', 'bar'] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) + + def test_flatten_4(self): + input = [['foo'], ['bar', 'baz'], ['bazz']] + output = ['foo', 'bar', 'baz', 'bazz'] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) + + def test_flatten_5(self): + input = [['foo'], ['bar', ['baz']], ['bazz']] + output = ['foo', 'bar', ['baz'], 'bazz'] + + res = flatten(input) + + self.assertEqual(len(res), len(output)) + for i, o in enumerate(output): + self.assertEqual(res[i], o) class ToolsCMDParserTestCase(unittest.TestCase): From 77f06aaa078ddbb37b107b1e172e72dd94ec50ce Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Rouby Date: Fri, 27 Oct 2023 11:53:00 +0200 Subject: [PATCH 36/36] tools: Time the parser function. --- src/tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tools.py b/src/tools.py index d6e787b3..cf4a0966 100644 --- a/src/tools.py +++ b/src/tools.py @@ -350,6 +350,7 @@ class SQL(object): parser_special_char = ["\"", "\'"] +@timer def parse_command_line(cmd): """Parse command line string and return list of string arguments