diff --git a/src/Model/Scenario.py b/src/Model/Scenario.py
index 3c9097f2..f49491c8 100644
--- a/src/Model/Scenario.py
+++ b/src/Model/Scenario.py
@@ -185,3 +185,19 @@ class Scenario(SQLSubModel):
@property
def parent(self):
return self._parent
+
+ def __setitem__(self, key, value):
+ if key == "name":
+ self.name = value
+ if key == "description":
+ self.description = description
+
+ def __getitem__(self, key):
+ if key == "name":
+ return self.name
+ if key == "description":
+ return self.description
+ if key == "parent":
+ return self.parent
+
+ return None
diff --git a/src/Model/Scenarios.py b/src/Model/Scenarios.py
index 254cdc44..1bc8e961 100644
--- a/src/Model/Scenarios.py
+++ b/src/Model/Scenarios.py
@@ -59,3 +59,12 @@ class Scenarios(PamhyrModelDict):
new = Scenario(parent=parent)
self.set(new._id, new)
return new
+
+ @property
+ def lst(self):
+ return list(
+ map(
+ lambda k: self.get(k),
+ self._dict
+ )
+ )
diff --git a/src/Modules.py b/src/Modules.py
index f8ed5b65..47389585 100644
--- a/src/Modules.py
+++ b/src/Modules.py
@@ -42,6 +42,7 @@ class Modules(IterableFlag):
# General
STUDY = auto()
CONFIG = auto()
+ SCENARIOS = auto()
# Modelling
NETWORK = auto()
@@ -65,6 +66,7 @@ class Modules(IterableFlag):
def values(cls):
return [
cls.STUDY, cls.CONFIG,
+ cls.SCENARIOS,
cls.NETWORK,
cls.GEOMETRY,
cls.BOUNDARY_CONDITION,
@@ -143,6 +145,7 @@ class Modules(IterableFlag):
_impact = {
+ Modules.SCENARIOS: Modules.values(),
Modules.NETWORK: [
Modules.GEOMETRY, Modules.BOUNDARY_CONDITION,
Modules.LATERAL_CONTRIBUTION, Modules.FRICTION,
diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py
index fc16bee4..fdcfcc70 100644
--- a/src/View/MainWindow.py
+++ b/src/View/MainWindow.py
@@ -57,6 +57,7 @@ from View.MainWindowTabChecker import WidgetChecker
from View.Configure.Window import ConfigureWindow
from View.Study.Window import NewStudyWindow
from View.About.Window import AboutWindow
+from View.Scenarios.Window import ScenariosWindow
from View.Network.Window import NetworkWindow
from View.Geometry.Window import GeometryWindow
from View.BoundaryCondition.Window import BoundaryConditionWindow
@@ -109,6 +110,7 @@ define_model_action = [
"action_toolBar_boundary_cond", "action_toolBar_lateral_contrib",
"action_toolBar_frictions", "action_toolBar_initial_cond",
# Menu
+ "action_menu_edit_scenarios",
"action_menu_run_solver", "action_menu_numerical_parameter",
"action_menu_edit_network", "action_menu_edit_geometry",
"action_menu_boundary_conditions", "action_menu_initial_conditions",
@@ -235,6 +237,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
"action_menu_save": self.save_study,
"action_menu_save_as": self.save_as_study,
"action_menu_numerical_parameter": self.open_solver_parameters,
+ "action_menu_edit_scenarios": self.open_scenarios,
"action_menu_edit_network": self.open_network,
"action_menu_edit_geometry": self.open_geometry,
"action_menu_boundary_conditions": self.open_boundary_cond,
@@ -993,6 +996,22 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
self.network = NetworkWindow(study=self._study, parent=self)
self.network.show()
+ def open_scenarios(self):
+ """Open scenarios window
+
+ Returns:
+ Nothing
+ """
+ if self._study is not None:
+ if self.sub_window_exists(
+ ScenariosWindow,
+ data=[self._study, None]
+ ):
+ return
+
+ self.scenarios = ScenariosWindow(study=self._study, parent=self)
+ self.scenarios.show()
+
def open_geometry(self):
"""Open geometry window
diff --git a/src/View/Scenarios/ContextMenu.py b/src/View/Scenarios/ContextMenu.py
new file mode 100644
index 00000000..bcee3f0f
--- /dev/null
+++ b/src/View/Scenarios/ContextMenu.py
@@ -0,0 +1,112 @@
+# ContextMenu.py -- Pamhyr
+# Copyright (C) 2024 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.QtWidgets import (
+ QMenu,
+)
+
+
+class AbstractMenu(object):
+ def __init__(self, event=None, pos=None, items=[],
+ graph=None, trad=None, parent=None):
+ super(AbstractMenu, self).__init__()
+
+ self._menu = QMenu(parent)
+ self._event = event
+ self._pos = pos
+ self._items = items
+
+ self._graph = graph
+ self._trad = trad
+ self._parent = parent
+
+ def map_to_global(self):
+ return self._parent.mapToGlobal(self._event.pos())
+
+ def _exec(self):
+ return self._menu.exec_(self.map_to_global())
+
+ def run(self):
+ return
+
+
+class DefaultMenu(AbstractMenu):
+ def run(self):
+ add_node = self._menu.addAction(self._trad["menu_add_node"])
+
+ action = self._exec()
+ if action == add_node:
+ self._parent.add_node(self._pos)
+
+
+class NodeMenu(AbstractMenu):
+ def run(self):
+ item = self._items[0]
+ node = item.node
+
+ delete = self._menu.addAction(self._trad["menu_del_node"])
+
+ is_internal = self._graph.is_internal_node(node)
+ if is_internal:
+ res = self._graph.reservoir.get_assoc_to_node(node)
+ if res is not None:
+ edt_res = self._menu.addAction(
+ self._trad["menu_edit_res_node"]
+ )
+ del_res = self._menu.addAction(
+ self._trad["menu_del_res_node"]
+ )
+ res_edit = True
+ else:
+ add_res = self._menu.addAction(self._trad["menu_add_res_node"])
+ res_edit = False
+
+ action = self._exec()
+ if action == delete:
+ self._parent.del_node(item)
+ elif is_internal:
+ if res_edit:
+ if action == edt_res:
+ self._parent.edit_node_reservoir(node)
+ elif action == del_res:
+ self._parent.del_node_reservoir(node)
+ else:
+ if action == add_res:
+ self._parent.add_node_reservoir(node)
+
+
+class EdgeMenu(AbstractMenu):
+ def run(self):
+ it = self._items[0]
+
+ reverse = self._menu.addAction(self._trad["menu_rev_edge"])
+ delete = self._menu.addAction(self._trad["menu_del_edge"])
+ if self._parent.graph.is_enable_edge(it.edge):
+ enable = self._menu.addAction(self._trad["menu_dis_edge"])
+ is_enable = True
+ else:
+ enable = self._menu.addAction(self._trad["menu_ena_edge"])
+ is_enable = False
+
+ action = self._exec()
+ if action == delete:
+ self._parent.del_edge(it)
+ elif action == enable:
+ self._parent.enable_edge(it, is_enable)
+ elif action == reverse:
+ self._parent.reverse_edge(it)
diff --git a/src/View/Scenarios/GraphWidget.py b/src/View/Scenarios/GraphWidget.py
new file mode 100644
index 00000000..6638362a
--- /dev/null
+++ b/src/View/Scenarios/GraphWidget.py
@@ -0,0 +1,813 @@
+# GraphWidget.py -- Pamhyr
+# Copyright (C) 2023-2024 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 math
+import logging
+
+from tools import timer
+
+from PyQt5.QtCore import (
+ Qt, QPoint, QPointF, QSizeF, QLineF, QRectF,
+ pyqtSlot, pyqtSignal, QCoreApplication,
+)
+from PyQt5.QtGui import (
+ QPainter, QColor, QBrush, QPen, QPainterPath,
+ QPolygonF,
+)
+from PyQt5.QtWidgets import (
+ QApplication, QGraphicsScene, QGraphicsView,
+ QGraphicsItem, QGraphicsTextItem, QMenu,
+)
+
+from View.Scenarios.UndoCommand import *
+from View.Scenarios.ContextMenu import (
+ DefaultMenu, NodeMenu, EdgeMenu,
+)
+
+logger = logging.getLogger()
+
+_translate = QCoreApplication.translate
+
+
+class ScenarioItem(QGraphicsItem):
+ Type = QGraphicsItem.UserType + 1
+
+ def __init__(self, node, graph_widget):
+ super(ScenarioItem, self).__init__()
+
+ self.node = node
+ self.setPos(QPointF(self.node.pos.x, self.node.pos.y))
+
+ self.graph = graph_widget
+
+ self.setFlag(QGraphicsItem.ItemIsMovable)
+ self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
+ self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
+ self.setZValue(1)
+
+ def type(self):
+ return NodeItem.Type
+
+ def boundingRect(self):
+ adjust = 2.0
+ return QRectF(-10 - adjust, -10 - adjust, 20 + adjust, 20 + adjust)
+
+ def shape(self):
+ path = QPainterPath()
+ if self.graph.graph._reservoir.get_assoc_to_node(self.node) is None:
+ path.addEllipse(-10, -10, 20, 20)
+ else:
+ path.addRect(-10, -10, 20, 20)
+ return path
+
+ @timer
+ def paint(self, painter, option, widget):
+ painter.setPen(Qt.NoPen)
+
+ # Select color in function of node position in graph and
+ # status
+ color = Qt.darkBlue
+ if self.graph.selected_new_edge_src_node() == self:
+ color = Qt.darkRed
+ elif self.graph.selected_item() == self:
+ color = Qt.red
+ elif not self.graph.graph.is_enable_node(self.node):
+ color = Qt.darkGray
+ elif self.graph.graph.is_upstream_node(self.node):
+ color = Qt.yellow
+ elif self.graph.graph.is_downstream_node(self.node):
+ color = Qt.green
+
+ painter.setBrush(QBrush(color))
+ if self.graph.graph._reservoir.get_assoc_to_node(self.node) is None:
+ painter.drawEllipse(-10, -10, 20, 20)
+ else:
+ painter.drawRect(-10, -10, 20, 20)
+
+ def itemChange(self, change, value):
+ if change == QGraphicsItem.ItemPositionHasChanged:
+ self.graph.node_change_position(value, self)
+
+ self.graph.update_edges(self)
+ return super(NodeItem, self).itemChange(change, value)
+
+ def mousePressEvent(self, event):
+ self.update()
+ if not self.graph._only_display:
+ super(NodeItem, self).mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ self.update()
+ super(NodeItem, self).mouseReleaseEvent(event)
+
+ def mouseMoveEvent(self, event):
+ self.update()
+ if not self.graph._only_display:
+ super(NodeItem, self).mouseMoveEvent(event)
+
+
+class EdgeItem(QGraphicsItem):
+ _arc_dist = 0.1
+
+ Type = QGraphicsItem.UserType + 2
+
+ def __init__(self, src_node_item, dest_node_item,
+ edge, graph_widget):
+ super(EdgeItem, self).__init__()
+
+ self.src_node = src_node_item
+ self.dest_node = dest_node_item
+ self.edge = edge
+
+ self.graph = graph_widget
+ self._ind = 1
+
+ geometry = self.compute_arc_two_point(
+ QPointF(
+ self.src_node.pos().x(),
+ self.src_node.pos().y()
+ ),
+ QPointF(
+ self.dest_node.pos().x(),
+ self.dest_node.pos().y()
+ ),
+ )
+
+ self._bound_rect = geometry['bound_rect']
+ self._arc = geometry['arc']
+
+ self.setAcceptedMouseButtons(Qt.NoButton)
+
+ def type(self):
+ return Edge.Type
+
+ def boundingRect(self):
+ # Rectangle of edge for display update
+ pen_width = 2.0
+ extra = 0 # (pen_width + 5) / 2.0
+
+ return self._bound_rect.normalized().adjusted(
+ -extra, -extra, extra, extra
+ )
+
+ def shape(self):
+ return self._arc
+
+ def paint(self, painter, option, widget):
+ # self.paint_line(painter, option, widget)
+ self.paint_arc(painter, option, widget)
+
+ @timer
+ def paint_line(self, painter, option, widget):
+ # Draw shape of the edge
+ # color = QColor(Qt.white)
+ # color.setAlpha(128)
+ # painter.setBrush(color)
+ # painter.drawPath(self.shape())
+
+ line = QLineF(self.src_node.pos(), self.dest_node.pos())
+ if line.length() == 0.0:
+ return
+
+ # Select color
+ # color = Qt.darkBlue
+ color = Qt.black
+ if self.graph.selected_item() == self:
+ color = Qt.red
+ elif self.graph.current_edge() == self:
+ color = Qt.blue
+ elif not self.graph.graph.is_enable_edge(self.edge):
+ color = Qt.darkGray
+
+ painter.setPen(
+ QPen(
+ color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin
+ )
+ )
+
+ painter.drawLine(line)
+
+ # Draw arrow
+ center = QPointF(
+ (self.src_node.pos().x() + self.dest_node.pos().x()) / 2,
+ (self.src_node.pos().y() + self.dest_node.pos().y()) / 2,
+ )
+ angle = math.acos(line.dx() / line.length())
+ if line.dy() >= 0:
+ angle = (math.pi * 2.0) - angle
+
+ self.paint_arrow(
+ painter, option, widget,
+ color, center, angle
+ )
+
+ @timer
+ def paint_arc(self, painter, option, widget):
+ line = QLineF(self.src_node.pos(), self.dest_node.pos())
+ if line.length() == 0.0:
+ return
+
+ # Select color
+ # color = Qt.darkBlue
+ color = Qt.black
+ if self.graph.selected_item() == self:
+ color = Qt.red
+ elif self.graph.current_edge() == self:
+ color = Qt.blue
+ elif not self.graph.graph.is_enable_edge(self.edge):
+ color = Qt.darkGray
+
+ painter.setPen(
+ QPen(
+ color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin
+ )
+ )
+
+ geometry = self.compute_arc_two_point(
+ QPointF(
+ self.src_node.pos().x(),
+ self.src_node.pos().y()
+ ),
+ QPointF(
+ self.dest_node.pos().x(),
+ self.dest_node.pos().y()
+ ),
+ )
+
+ self._bound_rect = geometry['bound_rect']
+ self._arc = geometry['arc']
+
+ painter.drawPath(self._arc)
+
+ line = geometry['line']
+ angle = math.acos(line.dx() / line.length())
+ if line.dy() >= 0:
+ angle = (math.pi * 2.0) - angle
+
+ self.paint_arrow(
+ painter, option, widget,
+ color, geometry['c'], angle,
+ )
+
+ def compute_arc_two_point(self, a, b):
+ # This function is a black magic spell for invoking an arc
+ # path between two points in Qt! (In fact, it's just cryptic
+ # trigonometry functions from stackoverflow:
+ # https://stackoverflow.com/questions/26901540/arc-in-qgraphicsscene)
+ c = self.compute_arc_p3(a, b)
+
+ line_ac = QLineF(a, c)
+ line_bc = QLineF(b, c)
+ line_ab = QLineF(a, b)
+
+ rad = abs(
+ line_bc.length() / (
+ 2 * math.sin(
+ math.radians(
+ line_ac.angleTo(line_ab)
+ )
+ )
+ )
+ )
+
+ bisector_bc = QLineF(line_bc.pointAt(0.5), line_bc.p2())
+ bisector_bc.setAngle(line_bc.normalVector().angle())
+
+ bisector_ab = QLineF(line_ab.pointAt(0.5), line_ab.p2())
+ bisector_ab.setAngle(line_ab.normalVector().angle())
+
+ center = QPointF(0, 0)
+ bisector_ab.intersect(bisector_bc, center)
+
+ circle = QRectF(
+ center.x() - rad,
+ center.y() - rad,
+ rad * 2, rad * 2
+ )
+
+ line_oa = QLineF(center, a)
+ line_ob = QLineF(center, b)
+ line_oc = QLineF(center, c)
+
+ start = line_ob
+ end = line_oa
+
+ w = 1
+ bounding_rect = circle.adjusted(
+ -w, -w, w, w
+ )
+
+ path = QPainterPath()
+ path.arcMoveTo(circle, start.angle())
+ path.arcTo(circle, start.angle(), start.angleTo(end))
+
+ return {
+ 'bound_rect': bounding_rect,
+ 'arc': path,
+ 'c': c,
+ 'line': line_ab,
+ }
+
+ def compute_arc_p3(self, p1, p2):
+ dist_p3 = self._ind * self._arc_dist
+
+ center_p1_p2 = QPointF(
+ (p1.x() + p2.x()) / 2,
+ (p1.y() + p2.y()) / 2
+ )
+
+ # u = (p2.x() - p1.x(), p2.y() - p1.y())
+ v = (p2.y() - p1.y(), p1.x() - p2.x())
+ p3 = QPointF(
+ center_p1_p2.x() + dist_p3 * v[0],
+ center_p1_p2.y() + dist_p3 * v[1]
+ )
+
+ return p3
+
+ def paint_arrow(self, painter, option, widget,
+ color, center, angle):
+ brush = QBrush()
+ brush.setColor(color)
+ brush.setStyle(Qt.SolidPattern)
+
+ size = 10.0
+ arrow_p1 = center + QPointF(
+ math.sin(angle - math.pi / 3) * size,
+ math.cos(angle - math.pi / 3) * size
+ )
+ arrow_p2 = center + QPointF(
+ math.sin(angle - math.pi + math.pi / 3) * size,
+ math.cos(angle - math.pi + math.pi / 3) * size
+ )
+ poly = QPolygonF([center, arrow_p1, arrow_p2])
+ path = QPainterPath()
+ path.addPolygon(poly)
+
+ painter.drawPolygon(poly)
+ painter.fillPath(path, brush)
+
+
+class NodeText(QGraphicsTextItem):
+ def __init__(self, node_item):
+ super(NodeText, self).__init__()
+
+ self.item = node_item
+ self.setPlainText(self.item.node.name)
+ self.setDefaultTextColor(Qt.black)
+ self.set_custom_pos(self.item.pos())
+ self.setZValue(2)
+
+ def set_custom_pos(self, pos):
+ x = pos.x()
+ y = pos.y() - 42 # Dont panic! The answer is 42
+
+ self.setPos(QPointF(x, y))
+
+ @timer
+ def paint(self, painter, option, widget):
+ color = QColor(Qt.white)
+ color.setAlpha(128)
+
+ # Draw text background
+ painter.setBrush(color)
+ painter.drawRect(self.boundingRect())
+
+ # Draw text
+ super(NodeText, self).paint(painter, option, widget)
+
+ def rename(self):
+ # Update the node text
+ self.setPlainText(self.item.node.name)
+
+
+class GraphWidget(QGraphicsView):
+ changeEdge = pyqtSignal(object)
+ changeNode = pyqtSignal(object)
+
+ def __init__(self, graph, parent=None,
+ min_size=(400, 400), max_size=None,
+ size=None, only_display=False, undo=None,
+ trad=None):
+ super(GraphWidget, self).__init__(parent=parent)
+
+ self.parent = parent
+ self._state = "move"
+ self._only_display = only_display
+ self._undo = undo
+ self._trad = trad
+
+ self.graph = graph
+
+ self._selected_item = None
+ self._selected_new_edge_src_node = None
+ self._current_edge = None
+ self._current_moved_node = None
+ self.tmp_line = None
+
+ self.node_items = []
+ self.edge_items = []
+ self.texts = {}
+
+ self.m_origin_x = 0.0
+ self.m_origin_y = 0.0
+ self.clicked = False
+
+ self.setup_scene(min_size, max_size, size)
+
+ def setup_scene(self, min_size, max_size, size):
+ scene = QGraphicsScene(self)
+ scene.setItemIndexMethod(QGraphicsScene.NoIndex)
+ scene.setSceneRect(0, 0, 2000, 2000)
+
+ self.setScene(scene)
+ self.setCacheMode(QGraphicsView.CacheBackground)
+ self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate)
+ self.setRenderHint(QPainter.Antialiasing)
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
+ self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
+
+ self.scale(1, 1)
+ self.previousScale = 1
+
+ if min_size:
+ self.setMinimumSize(*min_size)
+ if max_size:
+ self.setMaximumSize(*max_size)
+ if size:
+ self.resize(*size)
+
+ self.create_items()
+
+ @timer
+ def create_items(self):
+ """Create all items and draw its
+
+ Returns:
+ Nothing
+ """
+ self.node_items = []
+ self.edge_items = []
+ self.texts = {}
+
+ for node in self.graph.nodes():
+ inode = NodeItem(node, self)
+ self.texts[inode] = NodeText(inode)
+ self.scene().addItem(self.texts[inode])
+ self.scene().addItem(inode)
+ self.node_items.append(inode)
+
+ curr_edge = self.graph.current_reach()
+
+ iedges = []
+ multiple_edges = {}
+ for edge in self.graph.edges():
+ n1 = next(
+ filter(
+ lambda n: n.node.name == edge.node1.name,
+ self.node_items
+ )
+ )
+
+ n2 = next(
+ filter(
+ lambda n: n.node.name == edge.node2.name,
+ self.node_items
+ )
+ )
+
+ # Multiple edges counter
+ if (n1, n2) not in multiple_edges:
+ ind = 1
+ else:
+ ind = multiple_edges[(n1, n2)] + 1
+
+ multiple_edges[(n1, n2)] = ind
+
+ iedge = EdgeItem(n1, n2, edge, self)
+ iedge._ind = ind
+
+ if edge == curr_edge:
+ self._current_edge = iedge
+
+ iedges.append(iedge)
+
+ for iedge in reversed(iedges):
+ self.scene().addItem(iedge)
+ self.edge_items.append(iedge)
+
+ def state(self, status):
+ """Set the current status of draw widget
+
+ Set the current status of draw widget, like "move", "add" or
+ "del"
+
+ Args:
+ status: String of current status
+
+ Returns:
+ Nothing
+ """
+ self._state = status
+
+ def add_node(self, pos):
+ """Create a new node in graph and draw it
+
+ Args:
+ pos: The position of new node
+
+ Returns:
+ Nothing
+ """
+ node = self.graph.create_node(pos.x(), pos.y())
+ self._undo.push(
+ AddNodeCommand(
+ self.graph, node
+ )
+ )
+ inode = NodeItem(node, self)
+ self.scene().addItem(inode)
+ self.node_items.append(inode)
+ self.texts[inode] = NodeText(inode)
+ self.scene().addItem(self.texts[inode])
+
+ self.changeNode.emit(self.sender())
+
+ def del_node(self, item):
+ """Delete a node and update display
+
+ Args:
+ item: The node item to delete
+
+ Returns:
+ Nothing
+ """
+ node = item.node
+ self._undo.push(
+ DelNodeCommand(
+ self.graph, node
+ )
+ )
+
+ self.scene().clear()
+ self.create_items()
+ self.changeNode.emit(self.sender())
+ self.changeEdge.emit(self.sender())
+
+ def rename_nodes(self):
+ """Update all nodes item name in scene
+
+ Returns:
+ Nothing
+ """
+ for i in self.texts:
+ if type(i) is NodeItem:
+ self.texts[i].rename()
+
+ def add_node_reservoir(self, node):
+ self.parent.add_node_reservoir(node)
+ self.display_update()
+
+ def del_node_reservoir(self, node):
+ self.parent.del_node_reservoir(node)
+ self.changeNode.emit(self.sender())
+ self.display_update()
+
+ def edit_node_reservoir(self, node):
+ self.changeNode.emit(self.sender())
+ self.parent.edit_node_reservoir(node)
+
+ def enable_edge(self, edge, prev):
+ self._undo.push(
+ EnableEdgeCommand(
+ edge.edge, not prev
+ )
+ )
+ self.changeEdge.emit(self.sender())
+ self.changeNode.emit(self.sender())
+ self.display_update()
+
+ def reverse_edge(self, edge):
+ self._undo.push(
+ ReverseEdgeCommand(
+ edge.edge
+ )
+ )
+ self.changeEdge.emit(self.sender())
+ self.display_update()
+
+ def display_update(self):
+ """Clear the scene and redraw it
+
+ Returns:
+ Nothing
+ """
+ self.scene().clear()
+ self.create_items()
+
+ def keyPressEvent(self, event):
+ key = event.key()
+
+ if key == Qt.Key_Plus:
+ self.scaleView(1.2)
+ elif key == Qt.Key_Minus:
+ self.scaleView(1 / 1.2)
+ elif key == Qt.Key_Escape:
+ self.reset_selection()
+ else:
+ super(GraphWidget, self).keyPressEvent(event)
+
+ def drawBackground(self, painter, rect):
+ sceneRect = self.sceneRect()
+ painter.fillRect(
+ rect.intersected(sceneRect),
+ QBrush(Qt.lightGray)
+ )
+ painter.setBrush(Qt.NoBrush)
+ painter.drawRect(sceneRect)
+
+ def wheelEvent(self, event):
+ self.scaleView(math.pow(2.0, -event.angleDelta().y() / 240.0))
+
+ def scaleView(self, scaleFactor):
+ factor = self.transform().scale(
+ scaleFactor, scaleFactor
+ ).mapRect(QRectF(0, 0, 1, 1)).width()
+
+ if factor < 0.05 or factor > 1.5:
+ return
+
+ self.scale(scaleFactor, scaleFactor)
+
+ def mousePressEvent(self, event):
+ pos = self.mapToScene(event.pos())
+ self.clicked = True
+
+ if event.buttons() & Qt.RightButton:
+ self.update()
+ super(GraphWidget, self).mousePressEvent(event)
+ return
+
+ # Move item and select edge item
+ if self._state == "move":
+ self._selected_new_edge_src_node = None
+
+ items = self.items(event.pos())
+ if items and type(items[0]) is EdgeItem:
+ edge = items[0]
+ if edge:
+ self.set_current_edge(edge)
+ 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]
+
+ # Add nodes and edges
+ elif self._state == "add":
+ items = self.items(event.pos())
+ nodes = list(filter(lambda i: type(i) is NodeItem, items))
+ if not nodes:
+ self.add_node(pos)
+ self.set_selected_new_edge_src_node(None)
+ else:
+ if self.selected_new_edge_src_node() is None:
+ self.set_selected_new_edge_src_node(nodes[0])
+ else:
+ self.add_edge(self.selected_new_edge_src_node(), nodes[0])
+
+ # Delete nodes and edges
+ elif self._state == "del":
+ self._selected_new_edge_src_node = None
+ items = list(
+ filter(
+ lambda i: type(i) is NodeItem or type(i) is EdgeItem,
+ self.items(event.pos())
+ )
+ )
+ if len(items) > 0:
+ item = items[0]
+ if type(item) is NodeItem:
+ self.del_node(item)
+ elif type(item) is EdgeItem:
+ self.del_edge(item)
+
+ self.update()
+ super(GraphWidget, self).mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ self.clicked = False
+
+ if self._only_display:
+ return
+
+ if self._state == "move":
+ if self._current_moved_node is not None:
+ pos = self.mapToScene(event.pos())
+ self._undo.push(
+ SetNodePosCommand(
+ self._current_moved_node,
+ (pos.x(), pos.y()),
+ (self._mouse_origin_x,
+ self._mouse_origin_y)
+ )
+ )
+
+ self.update()
+ super(GraphWidget, self).mouseReleaseEvent(event)
+
+ def mouseMoveEvent(self, event):
+ pos = self.mapToScene(event.pos())
+
+ # Selecte item on the fly
+ items = self.items(event.pos())
+ selectable_items = list(
+ filter(
+ lambda i: (type(i) is NodeItem or type(i) is EdgeItem),
+ items
+ )
+ )
+
+ if selectable_items:
+ self.set_selected_item(selectable_items[0])
+ elif not self.clicked:
+ self.set_selected_item(None)
+
+ # Update temporary line
+ if self.selected_new_edge_src_node() is not None:
+ if self.tmp_line is None:
+ self.tmp_line = NewEdgeLine(
+ self.selected_new_edge_src_node().pos(),
+ pos
+ )
+ self.scene().addItem(self.tmp_line)
+ else:
+ self.tmp_line.dest = pos
+ self.tmp_line.update()
+
+ # If state is "move"
+ if self._state == "move":
+ # Move on scene
+ if not self.selected_item():
+ if event.buttons() & Qt.LeftButton:
+ old_p = self.mapToScene(
+ int(self.m_origin_x), int(self.m_origin_y)
+ )
+ new_p = self.mapToScene(event.pos())
+ translation = new_p - old_p
+
+ self.translate(translation.x(), translation.y())
+
+ self.m_origin_x = event.x()
+ self.m_origin_y = event.y()
+
+ # Propagate event
+ self.update()
+ super(GraphWidget, self).mouseMoveEvent(event)
+
+ # Contextual menu
+
+ def contextMenuEvent(self, event):
+ if self._only_display:
+ return
+
+ pos = self.mapToScene(event.pos())
+ items = self.items(event.pos())
+
+ # Select current menu
+ while len(items) > 0:
+ if type(items[0]) in [NodeItem, EdgeItem]:
+ break
+ else:
+ items = items[1:]
+
+ if len(items) == 0:
+ m_type = DefaultMenu
+ elif type(items[0]) is NodeItem:
+ m_type = NodeMenu
+ elif type(items[0]) is EdgeItem:
+ m_type = EdgeMenu
+ else:
+ m_type = DefaultMenu
+
+ # Create and exec menu
+ m = m_type(
+ event=event, pos=pos, items=items,
+ graph=self.graph, trad=self._trad, parent=self
+ )
+ m.run()
+ self.clicked = False
diff --git a/src/View/Scenarios/Table.py b/src/View/Scenarios/Table.py
new file mode 100644
index 00000000..69c06801
--- /dev/null
+++ b/src/View/Scenarios/Table.py
@@ -0,0 +1,82 @@
+# Table.py -- Pamhyr
+# Copyright (C) 2023-2024 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+import logging
+import traceback
+
+from tools import logger_exception
+
+from View.Scenarios.GraphWidget import GraphWidget
+from View.Scenarios.UndoCommand import *
+from View.Tools.PamhyrTable import PamhyrTableModel
+
+from PyQt5.QtCore import (
+ Qt, QRect, QVariant, QAbstractTableModel, pyqtSlot, pyqtSignal,
+ QEvent,
+)
+
+from PyQt5.QtWidgets import (
+ QTableView, QItemDelegate, QComboBox, QLineEdit, QHBoxLayout, QSlider,
+ QPushButton, QCheckBox, QStyledItemDelegate, QStyleOptionButton, QStyle,
+ QApplication,
+)
+
+logger = logging.getLogger()
+
+
+class ScenariosTableModel(PamhyrTableModel):
+ def _setup_lst(self):
+ self._lst = self._data.scenarios.lst
+
+ def data(self, index, role):
+ if role != Qt.ItemDataRole.DisplayRole:
+ return QVariant()
+
+ if self._headers[index.column()] == "parent":
+ parent = self._lst[index.row()][self._headers[index.column()]]
+ if parent is None:
+ return ""
+
+ return parent.name
+
+ return self._lst[index.row()][self._headers[index.column()]]
+
+ @pyqtSlot()
+ def setData(self, index, value, role=Qt.EditRole):
+ if not index.isValid():
+ return False
+
+ if role == Qt.EditRole:
+ try:
+ self._undo.push(
+ SetCommand(
+ self._lst[index.row()],
+ self._headers[index.column()],
+ value
+ )
+ )
+ except Exception as e:
+ logger_exception(e)
+
+ self.layoutChanged.emit()
+ return True
+
+ self.dataChanged.emit(index, index)
+
+ def update(self):
+ self.layoutChanged.emit()
diff --git a/src/View/Scenarios/UndoCommand.py b/src/View/Scenarios/UndoCommand.py
new file mode 100644
index 00000000..e90a5d7e
--- /dev/null
+++ b/src/View/Scenarios/UndoCommand.py
@@ -0,0 +1,68 @@
+# UndoCommand.py -- Pamhyr
+# Copyright (C) 2023-2024 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+from copy import deepcopy
+from tools import trace, timer
+
+from PyQt5.QtWidgets import (
+ QUndoCommand, QUndoStack,
+)
+
+
+class AddScenariosCommand(QUndoCommand):
+ def __init__(self, study, source):
+ QUndoCommand.__init__(self)
+
+ self._study = study
+ self._source = source
+
+ def undo(self):
+ logger.error(f"TODO {type(self)} : undo")
+
+ def redo(self):
+ self._study.new_scenario_from_current(switch=False)
+
+
+class DelScenariosCommand(QUndoCommand):
+ def __init__(self, study, scenario):
+ QUndoCommand.__init__(self)
+
+ self._study = study
+ self._scenario = scenario
+
+ def undo(self):
+ logger.error(f"TODO {type(self)} : undo")
+
+ def redo(self):
+ logger.error(f"TODO {type(self)} : redo")
+
+
+class SetCommand(QUndoCommand):
+ def __init__(self, scenario, column, new_value):
+ QUndoCommand.__init__(self)
+
+ self._el = scenario
+ self._column = column
+ self._old = self._el[self._column]
+ self._new = new_value
+
+ def undo(self):
+ self._el[self._column] = self._old
+
+ def redo(self):
+ self._el[self._column] = self._new
diff --git a/src/View/Scenarios/Window.py b/src/View/Scenarios/Window.py
new file mode 100644
index 00000000..0afe5ea7
--- /dev/null
+++ b/src/View/Scenarios/Window.py
@@ -0,0 +1,131 @@
+# Window.py -- Pamhyr
+# Copyright (C) 2023-2024 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 PyQt5.QtCore import QCoreApplication
+
+from PyQt5.QtGui import (
+ QKeySequence,
+)
+
+from PyQt5.QtCore import (
+ Qt, QRect, QVariant, QAbstractTableModel, pyqtSlot, pyqtSignal,
+ QEvent,
+)
+
+from PyQt5.QtWidgets import (
+ QTableView, QItemDelegate, QComboBox, QLineEdit, QHBoxLayout, QSlider,
+ QPushButton, QCheckBox, QStyledItemDelegate, QStyleOptionButton, QStyle,
+ QApplication, QToolBar, QAction, QHeaderView, QAbstractItemView,
+ QUndoStack, QShortcut,
+)
+
+from Modules import Modules
+from Model.River import RiverNode, RiverReach, River
+
+from View.Tools.PamhyrWindow import PamhyrWindow
+from View.Scenarios.GraphWidget import GraphWidget
+from View.Scenarios.UndoCommand import *
+from View.Scenarios.translate import ScenariosTranslate
+from View.Scenarios.Table import ScenariosTableModel
+
+# Reservoir short cut
+from View.Reservoir.Edit.Window import EditReservoirWindow
+import View.Reservoir.UndoCommand as ResUndoCommand
+
+
+logger = logging.getLogger()
+
+_translate = QCoreApplication.translate
+
+
+class ScenariosWindow(PamhyrWindow):
+ _pamhyr_ui = "Scenarios"
+ _pamhyr_name = "Scenarios"
+
+ def __init__(self, study=None, config=None, parent=None):
+ trad = ScenariosTranslate()
+ name = trad[self._pamhyr_name] + " - " + study.name
+
+ super(ScenariosWindow, self).__init__(
+ title=name,
+ study=study,
+ config=config,
+ trad=trad,
+ options=['undo'],
+ parent=parent,
+ )
+
+ self._table_headers_scenarios = self._trad.get_dict(
+ "table_headers_scenarios"
+ )
+
+ self.setup_graph()
+ self.setup_table()
+ self.setup_connections()
+
+ def setup_graph(self):
+ # self._graph_widget = GraphWidget(
+ # self._study,
+ # undo=self._undo_stack,
+ # trad=self._trad,
+ # parent=self,
+ # )
+
+ self._graph_layout = self.find(
+ QHBoxLayout, "horizontalLayout_graph"
+ )
+
+ # self._graph_layout.addWidget(self._graph_widget)
+
+ def setup_table(self):
+ table = self.find(QTableView, "tableView_scenarios")
+ self._scenarios_model = ScenariosTableModel(
+ table_view=table,
+ table_headers=self._table_headers_scenarios,
+ editable_headers=["name", "description"],
+ data=self._study,
+ undo=self._undo_stack,
+ )
+ table.setModel(self._scenarios_model)
+
+ table.setSelectionBehavior(QAbstractItemView.SelectRows)
+ table.horizontalHeader()\
+ .setSectionResizeMode(QHeaderView.Stretch)
+
+ def setup_connections(self):
+ self._scenarios_model.dataChanged.connect(self.update)
+ # self._scenarios_model.dataChanged.connect(
+ # self._graph_widget.rename_scenarios
+ # )
+ # self._graph_widget.changeScenarios.connect(self.update)
+
+ def _undo(self):
+ self._undo_stack.undo()
+ self.update()
+
+ def _redo(self):
+ self._undo_stack.redo()
+ self.update()
+
+ def update(self):
+ self._scenarios_model.update()
+ self._graph_widget.display_update()
+
+ self._propagate_update(key=Modules.SCENARIOS)
diff --git a/src/View/Scenarios/translate.py b/src/View/Scenarios/translate.py
new file mode 100644
index 00000000..101cf377
--- /dev/null
+++ b/src/View/Scenarios/translate.py
@@ -0,0 +1,45 @@
+# translate.py -- Pamhyr
+# Copyright (C) 2023-2024 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+from PyQt5.QtCore import QCoreApplication
+
+from View.Translate import MainTranslate
+
+_translate = QCoreApplication.translate
+
+
+class ScenariosTranslate(MainTranslate):
+ def __init__(self):
+ super(ScenariosTranslate, self).__init__()
+
+ self._dict["Scenarios"] = _translate(
+ "Scenarios", "Scenarios"
+ )
+
+ self._dict["menu_del_scenario"] = _translate(
+ "Scenarios", "Delete the scenario"
+ )
+ self._dict["menu_add_scenario"] = _translate(
+ "Scenarios", "Create new derived scenario"
+ )
+
+ self._sub_dict["table_headers_scenarios"] = {
+ "name": self._dict['name'],
+ "description": self._dict['description'],
+ "parent": _translate("Scenarios", "Parent"),
+ }
diff --git a/src/View/ui/MainWindow.ui b/src/View/ui/MainWindow.ui
index 1156fca9..1ab6f1b3 100644
--- a/src/View/ui/MainWindow.ui
+++ b/src/View/ui/MainWindow.ui
@@ -210,7 +210,14 @@
+
+
@@ -730,6 +737,11 @@
REP additional lines
+
+
+ Edit scenarios tree
+
+
diff --git a/src/View/ui/Scenarios.ui b/src/View/ui/Scenarios.ui
new file mode 100644
index 00000000..19dd347f
--- /dev/null
+++ b/src/View/ui/Scenarios.ui
@@ -0,0 +1,62 @@
+
+
+ MainWindow
+
+
+
+ 0
+ 0
+ 990
+ 600
+
+
+
+ MainWindow
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+
-
+
+
+
+
+
+
+
+ 650
+ 150
+
+
+
+
+ 16777215
+ 16777215
+
+
+
+
+
+
+
+
+
+
+
+
+