diff --git a/doc/dev/documentation.org b/doc/dev/documentation.org
index 40fe4e0e..48638366 100644
--- a/doc/dev/documentation.org
+++ b/doc/dev/documentation.org
@@ -1069,6 +1069,12 @@ gitlab-ci.
sudo python3 -m pip install pyinstaller
#+end_src
+In addition, you must install Glab[fn:glab] to allow the runner to
+proceed with the publication.
+
+[fn:glab] The Gitlab cli releases page:
+https://gitlab.com/gitlab-org/cli/-/releases
+
*** Windows (Wine) {{{windows}}}
The ci-runner environment for Wine need at least wine version 8, let
diff --git a/doc/images/wiki/en_scenarios_ensemble.png b/doc/images/wiki/en_scenarios_ensemble.png
new file mode 100644
index 00000000..1b843c3c
Binary files /dev/null and b/doc/images/wiki/en_scenarios_ensemble.png differ
diff --git a/doc/images/wiki/en_scenarios_ensemble_dialog.png b/doc/images/wiki/en_scenarios_ensemble_dialog.png
new file mode 100644
index 00000000..404f7c13
Binary files /dev/null and b/doc/images/wiki/en_scenarios_ensemble_dialog.png differ
diff --git a/doc/images/wiki/en_scenarios_ensemble_results.png b/doc/images/wiki/en_scenarios_ensemble_results.png
new file mode 100644
index 00000000..88d1d9cc
Binary files /dev/null and b/doc/images/wiki/en_scenarios_ensemble_results.png differ
diff --git a/doc/images/wiki/en_scenarios_ensemble_run_dialog.png b/doc/images/wiki/en_scenarios_ensemble_run_dialog.png
new file mode 100644
index 00000000..cbb94f80
Binary files /dev/null and b/doc/images/wiki/en_scenarios_ensemble_run_dialog.png differ
diff --git a/full-requirements.txt b/full-requirements.txt
index aa4197f5..4e094644 100644
--- a/full-requirements.txt
+++ b/full-requirements.txt
@@ -4,7 +4,7 @@ PyQt5-sip==12.18.0
PyQtWebEngine==5.15.6
QScintilla>=2.14.1
pyqtgraph>=0.12.1
-matplotlib==3.8.3
+matplotlib==3.10.8
numpy>=1.24.2
colorama>=0.4.3
pyinstaller>=5.11.0
@@ -14,3 +14,4 @@ platformdirs>=4.2.0
pyshp>=2.3.1
rasterio==1.3.11
#fortranformat==2.0.3
+psutil>=7.2.2
diff --git a/manifest.scm b/manifest.scm
index c3cd49e5..91f2527f 100644
--- a/manifest.scm
+++ b/manifest.scm
@@ -20,4 +20,4 @@
"python-pyqtgraph" "python-qscintilla"
"python-numpy@1" "python-shapely" "python-pyshp"
"python-matplotlib" "python-colorama"
- "python-rasterio"))
+ "python-rasterio" "python-psutil"))
diff --git a/requirements.txt b/requirements.txt
index ebe3523f..f1363d02 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,7 +4,7 @@ PyQt5-sip==12.18.0
#PyQtWebEngine==5.15.6
QScintilla>=2.14.1
pyqtgraph>=0.12.1
-matplotlib==3.8.3
+matplotlib==3.10.8
numpy>=1.24.2
colorama>=0.4.3
pyinstaller>=5.11.0
@@ -14,4 +14,4 @@ platformdirs>=4.2.0
pyshp>=2.3.1
rasterio==1.3.11
#fortranformat==2.0.3
-
+psutil>=7.2.2
diff --git a/src/Model/Ensembles/Distribution.py b/src/Model/Ensembles/Distribution.py
new file mode 100644
index 00000000..9a98d7aa
--- /dev/null
+++ b/src/Model/Ensembles/Distribution.py
@@ -0,0 +1,307 @@
+# Distribution.py -- Pamhyr
+# Copyright (C) 2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 struct
+import logging
+import itertools
+import numpy as np
+
+from tools import logger_exception
+
+from Model.Except import NotImplementedMethodeError
+from Model.Tools.PamhyrDB import SQLSubModel
+from Model.Scenario import Scenario
+
+logger = logging.getLogger()
+
+
+class Distribution(SQLSubModel):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "", script: str = "",
+ status=None):
+ super(Distribution, self).__init__(
+ id=id, status=status,
+ owner_scenario=0
+ )
+
+ self._name = name
+ self._type = "generic"
+ self._script = script
+ self._labels = []
+ self._default_values = []
+ self._params_checkers = []
+ self._params_tips = []
+
+ @property
+ def name(self):
+ return self._name
+
+ @property
+ def labels(self):
+ return self._labels
+
+ @property
+ def default_values(self):
+ return self._default_values
+
+ @classmethod
+ def _db_create(cls, execute, ext=""):
+ execute(f"""
+ CREATE TABLE ensemble_distribution{ext} (
+ {cls.create_db_add_pamhyr_id()},
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ name TEXT NOT NULL,
+ script TEXT NOT NULL,
+ PRIMARY KEY(pamhyr_id)
+ )
+ """)
+
+ if ext == "_tmp":
+ return True
+
+ return cls._create_submodel(execute)
+
+ @classmethod
+ def _db_update(cls, execute, version, data=None):
+ major, minor, release = version.strip().split(".")
+ if major == "0" and int(minor) < 2:
+ cls._db_create(execute)
+ elif major == "0" and int(minor) == 2:
+ if int(release) < 8:
+ cls._db_create(execute)
+
+ return cls._update_submodel(execute, version, data)
+
+ @classmethod
+ def _db_load(cls, execute, data=None):
+ new = []
+ scenario = data["scenario"]
+ loaded = data['loaded_pid']
+
+ table = execute(
+ "SELECT pamhyr_id, deleted, name, script " +
+ "FROM ensemble_distribution " +
+ f"WHERE pamhyr_id NOT IN ({', '.join(map(str, loaded))})"
+ )
+
+ for row in table:
+ it = iter(row)
+
+ id = next(it)
+ deleted = (next(it) == 1)
+ name = next(it)
+ script = next(it)
+
+ new_distribution = cls(
+ id, name=name, script=script,
+ status=data["status"]
+ )
+ if deleted:
+ new_distribution.set_as_deleted()
+
+ loaded.add(id)
+ new.append(new_distribution)
+
+ return new
+
+ def _db_save(self, execute, data=None):
+ # Only save custom distribution
+ if self._type != "custom":
+ return True
+
+ if not self.must_be_saved():
+ return True
+
+ execute(
+ "INSERT INTO " +
+ "ensemble_distribution(pamhyr_id, deleted, " +
+ " name, script) " +
+ "VALUES (?, ?, ?, ?)",
+ self.pamhyr_id, self.is_deleted(),
+ self._name, self._script
+ )
+
+ return True
+
+ def _data_traversal(self,
+ predicate=lambda obj, data: True,
+ modifier=lambda obj, data: None,
+ data={}):
+ if predicate(self, data):
+ modifier(self, data)
+
+ def is_valid_parameters(self, parameters):
+ valid = True
+ tips = []
+
+ for i, checker in enumerate(self._params_checkers):
+ ok = checker(parameters)
+ if not ok:
+ tips.append(self._params_tips[i])
+ valid = False
+
+ return (valid, tips)
+
+ def check_params(self, params):
+ return True
+
+ def random(self, r):
+ raise NotImplementedMethodeError(cls, cls._load)
+
+
+###############################
+# DISTRIBUTION IMPLEMENTATION #
+###############################
+
+class Uniform(Distribution):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "uniform", script: str = "",
+ status=None):
+ super(Uniform, self).__init__(
+ id=id, status=status,
+ name=name, script=script
+ )
+
+ self._labels = ["low", "high"]
+ self._default_values = [20.0, 40.0]
+ self._params_checkers = [self.check_params, self.check_params]
+ self._params_tips = ["uniform_low_tips", "uniform_high_tips"]
+
+ def check_params(self, params):
+ return (params[0] < params[1])
+
+ def random(self, params, nb):
+ lst = params.copy()
+ lst.append(nb)
+
+ return np.random.uniform(*lst)
+
+
+class Normal(Distribution):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "normal", script: str = "",
+ status=None):
+ super(Normal, self).__init__(
+ id=id, status=status,
+ name=name, script=script
+ )
+
+ self._labels = ["loc", "scale"]
+ self._default_values = [30.0, 5.0]
+ self._params_checkers = [self.check_params, self.check_scale]
+ self._params_tips = ["normal_loc_tips", "normal_scale_tips"]
+
+ def check_scale(self, params):
+ return 0 <= params[1]
+
+ def random(self, params, nb):
+ lst = params.copy()
+ lst.append(nb)
+
+ return np.random.normal(*lst)
+
+
+class Binomial(Distribution):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "binomial", script: str = "",
+ status=None):
+ super(Binomial, self).__init__(
+ id=id, status=status,
+ name=name, script=script
+ )
+
+ self._labels = ["binomial_n", "binomial_p"]
+ self._default_values = [100.0, 0.3]
+ self._params_checkers = [self.check_n, self.check_p]
+ self._params_tips = ["binomial_n_tips", "binomial_p_tips"]
+
+ def check_n(self, params):
+ n = params[0]
+ return (0 <= n) and (n == round(n))
+
+ def check_p(self, params):
+ p = params[1]
+ return 0 <= p <= 1
+
+ def random(self, params, nb):
+ lst = params.copy()
+ lst.append(nb)
+
+ return np.random.binomial(*lst)
+
+
+class Poisson(Distribution):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "poisson", script: str = "",
+ status=None):
+ super(Poisson, self).__init__(
+ id=id, status=status,
+ name=name, script=script
+ )
+
+ self._labels = ["poisson_lam"]
+ self._default_values = [5.0]
+ self._params_checkers = [self.check_lam]
+ self._params_tips = ["poisson_lam_tips"]
+
+ def check_lam(self, params):
+ lam = params[0]
+ return 0 <= lam
+
+ def random(self, params, nb):
+ lst = params.copy()
+ lst.append(nb)
+
+ return np.random.poisson(*lst)
+
+
+class Custom(Distribution):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "", script: str = "",
+ status=None):
+ super(Custom, self).__init__(
+ id=id, status=status,
+ name=name, script=script
+ )
+
+ self._labels = ["custom"]
+ self._type = "custom"
+ self._default_values = [""]
+ self._params_checkers = [self.check_params]
+ self._params_tips = ["custom_params_tips"]
+
+ def random(self, params, nb):
+ try:
+ # Run script to (re)define the sample distribution
+ f = eval(self._script)
+ return f(params, nb)
+ except Exception as e:
+ logger_exception(e)
+ return None # TODO: Raise helpful exception
diff --git a/src/Model/Ensembles/DistributionList.py b/src/Model/Ensembles/DistributionList.py
new file mode 100644
index 00000000..afab6653
--- /dev/null
+++ b/src/Model/Ensembles/DistributionList.py
@@ -0,0 +1,62 @@
+# DistributionList.py -- Pamhyr
+# Copyright (C) 2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 tools import trace, timer
+
+from Model.Tools.PamhyrListExt import PamhyrModelList
+from Model.Ensembles.Distribution import *
+
+
+class DistributionList(PamhyrModelList):
+ _sub_classes = [Distribution]
+
+ @classmethod
+ def _db_load(cls, execute, data=None):
+ new = cls(status=data['status'])
+
+ # Default distributions
+ d = {"status": data['status']}
+ new._lst = [
+ Uniform(**d),
+ Normal(**d),
+ Binomial(**d),
+ Poisson(**d),
+ ]
+
+ # DB custom distributions
+ new._lst += Distribution._db_load(
+ execute, data
+ )
+
+ return new
+
+ def _db_save(self, execute, data={}):
+ execute(
+ "DELETE FROM ensemble_distribution"
+ )
+
+ for fun in self._lst:
+ fun._db_save(execute, data)
+
+ return True
+
+ def new(self, index):
+ r = Custom(status=self._status)
+ self._lst.insert(index, r)
+ self._status.modified()
+ return r
diff --git a/src/Model/Ensembles/Ensemble.py b/src/Model/Ensembles/Ensemble.py
new file mode 100644
index 00000000..6dcad248
--- /dev/null
+++ b/src/Model/Ensembles/Ensemble.py
@@ -0,0 +1,279 @@
+# Ensemble.py -- Pamhyr
+# Copyright (C) 2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 struct
+import logging
+import itertools
+
+import numpy as np
+
+from Model.Tools.PamhyrDB import SQLSubModel
+from Model.Scenario import Scenario
+from Model.Ensembles.Distribution import Uniform
+
+logger = logging.getLogger()
+
+
+class Ensemble(SQLSubModel):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1, name: str = "",
+ distribution=None, params=[0., 100.],
+ data_type=None, target_data=None,
+ status=None, owner_scenario=-1):
+ super(Ensemble, self).__init__(
+ id=id, status=status,
+ owner_scenario=owner_scenario
+ )
+
+ self._name = name
+ self._data_type = data_type
+ self._distribution = distribution
+ self._params = params
+ self._target_data = target_data
+
+ @classmethod
+ def _db_create(cls, execute, ext=""):
+ execute(f"""
+ CREATE TABLE ensemble{ext} (
+ {cls.create_db_add_pamhyr_id()},
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ name TEXT NOT NULL,
+ type VARCHAR(8) NOT NULL,
+ distribution INTEGER NOT NULL,
+ distribution_name VARCHAR(32) NOT NULL,
+ params BLOB NOT NULL,
+ params_len INTEGER NOT NULL,
+ data_pid INTEGER NOT NULL,
+ {Scenario.create_db_add_scenario()},
+ {Scenario.create_db_add_scenario_fk()},
+ PRIMARY KEY(pamhyr_id, scenario)
+ )
+ """)
+
+ if ext == "_tmp":
+ return True
+
+ return cls._create_submodel(execute)
+
+ @classmethod
+ def _db_update(cls, execute, version, data=None):
+ major, minor, release = version.strip().split(".")
+ if major == "0" and int(minor) < 2:
+ cls._db_create(execute)
+ elif major == "0" and int(minor) == 2:
+ if int(release) < 8:
+ cls._db_create(execute)
+
+ return cls._update_submodel(execute, version, data)
+
+ @classmethod
+ def _db_load(cls, execute, data=None):
+ new = []
+ scenario = data["scenario"]
+ distributions = data["ens_distributions"]
+ stricklers = data["stricklers"]
+ loaded = data['loaded_pid']
+
+ table = execute(
+ "SELECT pamhyr_id, deleted, name, type, " +
+ "distribution, distribution_name, params, params_len, data_pid, " +
+ "scenario " +
+ "FROM ensemble " +
+ f"WHERE scenario = {scenario.id} " +
+ f"AND pamhyr_id NOT IN ({', '.join(map(str, loaded))})"
+ )
+
+ for row in table:
+ it = iter(row)
+
+ id = next(it)
+ deleted = (next(it) == 1)
+ name = next(it)
+ data_type = next(it)
+ fid = next(it)
+ fname = next(it)
+ params = next(it)
+ params_len = next(it)
+ data_pid = next(it)
+ owner_scenario = next(it)
+
+ def fn(f): return f._name == fname
+ if fid != -1:
+ def fn(f): return f._pamhyr_id == fid
+
+ distribution = next(
+ filter(fn, distributions.lst),
+ None
+ )
+
+ target_data = next(
+ filter(
+ lambda s: s._pamhyr_id == data_pid,
+ stricklers.lst
+ ),
+ None
+ )
+
+ rdata = list(cls._decode_params(params, params_len))
+
+ new_ensemble = cls(
+ id, name=name,
+ distribution=distribution,
+ params=rdata,
+ data_type=data_type,
+ target_data=target_data,
+ status=data["status"],
+ owner_scenario=owner_scenario
+ )
+ if deleted:
+ new_ensemble.set_as_deleted()
+
+ loaded.add(id)
+ new.append(new_ensemble)
+
+ return new
+
+ def _db_save(self, execute, data=None):
+ if not self.must_be_saved():
+ return True
+
+ execute(
+ "DELETE FROM ensemble " +
+ f"WHERE pamhyr_id = {self.pamhyr_id} " +
+ f"AND scenario = {self._status.scenario_id}"
+ )
+
+ bparams, length = self._encode_params()
+
+ if self._distribution is None:
+ fid = -1
+ fname = "NULL"
+ else:
+ fid = self._distribution._pamhyr_id
+ fname = self._distribution._name
+
+ if self._distribution._type == "generic":
+ fid = -1
+
+ data_pid = -1
+ if self._target_data is not None:
+ data_pid = self._target_data._pamhyr_id
+
+ execute(
+ "INSERT INTO " +
+ "ensemble(pamhyr_id, deleted, name, type, " +
+ " distribution, distribution_name, params, params_len, " +
+ " data_pid, scenario) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ self.pamhyr_id, self.is_deleted(),
+ self._name, self._data_type,
+ fid, fname, bparams, length,
+ data_pid, self._status.scenario_id
+ )
+
+ return True
+
+ def _encode_params(self):
+ length = len(self._params)
+ data_format = ">" + ''.join(itertools.repeat("d", length))
+
+ return struct.pack(data_format, *self._params), length
+
+ @classmethod
+ def _decode_params(cls, bparams, length):
+ data_format = ">" + ''.join(itertools.repeat("d", length))
+
+ return struct.unpack(data_format, bparams)
+
+ def random(self, nb):
+ return np.nditer(
+ self._distribution.random(
+ self._params, nb
+ )
+ )
+
+ @property
+ def name(self):
+ return self._name
+
+ @name.setter
+ def name(self, name):
+ self._name = name
+ self._status.modified()
+
+ @property
+ def distribution(self):
+ return self._distribution
+
+ @distribution.setter
+ def distribution(self, distribution):
+ self._distribution = distribution
+ self._status.modified()
+
+ @property
+ def params(self):
+ return self._params
+
+ @params.setter
+ def params(self, params):
+ self._params = params
+ self._status.modified()
+
+ @property
+ def data_type(self):
+ return self._data_type
+
+ @data_type.setter
+ def data_type(self, data_type):
+ self._data_type = data_type
+ self._status.modified()
+
+ @property
+ def target_data(self):
+ return self._target_data
+
+ @target_data.setter
+ def target_data(self, target_data):
+ self._target_data = target_data
+ self._status.modified()
+
+ def get_data_dict(self, use_ids=False):
+ target_data = self.target_data
+ distribution = self.distribution
+
+ if use_ids:
+ if self.target_data:
+ target_data = self.target_data.pamhyr_id
+ if self.distribution:
+ distribution = self.distribution.name
+
+ return {
+ "name": self.name,
+ "data_type": self.data_type,
+ "target_data": target_data,
+ "distribution": distribution,
+ "params": self.params,
+ }
+
+ def set_data_dict(self, data):
+ self.name = data["name"]
+ self.data_type = data["data_type"]
+ self.target_data = data["target_data"]
+ self.distribution = data["distribution"]
+ self.params = data["params"]
diff --git a/src/Model/Ensembles/EnsembleList.py b/src/Model/Ensembles/EnsembleList.py
new file mode 100644
index 00000000..b62bbe83
--- /dev/null
+++ b/src/Model/Ensembles/EnsembleList.py
@@ -0,0 +1,62 @@
+# EnsembleList.py -- Pamhyr
+# Copyright (C) 2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 tools import trace, timer
+
+from Model.Tools.PamhyrListExt import PamhyrModelList
+from Model.Ensembles.Ensemble import Ensemble
+
+
+class EnsembleList(PamhyrModelList):
+ _sub_classes = [Ensemble]
+
+ @classmethod
+ def _db_load(cls, execute, data=None):
+ new = cls(status=data['status'])
+
+ new._lst = Ensemble._db_load(
+ execute, data
+ )
+
+ return new
+
+ def _db_save(self, execute, data=None):
+ execute(
+ "DELETE FROM ensemble " +
+ f"WHERE scenario = {self._status.scenario_id}"
+ )
+
+ for ens in self._lst:
+ ens._db_save(execute, data=data)
+
+ return True
+
+ def new(self, index):
+ r = Ensemble(status=self._status)
+ self._lst.insert(index, r)
+ self._status.modified()
+ return r
+
+ def new_with_data(self, index, data):
+ r = Ensemble(status=self._status)
+
+ r.set_data_dict(data)
+
+ self._lst.insert(index, r)
+ self._status.modified()
+ return r
diff --git a/src/Model/Geometry/ProfileXYZ.py b/src/Model/Geometry/ProfileXYZ.py
index 1a50eba5..50c4dabe 100644
--- a/src/Model/Geometry/ProfileXYZ.py
+++ b/src/Model/Geometry/ProfileXYZ.py
@@ -862,6 +862,13 @@ class ProfileXYZ(Profile, SQLSubModel):
return n_zones
+ def froude(self, z, v):
+ return v / np.sqrt(
+ 9.81 * (
+ self.wet_area(z) / self.wet_width(z)
+ )
+ )
+
@timer
def get_all_water_limits_ac(self, z):
"""
diff --git a/src/Model/Results/Results.py b/src/Model/Results/Results.py
index ebc076b1..d9c4ff15 100644
--- a/src/Model/Results/Results.py
+++ b/src/Model/Results/Results.py
@@ -272,9 +272,9 @@ class TableData(SQLSubModel):
if len(shape) == 2:
shape_x, shape_y = shape
- elif len(shape == 1):
+ elif len(shape) == 1:
shape_x, = shape
- elif len(shape == 3):
+ elif len(shape) == 3:
shape_x, shape_y, shape_z = shape
else:
logger.error(
@@ -326,14 +326,14 @@ class TableData(SQLSubModel):
if isinstance(key, tuple):
return self._data[key[0], key[1]]
- self._date[key]
+ self._data[key]
class Results(SQLSubModel):
_sub_classes = [River, TableData, AdditionalData]
def __init__(self, id=-1, study=None, solver=None,
- repertory="", name="0"):
+ repertory="", name="0", is_ensemble=False):
super(Results, self).__init__(
id=id, status=study.status,
owner_scenario=study.status.scenario.id
@@ -346,6 +346,8 @@ class Results(SQLSubModel):
self._solver = solver
self._repertory = repertory
+ self._is_ensemble = is_ensemble
+
self._meta_data = {
# Keep results creation date
"creation_date": datetime.now(),
@@ -359,6 +361,9 @@ class Results(SQLSubModel):
if solver is not None:
self.set("solver_type", solver._type)
+ def is_ensemble(self):
+ return self._is_ensemble
+
@property
def date(self):
date = self._meta_data["creation_date"]
@@ -426,9 +431,11 @@ class Results(SQLSubModel):
solver_name TEXT NOT NULL,
solver_type TEXT NOT NULL,
study_revision INTEGER NOT NULL,
- creation_data DATE NOT NULL,
+ creation_date DATE NOT NULL,
nb_timestamps INTEGER NOT NULL,
timestamps BLOB NOT NULL,
+ seed INTEGER,
+ number_of_run INTEGER,
{Scenario.create_db_add_scenario()},
{Scenario.create_db_add_scenario_fk()},
PRIMARY KEY(pamhyr_id, scenario)
@@ -454,6 +461,21 @@ class Results(SQLSubModel):
cls._db_create(execute)
create = True
+ if major == "0" and int(minor) <= 2:
+ if int(release) < 9 and not create:
+ execute(
+ "ALTER TABLE results ADD COLUMN " +
+ "seed INTEGER"
+ )
+ execute(
+ "ALTER TABLE results ADD COLUMN " +
+ "number_of_run INTEGER"
+ )
+ execute(
+ "ALTER TABLE results RENAME COLUMN " +
+ "creation_data TO creation_date"
+ )
+
return cls._update_submodel(execute, version, data)
@classmethod
@@ -466,7 +488,9 @@ class Results(SQLSubModel):
table = execute(
"SELECT pamhyr_id, solver_name, solver_type, " +
- "study_revision, creation_data, nb_timestamps, timestamps, " +
+ "study_revision, creation_date, " +
+ "seed, number_of_run, " +
+ "nb_timestamps, timestamps, " +
"scenario " +
"FROM results " +
f"WHERE scenario = {scenario.id}"
@@ -486,6 +510,8 @@ class Results(SQLSubModel):
solver_type = next(it)
revision = next(it)
creation_date = next(it)
+ seed = next(it)
+ number_of_run = next(it)
nb_timestamps = next(it)
timestamps_bytes = next(it)
owner_scenario = next(it)
@@ -496,6 +522,9 @@ class Results(SQLSubModel):
new_results.set("solver_type", solver_type)
new_results.set("study_revision", revision)
new_results.set("creation_date", creation_date)
+ if seed is not None:
+ new_results.set("seed", seed)
+ new_results.set("number_of_run", number_of_run)
data["result_pid"] = pid
data["result"] = new_results
@@ -588,15 +617,25 @@ class Results(SQLSubModel):
ts = sorted(self.get("timestamps"))
sf = ">" + ''.join(itertools.repeat("d", len(ts)))
+ if "seed" in self._meta_data:
+ seed = int(self.get("seed"))
+ number_of_run = int(self.get("number_of_run"))
+ else:
+ seed = None
+ number_of_run = None
+
execute(
"INSERT INTO " +
"results (pamhyr_id, solver_name, solver_type, " +
- "study_revision, creation_data, " +
+ "study_revision, creation_date, " +
+ "seed, number_of_run, " +
"nb_timestamps, timestamps, " +
- "scenario) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "scenario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
self._pamhyr_id, solver_name, solver_type,
self._status.version, self.get("creation_date"),
- len(ts), struct.pack(sf, *ts), self._owner_scenario
+ seed, number_of_run,
+ len(ts), struct.pack(sf, *ts),
+ self._owner_scenario
)
data["result"] = self._pamhyr_id
diff --git a/src/Model/Results/River/River.py b/src/Model/Results/River/River.py
index cd6d591b..83db6e33 100644
--- a/src/Model/Results/River/River.py
+++ b/src/Model/Results/River/River.py
@@ -403,7 +403,7 @@ class Reach(SQLSubModel):
class River(SQLSubModel):
_sub_classes = [Reach]
- def __init__(self, study, parent):
+ def __init__(self, study, parent, init=True):
super(River, self).__init__(
id=-1, status=study.status,
owner_scenario=study.status.scenario.id
@@ -414,6 +414,9 @@ class River(SQLSubModel):
# Dict with timestamps as key
self._reachs = []
+ if init:
+ for i, _ in enumerate(self._study.river.enable_edges()):
+ self.add(i)
def __len__(self):
return len(self._reachs)
@@ -469,7 +472,7 @@ class River(SQLSubModel):
study = data["study"]
parent = data["parent"]
- new_river = cls(study, parent)
+ new_river = cls(study, parent, init=False)
for reach in study.river.reachs():
data["reach"] = reach.reach
diff --git a/src/Model/River.py b/src/Model/River.py
index 07e93efa..40fa4baa 100644
--- a/src/Model/River.py
+++ b/src/Model/River.py
@@ -69,6 +69,7 @@ from Model.WeatherParameters.WeatherParametersList \
import WeatherParametersList
from Model.GeoTIFF.GeoTIFFList import GeoTIFFList
+from Model.Ensembles.EnsembleList import EnsembleList
from Model.Results.Results import Results
logger = logging.getLogger()
@@ -487,6 +488,7 @@ class River(Graph):
BoundaryConditionsTemperatureList,
WeatherParametersList,
GeoTIFFList,
+ EnsembleList,
Results
]
@@ -532,6 +534,9 @@ class River(Graph):
self._geotiff = GeoTIFFList(status=self._status)
+ # Scenario ensemble
+ self._ensembles = EnsembleList(status=self._status)
+
self._results = {}
@classmethod
@@ -650,6 +655,8 @@ class River(Graph):
new._geotiff = GeoTIFFList._db_load(execute, data)
+ new._ensembles = EnsembleList._db_load(execute, data)
+
return new
def _db_load_results(self, execute, data=None):
@@ -688,6 +695,8 @@ class River(Graph):
objs.append(self._geotiff)
+ objs.append(self._ensembles)
+
for solv_type in self.results:
objs.append(self.results[solv_type])
@@ -766,7 +775,7 @@ class River(Graph):
self._InitialConditionsTemperature,
self._BoundaryConditionsTemperature,
self._WeatherParameters,
- self._geotiff,
+ self._geotiff, self._ensembles,
]
for solver in self._parameters:
diff --git a/src/Model/Scenario.py b/src/Model/Scenario.py
index f6d66887..c8172ac2 100644
--- a/src/Model/Scenario.py
+++ b/src/Model/Scenario.py
@@ -58,6 +58,7 @@ class Scenario(SQLSubModel):
"initial_conditions_temperature",
"initial_conditions_temperature_spec",
"weather_parameters", "weather_parameters_data",
+ "ensemble",
]
related_tables = tables_with_deleted_column + [
@@ -72,6 +73,7 @@ class Scenario(SQLSubModel):
name: str = "",
description: str = "",
x: int = 1000.0, y: int = 1000.0,
+ is_ensemble: bool = False,
revision: int = 0,
parent=None):
super(Scenario, self).__init__()
@@ -81,6 +83,7 @@ class Scenario(SQLSubModel):
self._x, self._y = x, y
self._name = name
self._description = description
+ self._is_ensemble = is_ensemble
self._revision = revision
self._parent = parent
@@ -103,6 +106,7 @@ class Scenario(SQLSubModel):
y REAL NOT NULL DEFAULT 1000,
name TEXT NOT NULL,
description TEXT NOT NULL,
+ is_ensemble BOOL DEFAULT FALSE,
revision INTEGER NOT NULL,
parent_id INTEGER REFERENCES scenario(id)
)
@@ -138,6 +142,13 @@ class Scenario(SQLSubModel):
cls._db_create(execute)
cls._db_add_default(execute)
+ if major == "0" and int(minor) == 2:
+ if int(release) < 8:
+ execute(
+ f"ALTER TABLE scenario " +
+ "ADD COLUMN is_ensemble BOOL DEFAULT FALSE"
+ )
+
return True
@classmethod
@@ -158,7 +169,8 @@ class Scenario(SQLSubModel):
scenarios = {}
table = execute(
- "SELECT id, x, y, name, description, revision, parent_id " +
+ "SELECT id, x, y, name, description, " +
+ " is_ensemble, revision, parent_id " +
"FROM scenario " +
"ORDER BY id ASC"
)
@@ -170,6 +182,7 @@ class Scenario(SQLSubModel):
x, y = next(it), next(it)
name = next(it)
desc = next(it)
+ is_ens = next(it)
revi = next(it)
parent = next(it)
@@ -179,7 +192,9 @@ class Scenario(SQLSubModel):
new = cls(
id=id, x=x, y=y,
name=name, description=desc,
- revision=revi, parent=parent
+ is_ensemble=is_ens,
+ revision=revi,
+ parent=parent
)
scenarios[id] = new
@@ -189,21 +204,18 @@ class Scenario(SQLSubModel):
if self.is_deleted():
return self.drop_all(execute)
- parent = 'NULL'
+ parent = None
if self.parent is not None:
parent = self.parent._id
execute(
"INSERT OR REPLACE INTO " +
- "scenario(id, x, y, name, description, revision, parent_id) " +
- "VALUES (" +
- f"{self._id}, " +
- f"{self.x}, {self.y}, " +
- f"'{self._db_format(self.name)}', " +
- f"'{self._db_format(self.description)}', " +
- f"{self._revision}, " +
- f"{parent}" +
- ")"
+ "scenario(id, x, y, name, description, " +
+ " is_ensemble, revision, parent_id) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ self._id, self.x, self.y,
+ self.name, self.description, self._is_ensemble,
+ self._revision, parent
)
return True
@@ -328,6 +340,9 @@ class Scenario(SQLSubModel):
def parent(self):
return self._parent
+ def is_ensemble(self):
+ return self._is_ensemble
+
def set_pos(self, x, y):
self._x = x
self._y = y
@@ -345,6 +360,8 @@ class Scenario(SQLSubModel):
return self.name
if key == "description":
return self.description
+ if key == "is_ensemble":
+ return self.is_ensemble()
if key == "parent":
return self.parent
diff --git a/src/Model/Scenarios.py b/src/Model/Scenarios.py
index e37a504e..5a688cd4 100644
--- a/src/Model/Scenarios.py
+++ b/src/Model/Scenarios.py
@@ -57,8 +57,11 @@ class Scenarios(PamhyrModelDict):
return None
- def new(self, parent):
- new = Scenario(parent=parent)
+ def new(self, parent, is_ensemble=False):
+ new = Scenario(
+ parent=parent,
+ is_ensemble=is_ensemble
+ )
self.set(new._id, new)
return new
diff --git a/src/Model/Study.py b/src/Model/Study.py
index 33554d43..0299f0f0 100644
--- a/src/Model/Study.py
+++ b/src/Model/Study.py
@@ -34,6 +34,7 @@ from Model.Except import NotImplementedMethodeError
from Model.River import River
from Model.TimeShift import shift_river_times, shift_database_times
from Model.Geometry.Reach import Reach
+from Model.Ensembles.DistributionList import DistributionList
from Model.HydraulicStructures.HydraulicStructures import (
HydraulicStructure
)
@@ -51,6 +52,7 @@ class Study(SQLModel):
_sub_classes = [
Scenario,
+ DistributionList,
River,
]
@@ -86,6 +88,9 @@ class Study(SQLModel):
self.scenarios = Scenarios(status=self.status)
self.scenarios[0] = s0
self.status.scenario = s0
+
+ self._ens_distributions = DistributionList(status=self.status)
+
self._river = River(status=self.status)
else:
self._init_db_file(filename, is_new=False)
@@ -406,12 +411,19 @@ class Study(SQLModel):
lambda a, s: a or (s.parent is scenario),
new.scenarios.lst,
False
- ):
+ ) or scenario.is_ensemble():
new.status.set_as_read_only()
data["scenario"] = scenario
data["loaded_pid"] = set()
+ # Get ensemble distribution
+ new._ens_distributions = DistributionList._db_load(
+ sql_exec, data=data
+ )
+
+ data["ens_distributions"] = new._ens_distributions
+
# Load river data
new._river = River._db_load(
sql_exec, data=data
@@ -483,8 +495,27 @@ class Study(SQLModel):
raise
self._pending_time_offset = 0.0
+ if self.status.scenario.is_ensemble():
+ lst = [
+ self.scenarios, self._ens_distributions,
+ self._river._ensembles,
+ ]
+ for solv in self._river._results:
+ lst.append(self._river._results[solv])
+
+ self._save_submodel(
+ lst, data=progress
+ )
+ progress()
+ self.commit()
+
+ return
+
self._save_submodel(
- [self.scenarios, self._river],
+ [
+ self.scenarios, self._ens_distributions,
+ self._river
+ ],
data=progress
)
@@ -602,7 +633,8 @@ class Study(SQLModel):
data = {
"status": self.status,
"loaded_pid": set(),
- "scenario": scenario
+ "scenario": scenario,
+ "ens_distributions": self._ens_distributions,
}
self.status.start_loading()
@@ -679,6 +711,7 @@ class Study(SQLModel):
self.status.scenario.y + 100)
new.name = self.status.scenario.name + " (copy)"
+ new.is_ensemble = self.status.scenario.is_ensemble
self.status.scenario = new
self.river._data_traversal(
@@ -690,6 +723,25 @@ class Study(SQLModel):
self.status.set_as_editable()
return new
+ def create_ensemble_scenario(self, pos=None, switch=True):
+ new = self.scenarios.new(
+ self.status.scenario,
+ is_ensemble=True
+ )
+
+ new.name = self.status.scenario.name + " (ensemble)"
+ if pos is not None:
+ new.set_pos(pos.x(), pos.y())
+ else:
+ new.set_pos(self.status.scenario.x + 100,
+ self.status.scenario.y)
+
+ if switch:
+ self.status.scenario = new
+
+ self.status.set_as_read_only()
+ return new
+
@property
def results(self):
return self._river.results
diff --git a/src/Model/Tools/PamhyrDB.py b/src/Model/Tools/PamhyrDB.py
index a9d95cfe..3aa4478d 100644
--- a/src/Model/Tools/PamhyrDB.py
+++ b/src/Model/Tools/PamhyrDB.py
@@ -185,6 +185,13 @@ class SQLSubModel(PamhyrID):
else:
self._owner_scenario = owner_scenario
+ def __str__(self):
+ t = type(self)
+ return (
+ f"'{t.__module__}.{t.__name__}" +
+ f"@{self._pamhyr_id}.{self._owner_scenario}'"
+ )
+
def must_be_saved(self):
"""Return True if this object MUST be save in the save file.
diff --git a/src/Solver/Mage.py b/src/Solver/Mage.py
index 075f4a97..13757d5b 100644
--- a/src/Solver/Mage.py
+++ b/src/Solver/Mage.py
@@ -1063,6 +1063,75 @@ class Mage8(Mage):
logger_exception(e)
return False
+ def export_ensemble_variation(self, study, repertory,
+ qlog=None, name="0",
+ samples=None):
+ self._study = study
+ name = study.name.replace(" ", "_")
+
+ var_samples = self._export_ensemble_RUG(
+ study, repertory,
+ qlog, name=name,
+ samples=samples,
+ )
+
+ return var_samples
+
+ def _export_ensemble_RUG(self, study, repertory,
+ qlog, name="0",
+ samples={}):
+ files = []
+ var_samples = []
+
+ for ens in samples:
+ if ens.target_data is None:
+ continue
+
+ x = next(samples[ens])
+ var_samples.append(x)
+
+ qlog.put(f"*** Variation " +
+ f"'{ens.target_data}' - {ens.data_type} x {x}")
+
+ if ens.data_type == "strickler_minor":
+ ens.target_data.minor = x
+ elif ens.data_type == "strickler_medium":
+ ens.target_data.medium = x
+
+ if qlog is not None:
+ qlog.put("Export variation of RUG file")
+
+ # Write header
+ with mage_file_open(os.path.join(repertory, f"{name}.RUG"), "w+") as f:
+ files.append(f"{name}.RUG")
+
+ edges = study.river.enable_edges()
+
+ id = 1
+ for edge in edges:
+ frictions = edge.frictions
+
+ for friction in frictions.frictions:
+ if friction.begin_strickler is None:
+ continue
+
+ if friction.is_deleted():
+ continue
+
+ num = f"{id:>3}"
+ brk = f"{friction.begin_rk:>10.3f}"
+ erk = f"{friction.end_rk:>10.3f}"
+
+ strickler = friction.begin_strickler
+ coef_1 = f"{strickler.minor:>10.3f}"
+ coef_2 = f"{strickler.medium:>10.3f}"
+
+ f.write(f"K{num} {brk}{erk}{coef_1}{coef_2}\n")
+
+ id += 1
+
+ return var_samples
+
###########
# RESULTS #
###########
@@ -1493,6 +1562,171 @@ class Mage8(Mage):
return results
+ @timer
+ def read_ensemble_bin(self, study, fname, tables, run_number, qlog=None):
+ logger.info(f"read_ensemble_bin: Start reading '{fname}' ...")
+ start = time.time()
+
+ with mage_file_open(fname, "r") as f:
+ def newline(): return np.fromfile(f, dtype=np.int32, count=1)
+ def endline(): return np.fromfile(f, dtype=np.int32, count=1)
+
+ def read_int(size): return np.fromfile(
+ f, dtype=np.int32, count=size)
+
+ def read_float(size): return np.fromfile(
+ f, dtype=np.float32, count=size)
+
+ def read_float64(size): return np.fromfile(
+ f, dtype=np.float64, count=size)
+
+ # Meta data (1st line)
+ newline()
+ data = read_int(3)
+
+ nb_reach = data[0]
+ nb_profile = data[1]
+ mage_version = data[2]
+
+ if mage_version <= 80:
+ msg = (
+ "Read BIN files: " +
+ f"Possible incompatible mage version '{mage_version}', " +
+ "please check your solver configuration..."
+ )
+ logger.warning(msg)
+
+ if qlog is not None:
+ qlog.put("[WARNING] " + msg)
+
+ endline()
+
+ # Reach information (2nd line)
+ newline()
+ _ = read_int(2*nb_reach)
+ endline()
+
+ # X (3rd line)
+ newline()
+ _ = read_float(nb_profile)
+ endline()
+
+ # Z and Y (4th line)
+ newline()
+ _ = read_float(3*nb_profile)
+ endline()
+
+ # Data
+ newline()
+
+ ts = set()
+
+ tmp_table = {}
+ end = False
+ while not end:
+ n = read_int(1)[0]
+ timestamp = read_float64(1)[0]
+ key = bytearray(
+ np.fromfile(
+ f, dtype=np.byte, count=1
+ )
+ ).decode()
+ data = read_float(n)
+
+ if key not in tmp_table:
+ tmp_table[key] = []
+
+ tmp_table[key].append(data)
+
+ ts.add(timestamp)
+
+ endline()
+ end = newline().size <= 0
+
+ table = {}
+ for k in tmp_table:
+ table[k] = np.array(tmp_table[k])
+
+ self.read_ensemble_bin_agregate_data(
+ table, tables, run_number
+ )
+
+ tables["timestamps"] = ts
+
+ logger.info(f"reading time: '{time.time() - start}'")
+
+ def read_ensemble_bin_agregate_data(self, tables, res, run_number):
+ z = tables["Z"]
+ q = tables["Q"]
+
+ if "z_mean" not in res:
+ res["z_mean"] = z
+ res["q_mean"] = q
+
+ res["z_tmp_M2"] = np.zeros(z.shape)
+ res["q_tmp_M2"] = np.zeros(q.shape)
+
+ res["z_std_derivation"] = np.zeros(z.shape)
+ res["q_std_derivation"] = np.zeros(q.shape)
+
+ res["z_min"] = z
+ res["z_max"] = z
+
+ res["q_min"] = q
+ res["q_max"] = q
+ else:
+ # Get mean results for Z and Q
+ n = run_number
+ for x, txt in [(z, "z"), (q, "q")]:
+ t_mean = f"{txt}_mean"
+ t_M2 = f"{txt}_tmp_M2"
+ t_std_deriv = f"{txt}_std_derivation"
+
+ delta = x - res[t_mean]
+
+ res[t_mean] += delta / n
+
+ delta_2 = x - res[t_mean]
+
+ res[t_M2] += delta * delta_2
+ res[t_std_deriv] = np.sqrt(res[t_M2] / n)
+
+ # Z and Q min max values
+ res["z_min"] = np.minimum(z, res["z_min"])
+ res["z_max"] = np.maximum(z, res["z_max"])
+ res["q_min"] = np.minimum(q, res["q_min"])
+ res["q_max"] = np.maximum(q, res["q_max"])
+
+ @timer
+ def results_ensemble(self, study, repertory,
+ tables, run_number,
+ qlog=None, name=None):
+ self._study = study
+ if name is None:
+ name = study.name.replace(" ", "_")
+
+ fname = os.path.join(repertory, f"{name}.BIN".replace(" ", "_"))
+ if not os.path.isfile(fname):
+ logger.info(f"Result file {name}.BIN does not exist")
+ return None
+ try:
+ self.read_ensemble_bin(study, fname, tables, run_number, qlog)
+ except Exception as e:
+ logger.error(f"Failed to read results")
+ logger_exception(e)
+ return None
+
+ return True
+
+ def get_ensemble_empty_results(self, study, repertory, name=None):
+ return Results(
+ study=study,
+ solver=self,
+ repertory=repertory,
+ name=name,
+ is_ensemble=True,
+ )
+
class MageFake7(Mage8):
_type = "mage_fake7"
diff --git a/src/View/Debug/Window.py b/src/View/Debug/Window.py
index 06931938..54232cad 100644
--- a/src/View/Debug/Window.py
+++ b/src/View/Debug/Window.py
@@ -16,6 +16,8 @@
# -*- coding: utf-8 -*-
+import os
+import psutil
import logging
import traceback
@@ -120,7 +122,10 @@ class TimerWindow(PamhyrWindow):
parent=parent
)
+ self._process = psutil.Process(os.getpid())
+
self.setup_table()
+ self.setup_label()
self.setup_alarm()
self.setup_connections()
@@ -136,6 +141,15 @@ class TimerWindow(PamhyrWindow):
data=None,
)
+ def setup_label(self):
+ label = self.find(QLabel, "label")
+ label.setText(
+ f" Memory usage:\t" +
+ "" +
+ f"{self._process.memory_info().rss / (1000 * 1000):.3f}" +
+ " MB"
+ )
+
def setup_alarm(self):
self._alarm = QTimer()
self._alarm.start(2000)
@@ -148,6 +162,7 @@ class TimerWindow(PamhyrWindow):
def update(self):
self._table.update_lst()
+ self.setup_label()
# def reset(self):
# self._table.reset_timers()
diff --git a/src/View/Ensembles/Edit/Translate.py b/src/View/Ensembles/Edit/Translate.py
new file mode 100644
index 00000000..2abe7f9c
--- /dev/null
+++ b/src/View/Ensembles/Edit/Translate.py
@@ -0,0 +1,64 @@
+# Translate.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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.Ensembles.Translate import EnsemblesTranslate
+
+_translate = QCoreApplication.translate
+
+
+class EnsembleEditTranslate(EnsemblesTranslate):
+ def __init__(self):
+ super(EnsembleEditTranslate, self).__init__()
+
+ self._dict["binomial_n"] = _translate("Ensemble", "n")
+ self._dict["binomial_p"] = _translate("Ensemble", "p")
+
+ self._dict["poisson_lam"] = _translate("Ensemble", "Lam")
+
+ self._dict["checker_failed_title"] = _translate(
+ "Ensemble",
+ "Invalid parameter"
+ )
+ self._dict["checker_failed_text"] = _translate(
+ "Ensemble",
+ "This parameter is not valid for selected distribution: "
+ )
+
+ uniform = _translate(
+ "Ensemble", "(High - Low) must be non-negative."
+ )
+ self._dict["uniform_low_tips"] = uniform
+ self._dict["uniform_high_tips"] = uniform
+
+ self._dict["normal_scale_tips"] = _translate(
+ "Ensemble", "Scale must be non-negative."
+ )
+
+ self._dict["binomial_n_tips"] = _translate(
+ "Ensemble", "N must be non-negative and must be an integers."
+ )
+
+ self._dict["binomial_p_tips"] = _translate(
+ "Ensemble", "P must be >= 0 and <=1."
+ )
+
+ self._dict["poisson_lam_tips"] = _translate(
+ "Ensemble", "Lam must be >= 0."
+ )
diff --git a/src/View/Ensembles/Edit/Window.py b/src/View/Ensembles/Edit/Window.py
new file mode 100644
index 00000000..74b4febc
--- /dev/null
+++ b/src/View/Ensembles/Edit/Window.py
@@ -0,0 +1,320 @@
+# Window.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+import logging
+
+from tools import trace, timer, logger_exception
+
+from View.Tools.PamhyrWindow import PamhyrWindow, PamhyrDialog
+from View.Tools.PamhyrWidget import PamhyrWidget
+from View.Tools.PamhyrTree import (
+ PamhyrModelItem, PamhyrModelListItem,
+)
+
+from PyQt5.QtGui import (
+ QKeySequence,
+)
+
+from PyQt5 import QtWidgets
+from PyQt5.QtCore import (
+ Qt, QVariant, QAbstractTableModel,
+ QCoreApplication, QModelIndex, pyqtSlot,
+ QRect, QSettings,
+)
+
+from PyQt5.QtWidgets import (
+ QDialogButtonBox, QPushButton, QLineEdit,
+ QFileDialog, QTableView, QAbstractItemView,
+ QUndoStack, QShortcut, QAction, QItemDelegate,
+ QComboBox, QVBoxLayout, QHeaderView, QTabWidget,
+ QGroupBox, QTreeWidget,
+)
+
+from View.Ensembles.Edit.Translate import EnsembleEditTranslate
+
+logger = logging.getLogger()
+
+
+class StricklersCoefModelItem(PamhyrModelItem):
+ def get_value(self):
+ return (self._data, self.parent()._data)
+
+
+class StricklersModelItem(PamhyrModelItem):
+ def __init__(self, data=None, trad=None, parent=None):
+ super(StricklersModelItem, self).__init__(
+ data=data, trad=trad, parent=parent,
+ )
+
+ def _setup_children(self):
+ for coef in ["strickler_minor", "strickler_medium"]:
+ self.addChild(
+ StricklersCoefModelItem(data=coef, parent=self)
+ )
+
+ def get_value(self):
+ return self._data
+
+
+class EnsembleEditDialog(PamhyrDialog):
+ _pamhyr_ui = "EnsembleEditDialog"
+ _pamhyr_name = "Parameters"
+
+ def __init__(self, study=None, ensemble=None,
+ parent=None):
+ trad = EnsembleEditTranslate()
+
+ super(EnsembleEditDialog, self).__init__(
+ title=trad[self._pamhyr_name],
+ trad=trad,
+ options=[],
+ parent=parent
+ )
+
+ self._study = study
+ self._ensemble = ensemble
+ self._target_data = None
+
+ self._tree = self.find(QTreeWidget, "treeWidget_target_data")
+ self._tg_group_box = self.find(QGroupBox, "groupBox_target_data")
+
+ self._init_tree()
+ self._init_default_values()
+ self._init_connection()
+
+ def _init_tree(self):
+ self._tree.setColumnCount(1)
+ self._tree.setHeaderLabels(["Name"])
+
+ stricklers = PamhyrModelListItem(
+ name="Stricklers",
+ lst=self._study.river.stricklers,
+ child_item_ctor=StricklersModelItem,
+ parent=self._tree
+ )
+
+ self._target_data = (
+ self._ensemble.data_type,
+ self._ensemble.target_data
+ )
+
+ self._tg_group_box.setTitle(
+ self._format_target_data(self._target_data)
+ )
+
+ item = stricklers.find_item_by_value(self._target_data)
+ if item:
+ self._tree.setCurrentItem(item)
+
+ def _init_default_values(self):
+ self.set_line_edit_text("lineEdit_name", self._ensemble.name)
+
+ self.combobox_add_items(
+ "comboBox_distribution",
+ [self._trad["not_defined"]] +
+ list(
+ map(
+ lambda d: d.name,
+ self._study._ens_distributions.lst
+ )
+ )
+ )
+
+ self._params_widget = None
+ if self._ensemble.distribution is not None:
+ self.set_combobox_text("comboBox_distribution",
+ self._ensemble.distribution.name)
+
+ self._set_widget(self._ensemble.distribution)
+
+ def _init_connection(self):
+ self.find(QComboBox, "comboBox_distribution")\
+ .currentTextChanged.connect(self.update_distribution)
+
+ self._tree.itemClicked.connect(self._tree_item_select)
+
+ def _tree_item_select(self, item):
+ if type(item) is StricklersCoefModelItem:
+ self._target_data = item.get_value()
+
+ self._tg_group_box.setTitle(
+ self._format_target_data(self._target_data)
+ )
+
+ def _format_target_data(self, data):
+ return f" Selection: {str(data[1])}, {data[0]}"
+
+ def update_distribution(self, txt):
+ # txt = self.get_combobox_text("comboBox_distribution")
+ distribution = next(
+ filter(
+ lambda d: d.name == txt,
+ self._study._ens_distributions.lst
+ ),
+ None
+ )
+
+ self._set_widget(distribution)
+
+ def _set_widget(self, distribution):
+ box = self.find(QGroupBox, "groupBox_params")
+ vbox = self.find(QVBoxLayout, "verticalLayout_params")
+
+ if self._params_widget is not None:
+ vbox.removeWidget(self._params_widget)
+ self._params_widget.setParent(None)
+
+ if distribution is None:
+ return
+
+ size = len(distribution._labels)
+ if size == 1:
+ self._params_widget = Ensemble1ParamsWidget(
+ ensemble=self._ensemble,
+ distribution=distribution,
+ trad=self._trad, parent=box,
+ )
+ vbox.addWidget(self._params_widget)
+ elif size == 2:
+ self._params_widget = Ensemble2ParamsWidget(
+ ensemble=self._ensemble,
+ distribution=distribution,
+ trad=self._trad, parent=box,
+ )
+ vbox.addWidget(self._params_widget)
+ else:
+ self._params_widget = None
+
+ @property
+ def values(self):
+ return self._values
+
+ def accept(self):
+ txt = self.get_combobox_text("comboBox_distribution")
+ distribution = next(
+ filter(
+ lambda d: d.name == txt,
+ self._study._ens_distributions.lst
+ ),
+ None
+ )
+ parameters = self._params_widget.values
+
+ if distribution is not None:
+ ok, tips = distribution.is_valid_parameters(parameters)
+ if not ok:
+ self.message_box(
+ text=self._trad["checker_failed_text"],
+ informative_text="\n".join(
+ map(lambda t: " - " + self._trad[t], tips)
+ ),
+ window_title=self._trad["checker_failed_title"],
+ )
+ return
+
+ self._values = {
+ "name": self.get_line_edit_text("lineEdit_name"),
+ "data_type": self._target_data[0],
+ "target_data": self._target_data[1],
+ "distribution": distribution,
+ "params": parameters,
+ }
+
+ super().accept()
+
+ def reject(self):
+ self.close()
+
+
+class Ensemble1ParamsWidget(PamhyrWidget):
+ _pamhyr_ui = "Ensemble1ParamsWidget"
+ _pamhyr_name = "Parameters"
+
+ def __init__(self, ensemble=None,
+ distribution=None,
+ trad=None, parent=None):
+ super(Ensemble1ParamsWidget, self).__init__(
+ parent=parent
+ )
+ self._trad = trad
+
+ self._ensemble = ensemble
+ self._distribution = distribution
+ self._labels = distribution.labels
+
+ self._init_default_labels()
+ self._init_default_values()
+
+ def _init_default_labels(self):
+ self.set_label_text("label_0", self._trad[self._labels[0]])
+
+ def _init_default_values(self):
+ self._values = self._ensemble.params
+ if len(self._values) != 1:
+ self._values = self._distribution.default_values.copy()
+
+ self.set_double_spin_box("doubleSpinBox_0", self._values[0])
+
+ @property
+ def values(self):
+ self._values = [
+ self.get_double_spin_box("doubleSpinBox_0"),
+ ]
+
+ return self._values
+
+
+class Ensemble2ParamsWidget(PamhyrWidget):
+ _pamhyr_ui = "Ensemble2ParamsWidget"
+ _pamhyr_name = "Parameters"
+
+ def __init__(self, ensemble=None,
+ distribution=None,
+ trad=None, parent=None):
+ super(Ensemble2ParamsWidget, self).__init__(
+ parent=parent
+ )
+ self._trad = trad
+
+ self._ensemble = ensemble
+ self._distribution = distribution
+ self._labels = distribution.labels
+
+ self._init_default_labels()
+ self._init_default_values()
+
+ def _init_default_labels(self):
+ self.set_label_text("label_0", self._trad[self._labels[0]])
+ self.set_label_text("label_1", self._trad[self._labels[1]])
+
+ def _init_default_values(self):
+ self._values = self._ensemble.params
+ if len(self._values) != 2:
+ self._values = self._distribution.default_values.copy()
+
+ self.set_double_spin_box("doubleSpinBox_0", self._values[0])
+ self.set_double_spin_box("doubleSpinBox_1", self._values[1])
+
+ @property
+ def values(self):
+ self._values = [
+ self.get_double_spin_box("doubleSpinBox_0"),
+ self.get_double_spin_box("doubleSpinBox_1")
+ ]
+
+ return self._values
diff --git a/src/View/Ensembles/Table.py b/src/View/Ensembles/Table.py
new file mode 100644
index 00000000..ac1d632f
--- /dev/null
+++ b/src/View/Ensembles/Table.py
@@ -0,0 +1,268 @@
+# Table.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+import logging
+import traceback
+
+from tools import trace, timer
+
+from PyQt5.QtCore import (
+ Qt, QVariant, QAbstractTableModel,
+ QCoreApplication, QModelIndex, pyqtSlot,
+ QRect,
+)
+
+from PyQt5.QtWidgets import (
+ QDialogButtonBox, QPushButton, QLineEdit,
+ QFileDialog, QTableView, QAbstractItemView,
+ QUndoStack, QShortcut, QAction, QItemDelegate,
+ QComboBox,
+)
+
+from View.Ensembles.UndoCommand import (
+ SetNameCommand, SetTypeCommand, SetDataCommand,
+ SetDistributionCommand,
+ AddCommand, DelCommand,
+)
+
+from View.Tools.PamhyrTable import PamhyrTableModel
+
+from View.Ensembles.Translate import *
+
+logger = logging.getLogger()
+
+_translate = QCoreApplication.translate
+
+
+class ComboBoxDelegate(QItemDelegate):
+ def __init__(self, data=None, study=None,
+ mode="stricklers", trad=None, parent=None):
+ super(ComboBoxDelegate, self).__init__(parent)
+
+ self._data = data
+ self._study = study
+ self._trad = trad
+ self._mode = mode
+
+ def createEditor(self, parent, option, index):
+ self.editor = QComboBox(parent)
+
+ if self._mode == "data_type":
+ self.editor.addItems(
+ [
+ self._trad["not_defined"],
+ self._trad["strickler_minor"],
+ self._trad["strickler_medium"],
+ ]
+ )
+ elif self._mode == "stricklers":
+ used = list(
+ map(
+ lambda e: (e.data_type, e._target_data),
+ self._data.lst
+ )
+ )
+ current = self._data.get(index.row())
+
+ self.editor.addItems(
+ [self._trad["not_defined"]] +
+ list(
+ map(
+ lambda s: str(s),
+ filter(
+ # Exclude target data already used for
+ # this data type
+ lambda s: (
+ current.target_data == s or
+ (current.data_type, s) not in used
+ ),
+ self._study.river.stricklers.stricklers
+ )
+ )
+ )
+ )
+ elif self._mode == "distribution":
+ self.editor.addItems(
+ [self._trad["not_defined"]] +
+ list(
+ map(
+ lambda f: f.name,
+ self._study._ens_distributions.lst
+ )
+ )
+ )
+
+ self.editor.setCurrentText(index.data(Qt.DisplayRole))
+ return self.editor
+
+ def setEditorData(self, editor, index):
+ value = index.data(Qt.DisplayRole)
+ self.editor.currentTextChanged.connect(self.currentItemChanged)
+
+ def setModelData(self, editor, model, index):
+ text = str(editor.currentText())
+
+ if self._mode == "data_type":
+ value = next(
+ filter(
+ lambda x: self._trad[x] == text,
+ ["strickler_minor", "strickler_medium"],
+ ),
+ ""
+ )
+ elif self._mode == "stricklers":
+ value = next(
+ filter(
+ lambda s: str(s) == text,
+ self._study.river.stricklers.stricklers
+ ),
+ None
+ )
+ elif self._mode == "distribution":
+ value = next(
+ filter(
+ lambda f: f.name == text,
+ self._study._ens_distributions.lst
+ ),
+ None
+ )
+ else:
+ value = text
+
+ model.setData(index, value)
+
+ editor.close()
+ editor.deleteLater()
+
+ def updateEditorGeometry(self, editor, option, index):
+ r = QRect(option.rect)
+ if self.editor.windowFlags() & Qt.Popup:
+ if editor.parent() is not None:
+ r.setTopLeft(self.editor.parent().mapToGlobal(r.topLeft()))
+ editor.setGeometry(r)
+
+ @pyqtSlot()
+ def currentItemChanged(self):
+ self.commitData.emit(self.sender())
+
+
+class EnsembleTableModel(PamhyrTableModel):
+ def _setup_lst(self):
+ self._lst = self._data
+ self._study = self._opt_data
+
+ def data(self, index, role):
+ if role != Qt.ItemDataRole.DisplayRole:
+ return QVariant()
+
+ row = index.row()
+ column = index.column()
+
+ if self._headers[column] == "name":
+ return self._lst.get(row).name
+ elif self._headers[column] == "type":
+ return self._trad[self._lst.get(row).data_type]
+ elif self._headers[column] == "target_data":
+ value = self._lst.get(row).target_data
+ if value is None:
+ return self._trad["not_defined"]
+ return str(value)
+ elif self._headers[column] == "distribution":
+ value = self._lst.get(row).distribution
+ if value is None:
+ return self._trad["not_defined"]
+ return value.name
+ elif self._headers[column] == "parameters":
+ value = self._lst.get(row).params
+ return str(value)
+
+ return QVariant()
+
+ def setData(self, index, value, role=Qt.EditRole):
+ if not index.isValid() or role != Qt.EditRole:
+ return False
+
+ if self.is_same_data(index, value):
+ return False
+
+ row = index.row()
+ column = index.column()
+
+ try:
+ if self._headers[column] == "name":
+ self._undo.push(
+ SetNameCommand(
+ self._lst, row, value
+ )
+ )
+ elif self._headers[column] == "type":
+ self._undo.push(
+ SetTypeCommand(
+ self._lst, row, value
+ )
+ )
+ elif self._headers[column] == "target_data":
+ self._undo.push(
+ SetDataCommand(
+ self._lst, row, value
+ )
+ )
+ elif self._headers[column] == "distribution":
+ self._undo.push(
+ SetDistributionCommand(
+ self._lst, row, value
+ )
+ )
+ except Exception as e:
+ logger.info(e)
+ logger.debug(traceback.format_exc())
+
+ self.dataChanged.emit(index, index)
+ return True
+
+ def add(self, row, parent=QModelIndex()):
+ self.beginInsertRows(parent, row, row - 1)
+
+ self._undo.push(
+ AddCommand(
+ self._lst, row,
+ )
+ )
+
+ self.endInsertRows()
+ self.layoutChanged.emit()
+
+ def delete(self, rows, parent=QModelIndex()):
+ self.beginRemoveRows(parent, rows[0], rows[-1])
+
+ self._undo.push(
+ DelCommand(
+ self._lst, rows
+ )
+ )
+
+ self.endRemoveRows()
+ self.layoutChanged.emit()
+
+ def undo(self):
+ self._undo.undo()
+ self.layoutChanged.emit()
+
+ def redo(self):
+ self._undo.redo()
+ self.layoutChanged.emit()
diff --git a/src/View/Ensembles/Translate.py b/src/View/Ensembles/Translate.py
new file mode 100644
index 00000000..801983b2
--- /dev/null
+++ b/src/View/Ensembles/Translate.py
@@ -0,0 +1,50 @@
+# Translate.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 EnsemblesTranslate(MainTranslate):
+ def __init__(self):
+ super(EnsemblesTranslate, self).__init__()
+
+ self._dict["Ensembles"] = _translate(
+ "Ensembles", "Ensembles"
+ )
+
+ self._dict["ensemble"] = _translate(
+ "Ensembles", "Ensembles scenario"
+ )
+ self._dict["strickler_minor"] = _translate(
+ "Ensembles", "Stricklers minor"
+ )
+ self._dict["strickler_medium"] = _translate(
+ "Ensembles", "Stricklers medium"
+ )
+
+ self._sub_dict["table_headers"] = {
+ "name": self._dict["name"],
+ "type": _translate("Ensembles", "Type"),
+ "target_data": _translate("Ensembles", "Target data"),
+ "distribution": _translate("Ensembles", "Distribution"),
+ "parameters": _translate("Ensembles", "Parameters"),
+ }
diff --git a/src/View/Ensembles/UndoCommand.py b/src/View/Ensembles/UndoCommand.py
new file mode 100644
index 00000000..6327be9a
--- /dev/null
+++ b/src/View/Ensembles/UndoCommand.py
@@ -0,0 +1,178 @@
+# UndoCommand.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+from copy import deepcopy
+from tools import trace, timer
+
+from PyQt5.QtWidgets import (
+ QMessageBox, QUndoCommand, QUndoStack,
+)
+
+from Model.Friction.Friction import Friction
+from Model.Friction.FrictionList import FrictionList
+
+
+class SetNameCommand(QUndoCommand):
+ def __init__(self, ensembles, index, new_value):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._old = self._ensembles.get(self._index).name
+ self._new = str(new_value)
+
+ def undo(self):
+ self._ensembles.get(self._index).name = self._old
+
+ def redo(self):
+ self._ensembles.get(self._index).name = self._new
+
+
+class SetTypeCommand(QUndoCommand):
+ def __init__(self, ensembles, index, new_value):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._old = self._ensembles.get(self._index)\
+ .data_type
+ self._new = new_value
+
+ def undo(self):
+ self._ensembles.get(self._index).data_type = self._old
+
+ def redo(self):
+ self._ensembles.get(self._index).data_type = self._new
+
+
+class SetDataCommand(QUndoCommand):
+ def __init__(self, ensembles, index, data):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._old = self._ensembles.get(self._index)\
+ .target_data
+ self._new = data
+
+ def undo(self):
+ self._ensembles.get(self._index).target_data = self._old
+
+ def redo(self):
+ self._ensembles.get(self._index).target_data = self._new
+
+
+class SetDistributionCommand(QUndoCommand):
+ def __init__(self, ensembles, index, distribution):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._old = self._ensembles.get(self._index).distribution
+ self._old_params = self._ensembles.get(self._index).params
+ self._new = distribution
+
+ def undo(self):
+ self._ensembles.get(self._index).distribution = self._old
+ self._ensembles.get(self._index).params = self._old_params
+
+ def redo(self):
+ self._ensembles.get(self._index).distribution = self._new
+ if self._new is not None:
+ self._ensembles.get(self._index).params = self._new.default_values
+
+
+class AddCommand(QUndoCommand):
+ def __init__(self, ensembles, index):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._new = None
+
+ def undo(self):
+ self._ensembles.delete_i([self._index])
+
+ def redo(self):
+ if self._new is None:
+ self._new = self._ensembles.new(self._index)
+ else:
+ self._ensembles.insert(self._index, self._new)
+
+
+class ImportCommand(QUndoCommand):
+ def __init__(self, ensembles, data, index):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._index = index
+ self._data = data
+ self._news = None
+
+ def undo(self):
+ for new in self._news:
+ new.set_as_deleted()
+
+ def redo(self):
+ if self._news is None:
+ self._news = list(
+ map(
+ lambda d: self._ensembles.new_with_data(
+ self._index, d
+ ),
+ self._data,
+ )
+ )
+ else:
+ for new in self._news:
+ new.set_as_not_deleted()
+
+
+class DelCommand(QUndoCommand):
+ def __init__(self, ensembles, rows):
+ QUndoCommand.__init__(self)
+
+ self._ensembles = ensembles
+ self._rows = rows
+
+ self._friction = []
+ for row in rows:
+ self._friction.append((row, self._ensembles.get(row)))
+ self._friction.sort()
+
+ def undo(self):
+ for row, el in self._friction:
+ self._ensembles.insert(row, el)
+
+ def redo(self):
+ self._ensembles.delete_i(self._rows)
+
+
+class SetAllCommand(QUndoCommand):
+ def __init__(self, ensemble, data):
+ QUndoCommand.__init__(self)
+
+ self._ensemble = ensemble
+ self._old = self._ensemble.get_data_dict()
+ self._new = data
+
+ def undo(self):
+ self._ensemble.set_data_dict(self._old)
+
+ def redo(self):
+ self._ensemble.set_data_dict(self._new)
diff --git a/src/View/Ensembles/Window.py b/src/View/Ensembles/Window.py
new file mode 100644
index 00000000..e2b0e158
--- /dev/null
+++ b/src/View/Ensembles/Window.py
@@ -0,0 +1,280 @@
+# Window.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 json
+
+from tools import trace, timer, logger_exception
+
+from View.Tools.PamhyrWindow import PamhyrWindow, PamhyrDialog
+
+from PyQt5.QtGui import (
+ QKeySequence,
+)
+
+from PyQt5 import QtWidgets
+from PyQt5.QtCore import (
+ Qt, QVariant, QAbstractTableModel,
+ QCoreApplication, QModelIndex, pyqtSlot,
+ QRect, QSettings,
+)
+
+from PyQt5.QtWidgets import (
+ QDialogButtonBox, QPushButton, QLineEdit,
+ QFileDialog, QTableView, QAbstractItemView,
+ QUndoStack, QShortcut, QAction, QItemDelegate,
+ QComboBox, QVBoxLayout, QHeaderView, QTabWidget,
+)
+
+from Modules import Modules
+
+from View.Ensembles.Edit.Window import EnsembleEditDialog
+from View.Ensembles.Table import (
+ EnsembleTableModel, ComboBoxDelegate
+)
+from View.Ensembles.Translate import EnsemblesTranslate
+from View.Ensembles.UndoCommand import SetAllCommand, ImportCommand
+
+logger = logging.getLogger()
+
+
+class EnsemblesWindow(PamhyrWindow):
+ _pamhyr_ui = "Ensembles"
+ _pamhyr_name = "Ensembles"
+
+ def __init__(self, ensembles=None,
+ study=None, config=None,
+ parent=None):
+ trad = EnsemblesTranslate()
+
+ self._ensembles = ensembles
+
+ name = (
+ trad[self._pamhyr_name] + " - " +
+ study.name
+ )
+
+ super(EnsemblesWindow, self).__init__(
+ title=name,
+ study=study,
+ config=config,
+ trad=trad,
+ parent=parent
+ )
+
+ self.setup_table()
+ self.setup_connections()
+
+ def setup_table_delegate(self):
+ self._delegate_stricklers = ComboBoxDelegate(
+ data=self._ensembles,
+ study=self._study,
+ mode="stricklers",
+ trad=self._trad,
+ parent=self
+ )
+
+ self._delegate_data_type = ComboBoxDelegate(
+ data=self._ensembles,
+ study=self._study,
+ mode="data_type",
+ trad=self._trad,
+ parent=self
+ )
+
+ self._delegate_distribution = ComboBoxDelegate(
+ data=self._ensembles,
+ study=self._study,
+ mode="distribution",
+ trad=self._trad,
+ parent=self
+ )
+
+ return {
+ "type": self._delegate_data_type,
+ "target_data": self._delegate_stricklers,
+ "distribution": self._delegate_distribution,
+ }
+
+ def setup_table(self):
+ self._table = {}
+
+ delegates = self.setup_table_delegate()
+
+ editable_headers = [
+ "name",
+ ]
+
+ table = self.find(QTableView, f"tableView")
+ self._table = EnsembleTableModel(
+ table_view=table,
+ table_headers=self._trad.get_dict("table_headers"),
+ editable_headers=editable_headers,
+ delegates=delegates,
+ data=self._ensembles,
+ trad=self._trad,
+ undo=self._undo_stack,
+ opt_data=self._study
+ )
+
+ table.setModel(self._table)
+ table.setSelectionBehavior(QAbstractItemView.SelectRows)
+ table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
+ table.setAlternatingRowColors(True)
+
+ def setup_connections(self):
+ self.find(QAction, "action_add").triggered.connect(self.add)
+ self.find(QAction, "action_del").triggered.connect(self.delete)
+ self.find(QAction, "action_edit").triggered.connect(self.edit)
+ self.find(QAction, "action_export").triggered.connect(
+ self.export_to_json
+ )
+ self.find(QAction, "action_import").triggered.connect(
+ self.import_from_json
+ )
+
+ table = self.find(QTableView, f"tableView")
+ table.selectionModel()\
+ .selectionChanged\
+ .connect(self.update)
+
+ self._table.dataChanged\
+ .connect(self.update)
+
+ def index_selected_rows(self):
+ table = self.find(QTableView, f"tableView")
+ return list(
+ # Delete duplicate
+ set(
+ map(
+ lambda i: i.row(),
+ table.selectedIndexes()
+ )
+ )
+ )
+
+ def add(self):
+ rows = self.index_selected_rows()
+ if len(self._ensembles) == 0 or len(rows) == 0:
+ self._table.add(0)
+ else:
+ self._table.add(rows[0])
+
+ def delete(self):
+ rows = self.index_selected_rows()
+ if len(rows) == 0:
+ return
+
+ self._table.delete(rows)
+
+ def _undo(self):
+ self._table.undo()
+
+ def _redo(self):
+ self._table.redo()
+
+ def edit(self):
+ rows = self.index_selected_rows()
+
+ for row in rows:
+ try:
+ ens = self._ensembles.get(row)
+ dlg = EnsembleEditDialog(
+ study=self._study,
+ ensemble=ens,
+ parent=self
+ )
+
+ if dlg.exec():
+ self._undo_stack.push(
+ SetAllCommand(
+ ens, dlg.values
+ )
+ )
+ except Exception as e:
+ logger_exception(e)
+ return
+
+ def export_to_json(self):
+ ens = []
+ for e in self._ensembles.lst:
+ ens.append(e.get_data_dict(use_ids=True))
+
+ data = {"ensembles": ens}
+
+ def aux(filename):
+ with open(filename[0], 'w') as f:
+ f.write(json.dumps(data))
+
+ self.file_dialog(
+ select_file=None,
+ callback=aux,
+ default_suffix='.json',
+ file_filter=[self._trad["file_json"]]
+ )
+
+ def import_from_json(self):
+ def aux(filename):
+ with open(filename[0], 'r') as f:
+ return json.load(f)
+
+ data = self.file_dialog(
+ select_file=None,
+ callback=aux,
+ default_suffix='.json',
+ file_filter=[self._trad["file_json"]]
+ )
+
+ dtarget = dict(
+ map(
+ lambda s: (s.pamhyr_id, s),
+ self._study.river.stricklers.stricklers
+ )
+ )
+
+ ddistr = dict(
+ map(
+ lambda d: (d.name, d),
+ self._study._ens_distributions.lst
+ )
+ )
+
+ def update(d, values):
+ d.update(values)
+ return d
+
+ updated = list(
+ map(
+ lambda d: update(
+ d, {"target_data": dtarget[d["target_data"]]}
+ ),
+ map(
+ lambda d: update(
+ d, {"distribution": ddistr[d["distribution"]]}
+ ),
+ data["ensembles"]
+ )
+ )
+ )
+
+ self._undo_stack.push(
+ ImportCommand(
+ self._ensembles, updated, 0
+ )
+ )
+ self._table.update()
diff --git a/src/View/MainWindow.py b/src/View/MainWindow.py
index ad4e7e73..6b84d6eb 100644
--- a/src/View/MainWindow.py
+++ b/src/View/MainWindow.py
@@ -67,6 +67,7 @@ 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.Ensembles.Window import EnsemblesWindow
from View.Network.Window import NetworkWindow
from View.Geometry.Window import GeometryWindow
from View.BoundaryCondition.Window import BoundaryConditionWindow
@@ -83,11 +84,14 @@ from View.GeoTIFF.Window import GeoTIFFListWindow
from View.REPLines.Window import REPLineListWindow
from View.SolverParameters.Window import SolverParametersWindow
from View.RunSolver.Window import (
+ EnsembleRunNumberDialog,
SelectSolverWindow,
SolverLogWindow,
+ SolverLogEnsWindow,
)
-from View.Results.Window import ResultsWindow
+
+from View.Results.Window import ResultsWindow, ResultsEnsembleWindow
from View.Results.CompareDialog import (
CompareScenariosWindow
)
@@ -1339,6 +1343,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
"""
if self._study is None:
return
+
if self._study.filename is None or self._study.filename == "":
self.message_box(
window_title=self._trad["Warning"],
@@ -1372,6 +1377,29 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
self.scenarios = ScenariosWindow(study=self._study, parent=self)
self.scenarios.show()
+ def open_ensemble_scenario(self):
+ """Open ensemble scenario window
+
+ Returns:
+ Nothing
+ """
+ if self._study is None:
+ return
+
+ if self.sub_window_exists(
+ EnsemblesWindow,
+ data=[self._study, self.conf]
+ ):
+ return
+
+ ensemble = EnsemblesWindow(
+ ensembles=self._study.river._ensembles,
+ study=self._study,
+ config=self.conf,
+ parent=self
+ )
+ ensemble.show()
+
def open_geometry(self):
"""Open geometry window
@@ -1728,6 +1756,9 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
sol.show()
def solver_log(self, solver):
+ if self._study.status.scenario.is_ensemble():
+ return self._solver_log_ensemble(solver)
+
sol = SolverLogWindow(
study=self._study,
config=self.conf,
@@ -1736,6 +1767,28 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
)
sol.show()
+ def _solver_log_ensemble(self, solver):
+ run_number = 1
+
+ dlg = EnsembleRunNumberDialog(
+ trad=self._trad,
+ parent=self
+ )
+ if dlg.exec():
+ function = dlg.function
+ number_of_run = dlg.number
+ seed = dlg.seed
+
+ sol = SolverLogEnsWindow(
+ number_of_run=number_of_run,
+ seed=seed, function=function,
+ study=self._study,
+ config=self.conf,
+ solver=solver,
+ parent=self
+ )
+ sol.show()
+
def msg_box_results_continue(self):
dlg = QMessageBox(self)
@@ -1812,6 +1865,12 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
if not self.msg_box_results_continue():
return
+ if self._study.status.scenario.is_ensemble():
+ self.open_solver_ens_results_window(solver, results)
+ else:
+ self.open_solver_results_window(solver, results)
+
+ def open_solver_results_window(self, solver, results):
# Windows already opened
if self.sub_window_exists(
ResultsWindow,
@@ -1831,6 +1890,26 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
)
res.show()
+ def open_solver_ens_results_window(self, solver, results):
+ # Windows already opened
+ if self.sub_window_exists(
+ ResultsEnsembleWindow,
+ data=[
+ self._study,
+ None, # No config,
+ results._solver,
+ results._repertory,
+ results._name]
+ ):
+ return
+
+ res = ResultsEnsembleWindow(
+ study=self._study,
+ results=[results],
+ parent=self
+ )
+ res.show()
+
def msg_open_results_no_results(self):
self.message_box(
window_title=self._trad["Warning"],
diff --git a/src/View/Results/CustomExport/CustomExport.py b/src/View/Results/CustomExport/CustomExport.py
index 9f81461c..0298bc74 100644
--- a/src/View/Results/CustomExport/CustomExport.py
+++ b/src/View/Results/CustomExport/CustomExport.py
@@ -26,10 +26,13 @@ from View.Results.translate import ResultsTranslate
class CustomExportDialog(PamhyrDialog):
- _pamhyr_ui = "CustomExportAdisDialog"
- _pamhyr_name = "Custom Plot Selection"
+ _pamhyr_ui = "CustomExportDialog"
+ _pamhyr_name = "Custom Export"
- def __init__(self, parent=None):
+ def __init__(self,
+ tag_x="values_x",
+ tag_y="values_y",
+ parent=None):
trad = ResultsTranslate()
super(CustomExportDialog, self).__init__(
title=trad[self._pamhyr_name],
@@ -38,8 +41,8 @@ class CustomExportDialog(PamhyrDialog):
parent=parent
)
- self._available_values_x = self._trad.get_dict("values_x")
- self._available_values_y = self._trad.get_dict("values_y")
+ self._available_values_x = self._trad.get_dict(tag_x)
+ self._available_values_y = self._trad.get_dict(tag_y)
self.setup_radio_buttons_x()
self.setup_radio_buttons_res()
@@ -66,8 +69,8 @@ class CustomExportDialog(PamhyrDialog):
def setup_radio_buttons_res(self):
self._radio2 = []
- layout = self.find(QVBoxLayout, "verticalLayout_pol")
- label = self.find(QLabel, "label_3")
+ layout = self.find(QVBoxLayout, "verticalLayout_solv")
+ label = self.find(QLabel, "label_solv")
label.setText(self._trad["solver"])
for solver in self.parent._solvers:
@@ -89,7 +92,7 @@ class CustomExportDialog(PamhyrDialog):
layout.addStretch()
def setup_label(self):
- self._label = self.find(QLabel, "label_4")
+ self._label = self.find(QLabel, "label_data")
self._label.setFrameStyle(QFrame.StyledPanel)
self._label.setStyleSheet('background-color: white')
self.set_label()
@@ -163,6 +166,15 @@ class CustomExportDialog(PamhyrDialog):
res = i
break
- self.value = x, y, self._envelop.isChecked(), res
+ self.value = (x, y, self._envelop.isChecked(), res)
super().accept()
+
+
+class CustomExportEnsembleDialog(CustomExportDialog):
+ def __init__(self, parent=None):
+ super(CustomExportEnsembleDialog, self).__init__(
+ tag_x="values_x_ens",
+ tag_y="values_y_ens",
+ parent=parent
+ )
diff --git a/src/View/Results/PlotHEnsemble.py b/src/View/Results/PlotHEnsemble.py
new file mode 100644
index 00000000..3a23b678
--- /dev/null
+++ b/src/View/Results/PlotHEnsemble.py
@@ -0,0 +1,261 @@
+# PlotHEnsemble.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 datetime import datetime
+
+from tools import timer, trace, logger_exception
+from View.Tools.PamhyrPlot import PamhyrPlot
+
+from PyQt5.QtCore import (
+ QCoreApplication
+)
+
+_translate = QCoreApplication.translate
+
+logger = logging.getLogger()
+
+
+class PlotHEnsemble(PamhyrPlot):
+ def __init__(self, canvas=None, trad=None, toolbar=None,
+ results=None, reach_id=0, profile_id=[0], res_id=[0],
+ parent=None):
+ super(PlotHEnsemble, self).__init__(
+ canvas=canvas,
+ trad=trad,
+ data=results,
+ toolbar=toolbar,
+ parent=parent
+ )
+
+ self._mode = "time"
+
+ self._parent = parent
+ self._current_reach_id = reach_id
+ self._current_profile_id = profile_id
+ self._current_res_id = res_id
+ self._timestamps = parent._timestamps
+ self._current_timestamp = max(self._timestamps)
+
+ self.label_x = self._trad["unit_time_s"]
+ self.label_y = self._trad["unit_discharge"]
+
+ self.label_discharge = _translate("Results", "Cross-section discharge")
+ self.label_discharge_max = _translate("Results", "Max discharge")
+ self.label_timestamp = _translate("Results", "Current timestamp")
+
+ self._isometric_axis = False
+
+ # self._auto_relim_update = False
+ # self._autoscale_update = False
+
+ self._line_max = []
+ self._line = []
+ self._add_data_lines = []
+
+ @property
+ def results(self):
+ return self.data
+
+ @results.setter
+ def results(self, results):
+ self.data = results
+ self._current_timestamp = max(self._timestamps)
+
+ @timer
+ def draw(self, highlight=None):
+ self.canvas.axes.cla()
+ self.canvas.axes.grid(color='grey', linestyle='--', linewidth=0.5)
+
+ if self.results is None:
+ return
+
+ if len(self._current_res_id) < 1:
+ return
+
+ for res_id in self._current_res_id:
+ if self.results[res_id] is None:
+ continue
+
+ results = self.results[res_id]
+ reach = results.river.reach(self._current_reach_id)
+
+ if reach.geometry.number_profiles == 0:
+ self._init = False
+ return
+
+ self.draw_data(res_id)
+ self.draw_data_std_derivation(res_id)
+
+ self.canvas.axes.set_xlabel(
+ self._trad["unit_time_s"],
+ color='black', fontsize=10
+ )
+
+ self.canvas.axes.set_ylabel(
+ self._trad["unit_discharge"],
+ color='black', fontsize=10
+ )
+
+ self.set_ticks_time_formater()
+
+ self.draw_additional_data(0)
+
+ self.enable_legend()
+
+ self.canvas.draw_idle()
+ self.update_idle()
+
+ self.draw_current()
+ self._init = True
+
+ @timer
+ def draw_data(self, res_id):
+ results = self.results[res_id]
+ reach = results.river.reach(self._current_reach_id)
+
+ colors = [
+ ("q_min", self.color_plot_ensemble_min),
+ ("q_mean", self.color_plot_ensemble_mean),
+ ("q_max", self.color_plot_ensemble_max),
+ ]
+
+ for t, c in colors:
+ q = results.get("table")[t]
+ profile = reach.profile(self._current_profile_id[0])
+
+ x = self._timestamps
+ y = q[:, profile.global_index]
+ # y = profile.get_key("Q")
+
+ if res_id == 2:
+ label = f"Δ {self.label_discharge} {t} {profile.name}"
+ else:
+ label = f"{self.label_discharge} {t} {profile.name}"
+
+ line, = self.canvas.axes.plot(
+ x, y,
+ label=label,
+ color=c,
+ # linestyle=self.linestyle[i % len(self.linestyle)],
+ **self.plot_default_kargs
+ )
+
+ self._line.append(line)
+
+ @timer
+ def draw_data_std_derivation(self, res_id):
+ results = self.results[res_id]
+ reach = results.river.reach(self._current_reach_id)
+
+ q = results.get("table")["q_mean"]
+ std_deriv = results.get("table")["q_std_derivation"]
+ profile = reach.profile(self._current_profile_id[0])
+
+ x = self._timestamps
+ y = q[:, profile.global_index]
+ std = std_deriv[:, profile.global_index]
+
+ y_b = y - std
+ y_t = y + std
+
+ plot = self.canvas.axes.fill_between(
+ x, y_b, y_t,
+ label=f"Standard derivation {profile.name}",
+ color=self.color_plot_ensemble_std_deriv,
+ alpha=0.3,
+ )
+
+ def draw_additional_data(self, res_id):
+ results = self.results[res_id]
+
+ self._add_data_lines = []
+
+ for data in results.get("additional_data"):
+ data = data._data
+ x, y = data['x'], data['y']
+ legend = data['legend']
+ unit = data['unit']
+
+ if data['type_x'] == 'time' and data['type_y'] == 'discharge':
+ line, = self.canvas.axes.plot(
+ x, y, marker="+",
+ label=legend + ' ' + unit
+ )
+
+ self._add_data_lines.append(line)
+
+ self.enable_legend()
+ self.canvas.draw_idle()
+
+ def draw_current(self):
+ self._current, = self.canvas.axes.plot(
+ [self._current_timestamp, self._current_timestamp],
+ self.canvas.axes.get_ylim(),
+ # label=self.label_timestamp,
+ color="grey",
+ linestyle="dashed",
+ lw=1.,
+ )
+
+ def set_reach(self, reach_id):
+ self._current_reach_id = reach_id
+ self._current_profile_id = [0]
+ self.draw()
+
+ def set_profile(self, profile_id):
+ self._current_profile_id = profile_id
+ # self.update()
+ self.draw()
+
+ def set_result(self, res_id):
+ self._current_res_id = res_id
+ self.draw()
+
+ def set_timestamp(self, timestamp):
+ self._current_timestamp = timestamp
+ self.update_current()
+ self.update_idle()
+
+ def update(self):
+ if not self._init:
+ self.draw()
+
+ self.update_current()
+ self.update_idle()
+
+ def update_additional_data(self):
+ self.draw_additional_data(0)
+ self.update_idle()
+
+ def update_all(self):
+ self._current_reach_id = self._parent._get_current_reach()
+ self._current_profile_id = self._parent._get_current_profiles_list()
+ self._current_res_id = self._parent._get_current_results()
+ self._current_timestamp = self._parent._get_current_timestamp()
+ self._init = False
+ self.update()
+
+ def update_current(self):
+ y = self._current.get_ydata()
+ self._current.set_data(
+ [self._current_timestamp, self._current_timestamp],
+ y
+ )
diff --git a/src/View/Results/PlotRKC.py b/src/View/Results/PlotRKC.py
index 0c8e19d4..0f48e464 100644
--- a/src/View/Results/PlotRKC.py
+++ b/src/View/Results/PlotRKC.py
@@ -124,12 +124,8 @@ class PlotRKC(PamhyrPlot):
table = results.get("table")["zfd"]
ts = results.get_timestamp_id(self._current_timestamp)
- zfd = list(
- map(
- lambda p: table[ts, p.global_index],
- reach.profiles
- )
- )
+ gi_0, gi_1 = reach.global_index
+ zfd = table[ts, gi_0:gi_1 + 1]
self.line_bottom, = self.canvas.axes.plot(
rk, zfd,
@@ -189,14 +185,8 @@ class PlotRKC(PamhyrPlot):
table = result.get("table")["Z"]
ts = result.get_timestamp_id(self._current_timestamp)
- water_z = list(
- map(
- lambda p: table[
- ts, p.global_index
- ],
- reach.profiles
- )
- )
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
self.water = self.canvas.axes.plot(
rk, water_z,
@@ -219,16 +209,8 @@ class PlotRKC(PamhyrPlot):
table = result.get("table")["Z"]
ts = result.get_timestamp_id(self._current_timestamp)
- water_z = list(
- map(
- lambda p: np.max(
- table[
- :, p.global_index
- ]
- ),
- reach.profiles
- )
- )
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
self.canvas.axes.plot(
rk, water_z, lw=1.,
@@ -361,14 +343,8 @@ class PlotRKC(PamhyrPlot):
table = result.get("table")["Z"]
ts = result.get_timestamp_id(self._current_timestamp)
- water_z = list(
- map(
- lambda p: table[
- ts, p.global_index
- ],
- reach.profiles
- )
- )
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
self.water[0].set_data(
rk, water_z
@@ -411,10 +387,8 @@ class PlotRKC(PamhyrPlot):
table = results.get("table")["zfd"]
ts = results.get_timestamp_id(self._current_timestamp)
- zfd = list(map(
- lambda p: table[ts, p.global_index],
- reach.profiles
- ))
+ gi_0, gi_1 = reach.global_index
+ zfd = table[ts, gi_0:gi_1 + 1]
self.line_bottom.remove()
diff --git a/src/View/Results/PlotRKCEnsemble.py b/src/View/Results/PlotRKCEnsemble.py
new file mode 100644
index 00000000..93eb8f7e
--- /dev/null
+++ b/src/View/Results/PlotRKCEnsemble.py
@@ -0,0 +1,355 @@
+# PlotRKCEnsemble.py -- Pamhyr
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# 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 numpy as np
+
+from functools import reduce
+
+from tools import timer
+from View.Tools.PamhyrPlot import PamhyrPlot
+
+from PyQt5.QtCore import (
+ QCoreApplication
+)
+
+logger = logging.getLogger()
+
+
+class PlotRKCEnsemble(PamhyrPlot):
+ def __init__(self, canvas=None, trad=None, toolbar=None,
+ results=None, reach_id=0, profile_id=0,
+ parent=None):
+ super(PlotRKCEnsemble, self).__init__(
+ canvas=canvas,
+ trad=trad,
+ data=results,
+ toolbar=toolbar,
+ parent=parent
+ )
+
+ self._parent = parent
+ self._current_reach_id = reach_id
+ self._current_profile_id = profile_id
+ self._timestamps = parent._timestamps
+ self._current_timestamp = max(self._timestamps)
+
+ self.label_x = self._trad["unit_rk"]
+ self.label_y = self._trad["unit_elevation"]
+
+ self.label_bottom = self._trad["label_bottom"]
+ self.label_water = self._trad["label_water"]
+ self.label_water_mean = self._trad["label_water_mean"]
+ self.label_water_min = self._trad["label_water_min"]
+ self.label_water_max = self._trad["label_water_max"]
+
+ self.water = []
+
+ self._isometric_axis = False
+
+ self._auto_relim_update = True
+ self._autoscale_update = False
+
+ @property
+ def results(self):
+ return self.data
+
+ @results.setter
+ def results(self, results):
+ self.data = results
+ self._current_timestamp = max(self._timestamps)
+
+ @timer
+ def draw(self, highlight=None):
+ self.init_axes()
+
+ if self.results is None:
+ return
+
+ if self.results[0] is None:
+ return
+
+ results = self.results[0]
+ reach = results.river.reach(self._current_reach_id)
+
+ self.draw_bottom(reach)
+ self.draw_std_derivation(reach)
+ self.draw_water_elevation(reach)
+ self.draw_current(reach)
+ self.draw_profiles_hs(reach)
+
+ self.enable_legend()
+
+ self.idle()
+ self._init = True
+
+ def draw_bottom(self, reach):
+ self.draw_bottom_geometry(reach)
+
+ def draw_profiles_hs(self, reach):
+ results = self.results[0]
+ lhs = filter(
+ lambda hs: hs._input_reach.reach is reach.geometry,
+ filter(
+ lambda hs: hs._input_reach is not None,
+ results.study.river.hydraulic_structures.lst
+ )
+ )
+
+ for hs in lhs:
+ x = hs.input_section.rk
+ z_min = reach.geometry.get_z_min()
+ z_max = reach.geometry.get_z_max()
+
+ self.canvas.axes.plot(
+ [x, x],
+ [min(z_min), max(z_max)],
+ linestyle="--",
+ lw=1.,
+ color=self.color_plot_previous,
+ )
+
+ self.canvas.axes.annotate(
+ " > " + hs.name,
+ (x, max(z_max)),
+ horizontalalignment='left',
+ verticalalignment='top',
+ annotation_clip=True,
+ fontsize=9, color=self.color_plot_previous,
+ )
+
+ def draw_bottom_geometry(self, reach):
+ rk = reach.geometry.get_rk()
+ z_min = reach.geometry.get_z_min()
+ z_max = reach.geometry.get_z_max()
+
+ self.line_rk_zmin = self.canvas.axes.plot(
+ rk, z_min,
+ color=self.color_plot_river_bottom,
+ lw=1.
+ )
+
+ self._river_bottom = z_min
+
+ def draw_water_elevation(self, reach):
+ if len(reach.geometry.profiles) != 0:
+ result = self.results[0]
+ rk = reach.geometry.get_rk()
+
+ self.water = []
+
+ colors = [
+ ("z_min", self.color_plot_ensemble_min),
+ ("z_mean", self.color_plot_ensemble_mean),
+ ("z_max", self.color_plot_ensemble_max),
+ ]
+
+ for t, color in colors:
+ table = result.get("table")[t]
+ ts = result.get_timestamp_id(self._current_timestamp)
+
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
+
+ plot, = self.canvas.axes.plot(
+ rk, water_z,
+ lw=1., color=color,
+ label=f"Elevation: {t} (m)",
+ )
+
+ self.water.append(plot)
+
+ def draw_std_derivation(self, reach):
+ if len(reach.geometry.profiles) != 0:
+ result = self.results[0]
+ rk = reach.geometry.get_rk()
+ z_min = reach.geometry.get_z_min()
+
+ color = self.color_plot_ensemble_std_deriv
+
+ table = result.get("table")["z_mean"]
+ std_deriv = result.get("table")["z_std_derivation"]
+
+ ts = result.get_timestamp_id(self._current_timestamp)
+
+ gi_0, gi_1 = reach.global_index
+
+ water_z = table[ts, gi_0:gi_1 + 1]
+ std = std_deriv[ts, gi_0:gi_1 + 1]
+
+ water_z_b = water_z - std
+ water_z_t = water_z + std
+
+ plot = self.canvas.axes.fill_between(
+ rk, water_z_b, water_z_t,
+ lw=1., color=color, alpha=0.3,
+ label=f"Standard derivation",
+ )
+
+ self.water_std_deriv = plot
+
+ def draw_additional_data(self):
+ results = self.results[0]
+
+ self._add_data_lines = []
+
+ for data in results.get("additional_data"):
+ data = data._data
+
+ tx, ty = data['type_x'], data['type_y']
+ x, y = data['x'], data['y']
+ legend = data['legend']
+ unit = data['unit']
+
+ if tx == 'water_elevation' and ty == 'time':
+ line, = self.canvas.axes.plot(
+ x, y, marker="+",
+ label=legend + ' ' + unit
+ )
+
+ self._add_data_lines.append(line)
+
+ self.canvas.draw_idle()
+
+ def draw_current(self, reach):
+ rk = reach.geometry.get_rk()
+ z_min = reach.geometry.get_z_min()
+ z_max = reach.geometry.get_z_max()
+
+ self.profile, = self.canvas.axes.plot(
+ [
+ rk[self._current_profile_id],
+ rk[self._current_profile_id]
+ ],
+ [
+ z_max[self._current_profile_id],
+ z_min[self._current_profile_id]
+ ],
+ color=self.color_plot,
+ lw=1.
+ )
+
+ def is_overflow_point(self, profile, point):
+ left_limit = profile.geometry.point(0)
+ right_limit = profile.geometry.point(
+ profile.geometry.number_points - 1
+ )
+
+ return (
+ point == left_limit
+ or point == right_limit
+ )
+
+ def set_reach(self, reach_id):
+ self._current_reach_id = reach_id
+ self._current_profile_id = 0
+ self.draw()
+
+ def set_profile(self, profile_id):
+ self._current_profile_id = profile_id
+ self.update_current()
+
+ def set_timestamp(self, timestamp):
+ self._current_timestamp = timestamp
+ self.update()
+
+ def update(self):
+ if not self._init:
+ self.draw()
+
+ results = self.results[0]
+ reach = results.river.reach(self._current_reach_id)
+
+ self.update_water_elevation()
+ self.update_std_derivation()
+
+ self.update_idle()
+
+ def update_all(self):
+ self._current_reach_id = self._parent._get_current_reach()
+ self._current_profile_id = self._parent._get_current_profile()
+ self._current_timestamp = self._parent._get_current_timestamp()
+ self._init = False
+ self.update()
+
+ def update_water_elevation(self):
+ result = self.results[0]
+ reach = result.river.reach(self._current_reach_id)
+ rk = reach.geometry.get_rk()
+ z_min = reach.geometry.get_z_min()
+ table = result.get("table")["z_mean"]
+ ts = result.get_timestamp_id(self._current_timestamp)
+
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
+
+ colors = [
+ (0, "z_min", self.color_plot_ensemble_min),
+ (1, "z_mean", self.color_plot_ensemble_mean),
+ (2, "z_max", self.color_plot_ensemble_max),
+ ]
+
+ for i, t, color in colors:
+ table = result.get("table")[t]
+ ts = result.get_timestamp_id(self._current_timestamp)
+
+ gi_0, gi_1 = reach.global_index
+ water_z = table[ts, gi_0:gi_1 + 1]
+
+ self.water[i].set_data(rk, water_z)
+
+ def update_std_derivation(self):
+ result = self.results[0]
+ reach = result.river.reach(self._current_reach_id)
+
+ rk = reach.geometry.get_rk()
+
+ table = result.get("table")["z_mean"]
+ std_deriv = result.get("table")["z_std_derivation"]
+
+ ts = result.get_timestamp_id(self._current_timestamp)
+
+ gi_0, gi_1 = reach.global_index
+
+ water_z = table[ts, gi_0:gi_1 + 1]
+ std = std_deriv[ts, gi_0:gi_1 + 1]
+
+ water_z_b = water_z - std
+ water_z_t = water_z + std
+
+ self.water_std_deriv.set_data(rk, water_z_b, water_z_t)
+
+ def update_additional_data(self):
+ self.draw_additional_data()
+ self.update_idle()
+
+ def update_current(self):
+ results = self.results[0]
+ reach = results.river.reach(self._current_reach_id)
+ rk = reach.geometry.get_rk()
+ z_min = reach.geometry.get_z_min()
+ z_max = reach.geometry.get_z_max()
+ cid = self._current_profile_id
+
+ self.profile.set_data(
+ [rk[cid], rk[cid]],
+ [z_max[cid], z_min[cid]]
+ )
+
+ self.canvas.figure.canvas.draw_idle()
+ self.update_idle()
diff --git a/src/View/Results/Table.py b/src/View/Results/Table.py
index b730ce12..0b41d78e 100644
--- a/src/View/Results/Table.py
+++ b/src/View/Results/Table.py
@@ -61,6 +61,8 @@ class TableModel(PamhyrTableModel):
# _river.reach(0).profiles))
elif self._opt_data == "solver":
self._lst = self._parent._solvers
+ elif self._opt_data == "runs":
+ self._lst = self._data[0].get("table")["samples"]
def __init__(self, **kwargs):
self._timestamp = max(kwargs["parent"]._timestamps)
@@ -93,6 +95,10 @@ class TableModel(PamhyrTableModel):
if v is None:
v = self._data[0].solver_name
return str(v)
+ elif self._opt_data == "runs":
+ if self._headers[column] == "runs":
+ p = self._lst[row, :]
+ return f"{row}: {p}"
elif self._opt_data == "raw_data":
table = self._data[0].get("table")
ts = self._data[0].get_timestamp_id(self._timestamp)
diff --git a/src/View/Results/Window.py b/src/View/Results/Window.py
index 2f03ec3c..2a9a5a79 100644
--- a/src/View/Results/Window.py
+++ b/src/View/Results/Window.py
@@ -72,12 +72,16 @@ from View.Results.PlotRKC import PlotRKC
from View.Results.PlotH import PlotH
from View.Results.PlotSedReach import PlotSedReach
from View.Results.PlotSedProfile import PlotSedProfile
+from View.Results.PlotRKCEnsemble import PlotRKCEnsemble
+from View.Results.PlotHEnsemble import PlotHEnsemble
from View.Results.CustomPlot.Plot import CustomPlot
from View.Results.CustomPlot.CustomPlotValuesSelectionDialog import (
CustomPlotValuesSelectionDialog,
)
-from View.Results.CustomExport.CustomExport import CustomExportDialog
+from View.Results.CustomExport.CustomExport import (
+ CustomExportDialog, CustomExportEnsembleDialog,
+)
from View.Results.Table import TableModel
from View.Results.translate import (
@@ -397,21 +401,21 @@ class ResultsWindow(PamhyrWindow):
)
# Table and Plot
- fun = {
- "reach": self._set_current_reach,
- "profile": self._set_current_profile,
- "raw_data": self._set_current_profile_raw_data,
- "solver": self._set_current_results,
- }
+ funs = [
+ ("reach", self._set_current_reach),
+ ("profile", self._set_current_profile),
+ ("raw_data", self._set_current_profile_raw_data),
+ ("solver", self._set_current_results),
+ ]
- for t in ["reach", "profile", "raw_data", "solver"]:
+ for t, f in funs:
table = self.find(QTableView, f"tableView_{t}")
table.selectionModel()\
.selectionChanged\
- .connect(fun[t])
+ .connect(f)
- self._table[t].dataChanged.connect(fun[t])
+ self._table[t].dataChanged.connect(f)
self._slider_time.valueChanged.connect(self._set_current_timestamp)
self._button_play.setChecked(False)
@@ -786,34 +790,47 @@ class ResultsWindow(PamhyrWindow):
def export_to(self, filename, x, y, envelop, solver_id):
results = self._results[solver_id]
reach = results.river.reachs[self._get_current_reach()]
- first_line = [f"Study: {results.study.name}",
- f"Reach: {reach.name}"]
+ first_line = [
+ f"Study: {results.study.name}",
+ f"Reach: {reach.name}"
+ ]
+
if x == "rk":
timestamp = self._get_current_timestamp()
+
first_line.append(f"Time: {timestamp}s")
val_dict = self._export_rk(timestamp, y, envelop, solver_id)
elif x == "time":
profile_id = self._get_current_profile()
profile = reach.profile(profile_id)
pname = profile.name if profile.name != "" else profile.rk
+
first_line.append(f"Profile: {pname}")
val_dict = self._export_time(profile_id, y, solver_id)
try:
with open(filename, 'w', newline='') as csvfile:
writer = csv.writer(csvfile, delimiter=',',
- quotechar='|', quoting=csv.QUOTE_MINIMAL)
+ quotechar='|',
+ quoting=csv.QUOTE_MINIMAL)
dict_x = self._trad.get_dict("values_x")
header = []
+
writer.writerow(first_line)
+
for text in val_dict.keys():
header.append(text)
+
writer.writerow(header)
+
for row in range(len(val_dict[dict_x[x]])):
line = []
+
for var in val_dict.keys():
line.append(val_dict[var][row])
+
writer.writerow(line)
+
self._timer.stop()
except Exception as e:
self.message_box(
@@ -876,266 +893,175 @@ class ResultsWindow(PamhyrWindow):
dict_x = self._trad.get_dict("values_x")
dict_y = self._trad.get_dict("values_y")
- if self._current_results == 2:
- envelop = False
- reach1 = results[0].river.reachs[self._get_current_reach()]
- reach2 = results[1].river.reachs[self._get_current_reach()]
-
if envelop:
dict_y.update(self._trad.get_dict("values_y_envelop"))
- my_dict = {}
- my_dict[dict_x["rk"]] = reach.geometry.get_rk()
-
table = results.get("table")
id_ts = results.get_timestamp_id(timestamp)
- z = table["Z"]
- q = table["Q"]
- v = table["V"]
- zfd = table["zfd"]
+ ind = reach.global_index
- if "bed_elevation" in y:
- if reach.has_bedload():
- zmin = list(map(
- lambda p: zfd[id_ts, p.global_index],
- reach.profiles
- ))
- else:
- zmin = reach.geometry.get_z_min()
- my_dict[dict_y["bed_elevation"]] = zmin
- # if envelop and reach.has_sediment():
- if "discharge" in y:
- my_dict[dict_y["discharge"]] = list(
- map(
- lambda p: q[id_ts, p.global_index],
- reach.profiles
- )
+ my_dict = {}
+ data = {
+ "z": table["Z"][:, ind[0]:ind[1]+1],
+ "q": table["Q"][:, ind[0]:ind[1]+1],
+ "v": table["V"][:, ind[0]:ind[1]+1],
+ "zfd": (table["zfd"][:, ind[0]:ind[1]+1]
+ if "zfd" in table else None),
+
+ "reach": reach,
+ "id_ts": id_ts, "envelop": envelop,
+ "my_dict": my_dict,
+ }
+
+ func = {
+ "bed_elevation": self._export_rk_compute_elevation,
+ "discharge": self._export_rk_compute_discharge,
+ "water_elevation": self._export_rk_compute_water_elevation,
+ "velocity": self._export_rk_compute_velocity,
+ "depth": self._export_rk_compute_depth,
+ "mean_depth": self._export_rk_compute_mean_depth,
+ "froude": self._export_rk_compute_froude,
+ "wet_area": self._export_rk_compute_wet_area,
+ }
+
+ # Compute values
+ for key in y:
+ func[key](data)
+
+ # Translate dictionary keys
+ translated_dict = {}
+ translated_dict[dict_x["rk"]] = reach.geometry.get_rk()
+
+ for key in my_dict:
+ translated_dict[dict_y[key]] = my_dict[key]
+
+ return translated_dict
+
+ def _export_rk_compute_elevation(self, data):
+ my_dict = data["my_dict"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ if reach.has_bedload() and data["zfd"] is not None:
+ zfd = data["zfd"]
+ zmin = zfd[id_ts, :]
+ else:
+ zmin = reach.geometry.get_z_min()
+
+ my_dict["bed_elevation"] = zmin
+
+ def _export_rk_compute_discharge(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ id_ts = data["id_ts"]
+
+ q = data["q"]
+
+ my_dict["discharge"] = q[id_ts, :]
+
+ if envelop:
+ my_dict["min_discharge"] = np.min(q, axis=0)
+ my_dict["max_discharge"] = np.max(q, axis=0)
+
+ def _export_rk_compute_water_elevation(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ id_ts = data["id_ts"]
+
+ z = data["z"]
+
+ my_dict["water_elevation"] = z[id_ts, :]
+
+ if envelop:
+ my_dict["min_water_elevation"] = np.min(z, axis=0)
+ my_dict["max_water_elevation"] = np.max(z, axis=0)
+
+ def _export_rk_compute_velocity(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ id_ts = data["id_ts"]
+
+ v = data["v"]
+
+ my_dict["velocity"] = v[id_ts, :]
+
+ if envelop:
+ my_dict["min_velocity"] = np.min(v, axis=0)
+ my_dict["max_velocity"] = np.max(v, axis=0)
+
+ def _export_rk_compute_depth(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ z = data["z"]
+
+ my_dict["depth"] = list(
+ map(
+ lambda p: p.geometry.max_water_depth(
+ z[id_ts, p.global_index]
+ ),
+ reach.profiles
)
- if envelop:
- my_dict[dict_y["min_discharge"]] = list(
- map(
- lambda p: np.min(q[:, p.global_index]),
- reach.profiles
- )
- )
- my_dict[dict_y["max_discharge"]] = list(
- map(
- lambda p: np.max(q[:, p.global_index]),
- reach.profiles
- )
- )
- if "water_elevation" in y:
- my_dict[dict_y["water_elevation"]] = list(
- map(
- lambda p: z[id_ts, p.global_index],
- reach.profiles
- )
+ )
+
+ if envelop:
+ z_min = reach.geometry.get_z_min()
+
+ my_dict["min_depth"] = np.min(z, axis=0) - z_min
+ my_dict["max_depth"] = np.max(z, axis=0) - z_min
+
+ def _export_rk_compute_mean_depth(self, data):
+ my_dict = data["my_dict"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ z = data["z"]
+
+ my_dict["mean_depth"] = list(
+ map(
+ lambda p: p.geometry.mean_water_depth(
+ z[id_ts, p.global_index]
+ ),
+ reach.profiles
)
- if envelop:
- my_dict[dict_y["min_water_elevation"]] = list(
- map(
- lambda p: np.min(z[:, p.global_index]),
- reach.profiles
- )
- )
- my_dict[dict_y["max_water_elevation"]] = list(
- map(
- lambda p: np.max(z[:, p.global_index]),
- reach.profiles
- )
- )
+ )
- if "velocity" in y:
- my_dict[dict_y["velocity"]] = list(
- map(
- lambda p: v[id_ts, p.global_index],
- reach.profiles
- )
+ def _export_rk_compute_froude(self, data):
+ my_dict = data["my_dict"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ z = data["z"]
+ v = data["v"]
+
+ my_dict["froude"] = list(
+ map(
+ lambda p: p.geometry.froude(
+ z[id_ts, p.global_index],
+ v[id_ts, p.global_index]
+ ),
+ reach.profiles
)
- if envelop:
- velocities = list(map(
- lambda p: v[:, p.global_index],
- reach.profiles
- ))
- my_dict[dict_y["min_velocity"]] = [min(v) for v in velocities]
- my_dict[dict_y["max_velocity"]] = [max(v) for v in velocities]
+ )
- if "depth" in y:
- if self._current_results != 2:
- d = my_dict[dict_y["depth"]] = list(
- map(
- lambda p: p.geometry.max_water_depth(
- z[id_ts, p.global_index]
- ),
- reach.profiles
- )
- )
- else:
- d1 = list(
- map(
- lambda p: p.geometry.max_water_depth(
- z[id_ts, p.global_index]
- ),
- reach1.profiles
- )
- )
- d2 = list(
- map(
- lambda p: p.geometry.max_water_depth(
- z[id_ts, p.global_index]
- ),
- reach2.profiles
- )
- )
- d = list(
- map(
- lambda x, y: x - y,
- d1, d2
- )
- )
- my_dict[dict_y["depth"]] = d
- if envelop:
- my_dict[dict_y["min_depth"]] = list(map(
- lambda p1, p2: p1 - p2, map(
- lambda p: np.min(z[:, p.global_index]),
- reach.profiles
- ), reach.geometry.get_z_min()
- )
- )
- my_dict[dict_y["max_depth"]] = list(map(
- lambda p1, p2: p1 - p2, map(
- lambda p: np.max(z[:, p.global_index]),
- reach.profiles
- ), reach.geometry.get_z_min()
- )
- )
+ def _export_rk_compute_wet_area(self, data):
+ my_dict = data["my_dict"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
- if "mean_depth" in y:
- if self._current_results != 2:
- d = list(
- map(
- lambda p: p.geometry.mean_water_depth(
- z[id_ts, p.global_index]
- ),
- reach.profiles
- )
- )
- else:
- d1 = list(
- map(
- lambda p: p.geometry.mean_water_depth(
- z[id_ts, p.global_index]
- ),
- reach1.profiles
- )
- )
- d2 = list(
- map(
- lambda p: p.geometry.mean_water_depth(
- z[id_ts, p.global_index]
- ),
- reach2.profiles
- )
- )
- d = list(
- map(
- lambda x, y: x - y,
- d1, d2
- )
- )
- my_dict[dict_y["mean_depth"]] = d
- if "froude" in y:
- if self._current_results != 2:
- fr = my_dict[dict_y["froude"]] = list(
- map(
- lambda p: (
- v[id_ts, p.global_index] /
- sqrt(9.81 * (
- p.geometry.wet_area(
- z[id_ts, p.global_index]
- ) /
- p.geometry.wet_width(
- z[id_ts, p.global_index]
- )
- ))
- ),
- reach.profiles
- )
- )
- else:
- fr1 = list(
- map(
- lambda p: (
- v[id_ts, p.global_index] /
- sqrt(9.81 * (
- p.geometry.wet_area(
- z[id_ts, p.global_index]
- ) /
- p.geometry.wet_width(
- z[id_ts, p.global_index]
- )
- ))
- ),
- reach1.profiles
- )
- )
- fr2 = list(
- map(
- lambda p: (
- v[id_ts, p.global_index] /
- sqrt(9.81 * (
- p.geometry.wet_area(
- z[id_ts, p.global_index]
- ) /
- p.geometry.wet_width(
- z[id_ts, p.global_index]
- )
- ))
- ),
- reach2.profiles
- )
- )
- fr = list(map(
- lambda x, y: x - y,
- fr1, fr2
- ))
- my_dict[dict_y["froude"]] = fr
- if "wet_area" in y:
- if self._current_results != 2:
- wa = list(
- map(
- lambda p: p.geometry.wet_area(
- z[id_ts, p.global_index]
- ),
- reach.profiles
- )
- )
- else:
- wa1 = list(
- map(
- lambda p: p.geometry.wet_area(
- z[id_ts, p.global_index]
- ),
- reach1.profiles
- )
- )
- wa2 = list(
- map(
- lambda p: p.geometry.wet_area(
- z[id_ts, p.global_index]
- ),
- reach2.profiles
- )
- )
- wa = list(map(
- lambda x, y: x - y,
- wa1, wa2
- ))
+ z = data["z"]
- my_dict[dict_y["wet_area"]] = wa
-
- return my_dict
+ my_dict["wet_area"] = list(
+ map(
+ lambda p: p.geometry.wet_area(
+ z[id_ts, p.global_index]
+ ),
+ reach.profiles
+ )
+ )
def _export_time(self, profile, y, solver_id):
results = self._results[solver_id]
@@ -1144,133 +1070,106 @@ class ResultsWindow(PamhyrWindow):
dict_x = self._trad.get_dict("values_x")
dict_y = self._trad.get_dict("values_y")
- my_dict = {}
- my_dict[dict_x["time"]] = self._timestamps
-
table = results.get("table")
+ ind = profile.global_index
- z = table["Z"][:, profile.global_index]
- q = table["Q"][:, profile.global_index]
- v = table["V"][:, profile.global_index]
+ my_dict = {}
+ data = {
+ "z": table["Z"][:, ind],
+ "q": table["Q"][:, ind],
+ "v": table["V"][:, ind],
+ "zfd": table["zfd"][:, ind] if "zfd" in table else None,
- if self._current_results == 2:
- reach1 = self._results[0].river.reach(self._reach)
- reach2 = self._results[1].river.reach(self._reach)
- reach3 = self._results[2].river.reach(self._reach)
- profile1 = reach1.profile(self._profile)
- profile2 = reach2.profile(self._profile)
- # profile3 = reach3.profile(self._profile)
+ "reach": reach,
+ "profile": profile,
+ "my_dict": my_dict,
+ }
- z1 = table["Z"][:, profile1.global_index]
- q1 = table["Q"][:, profile1.global_index]
- v1 = table["V"][:, profile1.global_index]
+ def set_dict(k, v):
+ my_dict[k] = v
- z2 = table["Z"][:, profile2.global_index]
- q2 = table["Q"][:, profile2.global_index]
- v2 = table["V"][:, profile2.global_index]
+ func = {
+ "bed_elevation": self._export_time_compute_elevation,
+ "discharge": lambda d: set_dict("discharge", d["q"]),
+ "water_elevation": lambda d: set_dict("water_elevation", d["z"]),
+ "velocity": lambda d: set_dict("velocity", d["v"]),
+ "depth": self._export_time_compute_depth,
+ "mean_depth": self._export_time_compute_mean_depth,
+ "froude": self._export_time_compute_froude,
+ "wet_area": self._export_time_compute_wet_area,
+ }
- if "bed_elevation" in y:
- if reach.has_bedload() and "zfd" in table:
- z_min = table["zfd"][:, profile.global_index]
- else:
- z_min = [profile.geometry.z_min()] * len(self._timestamps)
+ # Compute values
+ for key in y:
+ func[key](data)
- my_dict[dict_y["bed_elevation"]] = z_min
- if "discharge" in y:
- my_dict[dict_y["discharge"]] = q
- if "water_elevation" in y:
- my_dict[dict_y["water_elevation"]] = z
- if "velocity" in y:
- my_dict[dict_y["velocity"]] = v
- if "depth" in y:
- if self._current_results != 2:
- d = list(
- map(lambda x: profile.geometry.max_water_depth(x), z)
- )
- else:
- d1 = list(
- map(lambda x: profile1.geometry.max_water_depth(x), z1)
- )
- d2 = list(
- map(lambda x: profile2.geometry.max_water_depth(x), z2)
- )
- d = list(
- map(
- lambda x, y: x - y,
- d1, d2
- )
- )
- my_dict[dict_y["depth"]] = d
- if "mean_depth" in y:
- if self._current_results != 2:
- d = list(
- map(lambda x: profile.geometry.mean_water_depth(x), z)
- )
- else:
- d1 = list(
- map(lambda x: profile1.geometry.mean_water_depth(x), z1)
- )
- d2 = list(
- map(lambda x: profile2.geometry.mean_water_depth(x), z2)
- )
- d = list(map(
- lambda x, y: x - y,
- d1, d2
- ))
- my_dict[dict_y["mean_depth"]] = d
- if "froude" in y:
- if self._current_results != 2:
- fr = my_dict[dict_y["froude"]] = list(
- map(lambda z, v:
- v / sqrt(9.81 * (
- profile.geometry.wet_area(z) /
- profile.geometry.wet_width(z))
- ),
- z, v)
- )
- else:
- fr1 = list(
- map(lambda z, v:
- v /
- sqrt(9.81 * (
- profile1.geometry.wet_area(z) /
- profile1.geometry.wet_width(z))
- ),
- z1, v1)
- )
- fr2 = list(
- map(lambda z, v:
- v /
- sqrt(9.81 * (
- profile2.geometry.wet_area(z) /
- profile2.geometry.wet_width(z))
- ),
- z2, v2)
- )
- fr = list(map(
- lambda x, y: x - y,
- fr1, fr2
- ))
- my_dict[dict_y["froude"]] = fr
- if "wet_area" in y:
- if self._current_results != 2:
- wa = list(
- map(lambda x: profile.geometry.wet_area(x), z)
- )
- else:
- wa1 = list(
- map(lambda x: profile1.geometry.wet_area(x), z1)
- )
- wa2 = list(
- map(lambda x: profile2.geometry.wet_area(x), z2)
- )
- wa = list(map(
- lambda x, y: x - y,
- wa1, wa2
- ))
- my_dict[dict_y["wet_area"]] = wa
+ # Translate dictionary keys
+ translated_dict = {}
+ translated_dict[dict_x["time"]] = self._timestamps
- return my_dict
+ for key in my_dict:
+ translated_dict[dict_y[key]] = my_dict[key]
+
+ return translated_dict
+
+ def _export_time_compute_elevation(self, data):
+ my_dict = data["my_dict"]
+ profile = data["profile"]
+ reach = data["reach"]
+
+ if reach.has_bedload() and data["zfd"] is not None:
+ z_min = data["zfd"]
+ else:
+ z_min = [profile.geometry.z_min()] * len(self._timestamps)
+
+ my_dict["bed_elevation"] = z_min
+
+ def _export_time_compute_depth(self, data):
+ my_dict = data["my_dict"]
+ profile = data["profile"]
+
+ z = data["z"]
+
+ my_dict["depth"] = list(
+ map(lambda x: profile.geometry.max_water_depth(x), z)
+ )
+
+ def _export_time_compute_mean_depth(self, data):
+ my_dict = data["my_dict"]
+ profile = data["profile"]
+
+ z = data["z"]
+
+ my_dict["mean_depth"] = list(
+ map(lambda x: profile.geometry.mean_water_depth(x), z)
+ )
+
+ def _export_time_compute_froude(self, data):
+ my_dict = data["my_dict"]
+ profile = data["profile"]
+
+ z = data["z"]
+ v = data["v"]
+
+ my_dict["froude"] = list(
+ map(
+ lambda z, v: profile.geometry.froude(z, v),
+ z, v
+ )
+ )
+
+ def _export_time_compute_wet_area(self, data):
+ my_dict = data["my_dict"]
+ profile = data["profile"]
+
+ z = data["z"]
+
+ my_dict["wet_area"] = list(
+ map(
+ lambda x: profile.geometry.wet_area(x),
+ z
+ )
+ )
def get_timestamps(self):
self._timestamps = sorted(list(self._results[0].get("timestamps")))
@@ -1438,3 +1337,405 @@ class ResultsWindow(PamhyrWindow):
for data in results.get("additional_data"):
for p in self._additional_plot:
self._additional_plot[p].add_imported_plot(data)
+
+
+class ResultsEnsembleWindow(ResultsWindow):
+ _pamhyr_ui = "ResultsEnsemble"
+ _pamhyr_name = "Results"
+
+ def setup_table(self):
+ self._table = {}
+
+ for t in ["reach", "profile", "runs"]:
+ table = self.find(QTableView, f"tableView_{t}")
+
+ self._table[t] = TableModel(
+ table_view=table,
+ table_headers=self._trad.get_dict(f"table_headers_{t}"),
+ data=self._results,
+ undo=self._undo_stack,
+ opt_data=t,
+ parent=self
+ )
+
+ self._table[t]._timestamp = self._timestamps[
+ self._slider_time.value()
+ ]
+
+ def setup_plots(self):
+ self.canvas = MplCanvas(width=5, height=4, dpi=100)
+
+ tab_widget = self.find(QTabWidget, f"tabWidget")
+
+ tab_widget.setTabsClosable(True)
+ tab_widget.tabCloseRequested.connect(self.delete_tab)
+ tab_widget.tabBar().setTabButton(0, QTabBar.RightSide, None)
+ tab_widget.tabBar().setTabButton(1, QTabBar.RightSide, None)
+
+ self.canvas_1 = MplCanvas(width=5, height=4, dpi=100)
+ self.canvas_1.setObjectName("canvas_1")
+ self.toolbar_1 = PamhyrPlotToolbar(
+ self.canvas_1, self, items=[
+ "home", "move", "zoom", "save",
+ "iso", "back/forward"
+ ]
+ )
+ self.plot_layout_1 = self.find(QVBoxLayout,
+ "verticalLayout_elevation")
+ self.plot_layout_1.addWidget(self.toolbar_1)
+ self.plot_layout_1.addWidget(self.canvas_1)
+
+ self.plot_rkc = PlotRKCEnsemble(
+ canvas=self.canvas_1,
+ results=self._results,
+ reach_id=0,
+ profile_id=0,
+ trad=self._trad,
+ toolbar=self.toolbar_1,
+ parent=self
+ )
+ self.plot_rkc.draw()
+
+ self.canvas_2 = MplCanvas(width=5, height=4, dpi=100)
+ self.canvas_2.setObjectName("canvas_2")
+ self.toolbar_2 = PamhyrPlotToolbar(
+ self.canvas_2, self, items=[
+ "home", "move", "zoom", "save",
+ "iso", "back/forward"
+ ]
+ )
+ self.plot_layout_2 = self.find(
+ QVBoxLayout, "verticalLayout_hydrograph")
+ self.plot_layout_2.addWidget(self.toolbar_2)
+ self.plot_layout_2.addWidget(self.canvas_2)
+
+ self.plot_h = PlotHEnsemble(
+ canvas=self.canvas_2,
+ results=self._results,
+ reach_id=0,
+ profile_id=[0],
+ res_id=self._current_results,
+ trad=self._trad,
+ toolbar=self.toolbar_2,
+ parent=self
+ )
+ self.plot_h.draw()
+
+ def setup_connections(self):
+ # Action
+ actions = {
+ "action_export": self._export,
+ # "action_export": self.export_current,
+ "action_import_data": self.import_data
+ }
+
+ if len(self._results) > 1:
+ self.find(QAction, "action_reload").setEnabled(False)
+
+ for action in actions:
+ self.find(QAction, action).triggered.connect(
+ actions[action]
+ )
+
+ # Table and Plot
+ funs = [
+ ("reach", self._set_current_reach),
+ ("profile", self._set_current_profile),
+ ]
+
+ for t, f in funs:
+ table = self.find(QTableView, f"tableView_{t}")
+
+ table.selectionModel()\
+ .selectionChanged\
+ .connect(f)
+
+ self._table[t].dataChanged.connect(f)
+
+ self._slider_time.valueChanged.connect(self._set_current_timestamp)
+ self._button_play.setChecked(False)
+ self._button_play.clicked.connect(self._pause)
+ self._button_back.clicked.connect(self._back)
+ self._button_next.clicked.connect(self._next)
+ self._button_first.clicked.connect(self._first)
+ self._button_last.clicked.connect(self._last)
+ self._timer.timeout.connect(self._update_slider)
+
+ # tabs
+ tab_widget = self.find(QTabWidget, f"tabWidget")
+ tab_widget.currentChanged.connect(self.tab_changed)
+
+ def _compute_status_label(self):
+ return (self.text_bief() + " | " +
+ self.text_profile() + " | " +
+ self.text_time() + " | " +
+ self.text_seed())
+
+ def text_seed(self):
+ return f"Seed: {self._results[0].get("seed")}"
+
+ def update(self,
+ reach_id=None,
+ profile_id=None,
+ timestamp=None):
+ tab_widget = self.find(QTabWidget, f"tabWidget")
+ tab_index = tab_widget.currentIndex()
+ name = tab_widget.tabText(tab_index)
+
+ if reach_id is not None:
+ if tab_index == 0:
+ self.plot_rkc.set_reach(reach_id)
+ elif tab_index == 1:
+ self.plot_h.set_reach(reach_id)
+
+ self.update_table_selection_reach(reach_id)
+ self.update_table_selection_profile(0)
+
+ if profile_id is not None:
+ if tab_index == 0:
+ self.plot_rkc.set_profile(profile_id[0])
+ elif tab_index == 1:
+ self.plot_h.set_profile(profile_id)
+
+ tab_widget = self.find(QTabWidget, f"tabWidget")
+ if tab_widget.currentIndex() != 2:
+ self.update_table_selection_profile(profile_id[0])
+
+ if timestamp is not None:
+ self.plot_rkc.set_timestamp(timestamp)
+ if tab_index == 1:
+ self.plot_h.set_timestamp(timestamp)
+
+ self.update_statusbar()
+
+ def tab_changed(self, i):
+ if i != 2:
+ if len(self._get_current_profiles_list()) > 1:
+ # unselect all profiles but the first one
+ profile_id = self._get_current_profile()
+ self.update_table_selection_profile(profile_id)
+
+ tab_widget = self.find(QTabWidget, f"tabWidget")
+ tab_index = tab_widget.currentIndex()
+ name = tab_widget.tabText(tab_index)
+ if tab_index == 1:
+ self.plot_rkc.update_all()
+ elif tab_index == 2:
+ self.plot_h.update_all()
+ elif tab_index > 2:
+ self._additional_plot[name].update_all()
+
+ def update_table_selection_reach(self, ind):
+ table = self.find(QTableView, f"tableView_reach")
+ selectionModel = table.selectionModel()
+ index = table.model().index(ind, 0)
+
+ selectionModel.select(
+ index,
+ QItemSelectionModel.Rows |
+ QItemSelectionModel.ClearAndSelect |
+ QItemSelectionModel.Select
+ )
+ table.scrollTo(index)
+
+ self._table["profile"].update(ind)
+
+ def update_table_selection_profile(self, ind):
+ for t in ["profile"]:
+ table = self.find(QTableView, f"tableView_{t}")
+ selectionModel = table.selectionModel()
+ index = table.model().index(ind, 0)
+
+ selectionModel.select(
+ index,
+ QItemSelectionModel.Rows |
+ QItemSelectionModel.ClearAndSelect |
+ QItemSelectionModel.Select
+ )
+ table.scrollTo(index)
+
+ def update_table_selection_solver(self, ind):
+ return
+
+ def _export(self):
+ profile_id = self._get_current_profile()
+ self.update_table_selection_profile(profile_id)
+
+ dlg = CustomExportEnsembleDialog(parent=self)
+ if dlg.exec():
+ x, y, envelop, solver_id = dlg.value
+ else:
+ return
+
+ logger.debug(
+ "Export custom plot for: " +
+ f"{x} -> {','.join(y)}"
+ )
+ self.file_dialog(
+ select_file="AnyFile",
+ callback=lambda f: self.export_to(f[0], x, y, envelop, solver_id),
+ default_suffix=".csv",
+ file_filter=[self._trad["file_csv"]],
+ )
+
+ def _export_rk(self, timestamp, y, envelop, solver_id):
+ results = self._results[solver_id]
+ reach = results.river.reachs[self._get_current_reach()]
+ dict_x = self._trad.get_dict("values_x_ens")
+ dict_y = self._trad.get_dict("values_y_ens")
+
+ if envelop:
+ dict_y.update(self._trad.get_dict("values_y_envelop_ens"))
+
+ table = results.get("table")
+ id_ts = results.get_timestamp_id(timestamp)
+
+ ind = reach.global_index
+
+ my_dict = {}
+ data = {
+ "z_min": table["z_min"][:, ind[0]:ind[1]+1],
+ "q_min": table["q_min"][:, ind[0]:ind[1]+1],
+ "z": table["z_mean"][:, ind[0]:ind[1]+1],
+ "q": table["q_mean"][:, ind[0]:ind[1]+1],
+ "z_max": table["z_max"][:, ind[0]:ind[1]+1],
+ "q_max": table["q_max"][:, ind[0]:ind[1]+1],
+
+ "z_std_deriv": table["z_std_derivation"][:, ind[0]:ind[1]+1],
+ "q_std_deriv": table["q_std_derivation"][:, ind[0]:ind[1]+1],
+
+ "reach": reach,
+ "id_ts": id_ts, "envelop": envelop,
+ "my_dict": my_dict,
+ }
+
+ func = {
+ "z_mean": self._export_rk_z,
+ "q_mean": self._export_rk_q,
+ "z_standard_derivation": self._export_rk_z_std_deriv,
+ "q_standard_derivation": self._export_rk_q_std_deriv,
+ }
+
+ # Compute values
+ for key in y:
+ func[key](data)
+
+ # Translate dictionary keys
+ translated_dict = {}
+ translated_dict[dict_x["rk"]] = reach.geometry.get_rk()
+
+ for key in my_dict:
+ translated_dict[dict_y[key]] = my_dict[key]
+
+ return translated_dict
+
+ def _export_rk_z(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ my_dict["z_mean"] = data["z"][id_ts, :]
+
+ if envelop:
+ my_dict["z_min"] = data["z_min"][id_ts, :]
+ my_dict["z_max"] = data["z_max"][id_ts, :]
+
+ def _export_rk_z_std_deriv(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ my_dict["z_standard_derivation"] = data["z_std_deriv"][id_ts, :]
+
+ def _export_rk_q(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ my_dict["q_mean"] = data["q"][id_ts, :]
+
+ if envelop:
+ my_dict["q_min"] = data["q_min"][id_ts, :]
+ my_dict["q_max"] = data["q_max"][id_ts, :]
+
+ def _export_rk_q_std_deriv(self, data):
+ my_dict = data["my_dict"]
+ envelop = data["envelop"]
+ reach = data["reach"]
+ id_ts = data["id_ts"]
+
+ my_dict["q_standard_derivation"] = data["q_std_deriv"][id_ts, :]
+
+ def _export_time(self, profile, y, solver_id):
+ results = self._results[solver_id]
+ reach = results.river.reachs[self._get_current_reach()]
+ profile = reach.profile(profile)
+ dict_x = self._trad.get_dict("values_x")
+ dict_y = self._trad.get_dict("values_y")
+
+ table = results.get("table")
+ ind = profile.global_index
+
+ my_dict = {}
+ data = {
+ "z_min": table["z_min"][:, ind],
+ "q_min": table["q_min"][:, ind],
+ "z": table["z_mean"][:, ind],
+ "q": table["q_mean"][:, ind],
+ "z_max": table["z_max"][:, ind],
+ "q_max": table["q_max"][:, ind],
+
+ "z": table["z_std_derivation"][:, ind],
+ "q": table["q_std_derivation"][:, ind],
+
+ "reach": reach,
+ "profile": profile,
+ "my_dict": my_dict,
+ }
+
+ func = {
+ "z_mean": self._export_time_z,
+ "q_mean": self._export_time_q,
+ "z_standard_derivation": self._export_time_z_std_deriv,
+ "q_standard_derivation": self._export_time_q_std_deriv,
+ }
+
+ # Compute values
+ for key in y:
+ func[key](data)
+
+ # Translate dictionary keys
+ translated_dict = {}
+ translated_dict[dict_x["time"]] = self._timestamps
+
+ for key in my_dict:
+ translated_dict[dict_y[key]] = my_dict[key]
+
+ return translated_dict
+
+ def _export_time_z(self, data):
+ my_dict = data["my_dict"]
+
+ my_dict["z_mean"] = data["z"]
+ my_dict["z_min"] = data["z_min"]
+ my_dict["z_max"] = data["z_max"]
+
+ def _export_time_z_std_deriv(self, data):
+ my_dict = data["my_dict"]
+
+ my_dict["z_standard_derivation"] = data["z_std_deriv"]
+
+ def _export_time_q(self, data):
+ my_dict = data["my_dict"]
+
+ my_dict["q_mean"] = data["q"]
+ my_dict["q_min"] = data["q_min"]
+ my_dict["q_max"] = data["q_max"]
+
+ def _export_time_q_std_deriv(self, data):
+ my_dict = data["my_dict"]
+
+ my_dict["q_standard_derivation"] = data["q_std_deriv"]
diff --git a/src/View/Results/translate.py b/src/View/Results/translate.py
index d4b103d8..927aaf6e 100644
--- a/src/View/Results/translate.py
+++ b/src/View/Results/translate.py
@@ -44,6 +44,7 @@ class ResultsTranslate(MainTranslate):
self._dict['envelop'] = _translate("Results", "Envelop")
self._dict['solver'] = _translate("Results", "Solver")
+ self._dict['runs'] = _translate("Results", "Runs")
self._dict['x'] = _translate("Results", "X (m)")
self._dict["temperature_map"] = _translate(
@@ -55,10 +56,20 @@ class ResultsTranslate(MainTranslate):
self._dict['label_bottom'] = _translate("Results", "Bottom")
self._dict['label_water'] = _translate("Results", "Water elevation")
+
+ self._dict['label_water_min'] = _translate(
+ "Results",
+ "Min water elevation"
+ )
+ self._dict['label_water_mean'] = _translate(
+ "Results",
+ "Mean water elevation"
+ )
self._dict['label_water_max'] = _translate(
"Results",
"Max water elevation"
)
+
self._dict["ImageCoordinates"] = _translate(
"Results", "Image coordinates"
)
@@ -87,6 +98,10 @@ class ResultsTranslate(MainTranslate):
"solver": self._dict['solver'],
}
+ self._sub_dict["table_headers_runs"] = {
+ "runs": self._dict['runs'],
+ }
+
self._sub_dict["table_headers_pollutants"] = {
"name": _translate("Results", "Pollutant name"),
}
@@ -121,6 +136,25 @@ class ResultsTranslate(MainTranslate):
"wet_area": self._dict["unit_wet_area"],
}
+ self._sub_dict["values_x_ens"] = {
+ "rk": self._dict["unit_rk"],
+ "time": self._dict["unit_time_s"],
+ }
+
+ self._sub_dict["values_y_ens"] = {
+ "z_mean": self._dict["unit_mean_water_elevation"],
+ "q_mean": self._dict["unit_mean_discharge"],
+ "z_standard_derivation": self._dict["unit_std_deriv_elevation"],
+ "q_standard_derivation": self._dict["unit_std_deriv_discharge"],
+ }
+
+ self._sub_dict["values_y_envelop_ens"] = {
+ "z_min": self._dict["unit_min_water_elevation"],
+ "z_max": self._dict["unit_max_water_elevation"],
+ "q_min": self._dict["unit_min_discharge"],
+ "q_max": self._dict["unit_max_discharge"],
+ }
+
if self.pollutants is not None:
self._sub_dict["table_headers_raw_data"] = {
"name": _translate("Results", "Profile"),
@@ -151,7 +185,7 @@ class ResultsTranslate(MainTranslate):
"max_velocity": self._dict["unit_max_velocity"],
"min_depth": self._dict["unit_min_depth"],
"max_depth": self._dict["unit_max_depth"],
- }
+ }
self._sub_dict["values_y_pol"] = {
"unit_C": self._dict["unit_concentration"],
diff --git a/src/View/RunSolver/Window.py b/src/View/RunSolver/Window.py
index 48654128..0b2ed20d 100644
--- a/src/View/RunSolver/Window.py
+++ b/src/View/RunSolver/Window.py
@@ -20,6 +20,8 @@ import os
import logging
import tempfile
+import numpy as np
+
from queue import Queue
from tools import trace, timer, logger_exception
@@ -40,7 +42,7 @@ from PyQt5.QtWidgets import (
QFileDialog, QTableView, QAbstractItemView,
QUndoStack, QShortcut, QAction, QItemDelegate,
QComboBox, QVBoxLayout, QHeaderView, QTabWidget,
- QTextEdit,
+ QTextEdit, QProgressBar, QCheckBox,
)
from View.RunSolver.Log.Window import SolverLogFileWindow
@@ -57,6 +59,63 @@ _translate = QCoreApplication.translate
logger = logging.getLogger()
+class EnsembleRunNumberDialog(PamhyrDialog):
+ _pamhyr_ui = "EnsembleRunNumberDialog"
+ _pamhyr_name = "EnsembleRunNumber"
+
+ def __init__(self, trad=None, parent=None):
+ super(EnsembleRunNumberDialog, self).__init__(
+ title=trad[self._pamhyr_name],
+ trad=trad,
+ options=[],
+ parent=parent
+ )
+
+ self._function = "Random"
+ self._number = 100
+ self._seed = -1
+
+ self._init_default_values()
+ self._init_connection()
+
+ def _init_default_values(self):
+ self.combobox_add_items("comboBox", ["Random"])
+ self.set_combobox_text("comboBox", "Random")
+
+ self.set_spin_box("spinBox", 100)
+
+ def _init_connection(self):
+ self._checkbox = self.find(QCheckBox, f"checkBox")
+ self._checkbox.clicked.connect(self._set_random_seed_spin_box_state)
+
+ def _set_random_seed_spin_box_state(self):
+ self.set_spin_box_enable("spinBox_seed", self._checkbox.isChecked())
+
+ @property
+ def number(self):
+ return self._number
+
+ @property
+ def seed(self):
+ return self._seed
+
+ @property
+ def function(self):
+ return self._function
+
+ def accept(self):
+ self._function = self.get_combobox_text("comboBox")
+ self._number = self.get_spin_box("spinBox")
+
+ if self._checkbox.isChecked():
+ self._seed = self.get_spin_box("spinBox_seed")
+
+ super().accept()
+
+ def reject(self):
+ self.close()
+
+
class SelectSolverWindow(PamhyrDialog):
_pamhyr_ui = "SelectSolver"
_pamhyr_name = "Select solver"
@@ -427,3 +486,233 @@ class SolverLogWindow(PamhyrWindow):
parent=self,
)
log.show()
+
+
+class SolverLogEnsWindow(SolverLogWindow):
+ _pamhyr_ui = "SolverLogEnsemble"
+ _pamhyr_name = "Solver Log Ensemble"
+
+ def __init__(self, number_of_run=100,
+ seed=-1, function="Random",
+ study=None, config=None,
+ solver=None, parent=None):
+ self._number_of_run = number_of_run
+ self._last_run = 0
+
+ if seed != -1:
+ np.random.seed(seed)
+
+ self._seed = np.random.get_state()[1][0]
+ logger.info(
+ "solver: Set ensemble with " +
+ f"{number_of_run} {function} and " +
+ f"{self._seed} as random seed"
+ )
+
+ self._samples = {}
+ for ens in study.river._ensembles.lst:
+ if ens.distribution is None:
+ continue
+
+ self._samples[ens] = ens.random(self._number_of_run)
+
+ self._results = None
+ self._run_samples = []
+ self._results_tables = {}
+
+ super(SolverLogEnsWindow, self).__init__(
+ solver=solver,
+ study=study,
+ config=config,
+ parent=parent,
+ )
+
+ def setup_connections(self):
+ self.setup_progress_bar()
+
+ super(SolverLogEnsWindow, self).setup_connections()
+
+ def setup_progress_bar(self):
+ self._progress_bar = self.find(QProgressBar, "progressBar")
+ self._progress_bar.setRange(0, self._number_of_run)
+ self._progress_bar.setValue(0)
+
+ def _log_int(self, int_code, color=None):
+ logger.info(f"solver: Returns {int_code}")
+ color = "blue" if int_code == 0 else "red"
+
+ self.find(QTextEdit, "textEdit")\
+ .append(
+ f"" +
+ f" *** Finished with code {int_code}" +
+ ""
+ )
+
+ self.statusbar.showMessage(
+ ("Done" if int_code == 0 else "Failed")
+ + f" #{self._last_run}",
+ 3000
+ )
+
+ self._solver.results_ensemble(
+ self._study, self._workdir,
+ self._results_tables,
+ self._last_run,
+ qlog=self._output,
+ )
+
+ if self._last_run < self._number_of_run:
+ self.next()
+ else:
+ self._results = self.fill_results_from_tables()
+ self._parent.set_results(self._solver, self._results)
+
+ self._progress_bar.setValue(self._last_run)
+
+ def fill_results_from_tables(self):
+ tables = self._results_tables
+ results = self._solver.get_ensemble_empty_results(
+ self._study, self._workdir
+ )
+
+ table = {}
+
+ for key in ["z", "q"]:
+ for func in ["min", "mean", "max", "std_derivation"]:
+ name = f"{key}_{func}"
+ table[name] = results.new_table_data(name, tables[name])
+
+ table["samples"] = results.new_table_data(
+ "samples", np.array(self._run_samples)
+ )
+ results.set("number_of_run", self._number_of_run)
+ results.set("seed", self._seed) # Keep random seed
+
+ results.set("table", table)
+
+ # Compute global index for reaches and profiles
+ gi = 0
+ for reach in results.river.reachs:
+ gi_start = gi
+
+ for profile in reach.profiles:
+ gi += 1
+
+ reach.set_global_index(range(gi_start, gi))
+
+ # Compute timestamps index table
+ ts = tables["timestamps"]
+ ts_index = {}
+ for i, t in enumerate(sorted(ts)):
+ ts_index[t] = i
+
+ results.set("timestamps", ts)
+ results.set_timestamp_index(ts_index)
+
+ return results
+
+ def next(self):
+ self._process = self.new_process(self._parent)
+
+ self._log(f" *** Run #{self._last_run}", color="blue")
+
+ if self._solver._type in ["mage8"]:
+ samples = self._solver.export_ensemble_variation(
+ self._study, self._workdir, qlog=self._output,
+ samples=self._samples,
+ )
+ else:
+ samples = self.ensemble_variation()
+ self._solver.export(
+ self._study, self._workdir, qlog=self._output
+ )
+
+ # Keep samples generators results
+ self._run_samples.append(samples)
+
+ self._solver.run(
+ self._study,
+ process=self._process,
+ output_queue=self._output
+ )
+
+ self.set_running_button_status()
+
+ self._last_run += 1
+ self._progress_bar.setValue(self._last_run - 1)
+
+ def ensemble_variation(self):
+ samples = []
+
+ for ens in self._samples:
+ if ens.target_data is None:
+ continue
+
+ x = next(self._samples[ens])
+ samples.append(x)
+
+ self._log(f"*** Variation #{self._last_run} " +
+ f"'{ens.target_data}' - {ens.data_type} x {x}",
+ color="blue")
+
+ if ens.data_type == "strickler_minor":
+ ens.target_data.minor = x
+ elif ens.data_type == "strickler_medium":
+ ens.target_data.medium = x
+
+ return samples
+
+ def run(self):
+ self._log(f" *** Run solver {self._solver.name}", color="blue")
+ self.next()
+
+ def start(self):
+ if self._solver.is_stoped():
+ self._log(f" *** Export study {self._solver.name}", color="blue")
+
+ ok = self._solver.export(
+ self._study, self._workdir, qlog=self._output
+ )
+
+ if not ok:
+ self._log(f" *** Failed to export", color="red")
+ self.update()
+ return
+ else:
+ self.update()
+ self._process = self.new_process(self._parent)
+
+ self._log(" *** Start", color="blue")
+ self._results = None
+
+ self.next()
+
+ def set_running_button_status(self):
+ self.find(QAction, "action_start").setEnabled(False)
+ if _signal:
+ self.find(QAction, "action_pause").setEnabled(True)
+ else:
+ self.find(QAction, "action_pause").setEnabled(False)
+ self.find(QAction, "action_stop").setEnabled(True)
+ self.find(QAction, "action_log_file").setEnabled(False)
+ self.find(QAction, "action_results").setEnabled(False)
+
+ def stop(self):
+ self._log(" *** Stop", color="blue")
+ self._solver.kill()
+
+ self.find(QAction, "action_start").setEnabled(True)
+ self.find(QAction, "action_pause").setEnabled(False)
+ self.find(QAction, "action_stop").setEnabled(False)
+ self.find(QAction, "action_results").setEnabled(True)
+ if self._solver.log_file() != "":
+ self.find(QAction, "action_log_file").setEnabled(True)
+
+ def results(self):
+ if self._results is None:
+ return
+
+ self._parent.set_results(self._solver, self._results)
+ self._parent.open_solver_results(self._solver, self._results)
+
+ self._solver.has_results_loaded()
diff --git a/src/View/Scenarios/ContextMenu.py b/src/View/Scenarios/ContextMenu.py
index 1a733082..922b543b 100644
--- a/src/View/Scenarios/ContextMenu.py
+++ b/src/View/Scenarios/ContextMenu.py
@@ -47,11 +47,19 @@ class AbstractMenu(object):
class DefaultMenu(AbstractMenu):
def run(self):
+ current_scenario = self._graph.status.scenario
+
+ if current_scenario.is_ensemble():
+ return
+
add_node = self._menu.addAction(self._trad["menu_add_scenario"])
+ ensemble = self._menu.addAction(self._trad["menu_ens_scenario"])
action = self._exec()
if action == add_node:
self._parent.new_scenario(self._pos)
+ if action == ensemble:
+ self._parent.create_new_ensemble_scenario(self._pos)
class ScenarioMenu(AbstractMenu):
@@ -61,9 +69,13 @@ class ScenarioMenu(AbstractMenu):
current_scenario = item.graph._study.status.scenario.id
- select = self._menu.addAction(self._trad["menu_select_scenario"])
+ select = None
duplicate = None
delete = None
+ edit_ens = None
+
+ if len(scenarios) > 1:
+ select = self._menu.addAction(self._trad["menu_select_scenario"])
if item.scenario.id != 0:
if scenarios.is_leaf(item.scenario):
@@ -74,6 +86,11 @@ class ScenarioMenu(AbstractMenu):
self._trad["menu_dup_scenario"]
)
+ if item.scenario.is_ensemble():
+ edit_ens = self._menu.addAction(
+ self._trad["menu_edit_ens_scenario"]
+ )
+
action = self._exec()
if action is None:
return
@@ -83,3 +100,5 @@ class ScenarioMenu(AbstractMenu):
self._parent.delete_scenario(item)
elif action == duplicate:
self._parent.duplicate_scenario(item)
+ elif action == edit_ens:
+ self._parent.edit_ensemble_scenario(item)
diff --git a/src/View/Scenarios/GraphWidget.py b/src/View/Scenarios/GraphWidget.py
index 9756a8f0..0b76198c 100644
--- a/src/View/Scenarios/GraphWidget.py
+++ b/src/View/Scenarios/GraphWidget.py
@@ -100,7 +100,11 @@ class ScenarioItem(QGraphicsTextItem):
)
)
painter.setBrush(QBrush(color))
- painter.drawEllipse(self.boundingRect())
+
+ if self.scenario.is_ensemble():
+ painter.drawRect(self.boundingRect())
+ else:
+ painter.drawEllipse(self.boundingRect())
super(ScenarioItem, self).paint(painter, option, widget)
@@ -526,6 +530,51 @@ class GraphWidget(QGraphicsView):
self.exec_with_waiting_window(fn, "duplicate_scenario")
self.changeScenario.emit(self.sender())
+ def create_ensemble_scenario(self, item):
+ must_save = self.dialog_save()
+ if must_save == "Cancel":
+ return
+
+ def fn():
+ self._close_other_window()
+
+ if must_save == "Save":
+ self._study.save()
+
+ self._undo.push(
+ CreateEnsembleScenarioCommand(
+ self._study,
+ )
+ )
+
+ self.exec_with_waiting_window(fn, "create_ensemble_scenario")
+ self.changeScenario.emit(self.sender())
+
+ def create_new_ensemble_scenario(self, pos):
+ must_save = self.dialog_save()
+ if must_save == "Cancel":
+ return
+
+ def fn():
+ self._close_other_window()
+
+ if must_save == "Save":
+ self._study.save()
+
+ self._undo.push(
+ CreateEnsembleScenarioCommand(
+ self._study, pos=pos,
+ )
+ )
+
+ self.exec_with_waiting_window(fn, "create_ensemble_scenario")
+ self.changeScenario.emit(self.sender())
+
+ def edit_ensemble_scenario(self, item):
+ self.parent\
+ .parent\
+ .open_ensemble_scenario()
+
def _close_other_window(self):
self.parent\
.parent\
diff --git a/src/View/Scenarios/UndoCommand.py b/src/View/Scenarios/UndoCommand.py
index dd6631e7..0ec65788 100644
--- a/src/View/Scenarios/UndoCommand.py
+++ b/src/View/Scenarios/UndoCommand.py
@@ -98,6 +98,26 @@ class DuplicateScenariosCommand(QUndoCommand):
self._study.reload_from_scenario(self._new)
+class CreateEnsembleScenarioCommand(QUndoCommand):
+ def __init__(self, study, pos=None):
+ QUndoCommand.__init__(self)
+
+ self._study = study
+ self._new = None
+ self._pos = pos
+
+ def undo(self):
+ self._study.scenarios.delete(self._new.id)
+ self._study.reload_from_scenario(self._new.parent)
+
+ def redo(self):
+ if self._new is None:
+ self._new = self._study.create_ensemble_scenario(self._pos)
+ else:
+ self._new.set_as_not_deleted()
+ self._study.reload_from_scenario(self._new)
+
+
class SetCommand(QUndoCommand):
def __init__(self, scenario, column, new_value):
QUndoCommand.__init__(self)
diff --git a/src/View/Scenarios/translate.py b/src/View/Scenarios/translate.py
index 8c7f0c9d..9338e663 100644
--- a/src/View/Scenarios/translate.py
+++ b/src/View/Scenarios/translate.py
@@ -43,6 +43,12 @@ class ScenariosTranslate(MainTranslate):
self._dict["menu_dup_scenario"] = _translate(
"Scenarios", "Duplicate this scenario"
)
+ self._dict["menu_ens_scenario"] = _translate(
+ "Scenarios", "Create ensemble scenario from current scenario"
+ )
+ self._dict["menu_edit_ens_scenario"] = _translate(
+ "Scenarios", "Edit this ensemble scenario"
+ )
self._sub_dict["table_headers_scenarios"] = {
# "id": self._dict['id'],
diff --git a/src/View/Tools/ASubWindow.py b/src/View/Tools/ASubWindow.py
index 01f2cd25..5558a742 100644
--- a/src/View/Tools/ASubWindow.py
+++ b/src/View/Tools/ASubWindow.py
@@ -107,7 +107,7 @@ class WindowToolKit(object):
dialog = QFileDialog(self, options=options)
- if select_file == "Existing_file":
+ if select_file == "ExistingFile":
mode = QFileDialog.FileMode.ExistingFile
elif select_file == "Directory":
mode = QFileDialog.FileMode.Directory
@@ -390,6 +390,18 @@ class ASubWindowFeatures(object):
"""
return self.find(QSpinBox, name).value()
+ def set_spin_box_enable(self, name: str, value: bool):
+ """Set status of spinbox component
+
+ Args:
+ name: The spinbox component name
+ value: The boolean value
+
+ Returns:
+ Nothing
+ """
+ self.find(QSpinBox, name).setEnabled(value)
+
def set_double_spin_box(self, name: str, value: int):
"""Set value of spinbox component
diff --git a/src/View/Tools/PamhyrPlot.py b/src/View/Tools/PamhyrPlot.py
index 0aebaaba..1c77cd8e 100644
--- a/src/View/Tools/PamhyrPlot.py
+++ b/src/View/Tools/PamhyrPlot.py
@@ -42,6 +42,12 @@ class PamhyrPlot(APlot):
color_plot_river_bottom = "grey"
color_plot_river_water = "blue"
color_plot_river_water_zone = "skyblue"
+
+ color_plot_ensemble_min = "green"
+ color_plot_ensemble_mean = "black"
+ color_plot_ensemble_max = "red"
+ color_plot_ensemble_std_deriv = "black"
+
colors = list(mplcolors.TABLEAU_COLORS)
linestyle = ['solid', 'dashed', 'dashdot', 'dotted']
diff --git a/src/View/Tools/PamhyrTree.py b/src/View/Tools/PamhyrTree.py
new file mode 100644
index 00000000..74dbb030
--- /dev/null
+++ b/src/View/Tools/PamhyrTree.py
@@ -0,0 +1,113 @@
+# PamhyrTree.py -- Pamhyr abstract table model
+# Copyright (C) 2023-2026 INRAE
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# -*- coding: utf-8 -*-
+
+import logging
+import traceback
+
+from tools import trace, timer
+
+from Model.Except import NotImplementedMethodeError
+
+from PyQt5.QtCore import (
+ Qt, QVariant, QAbstractItemModel,
+ QCoreApplication, QModelIndex, pyqtSlot,
+)
+
+from PyQt5.QtWidgets import (
+ QTreeWidget, QTreeWidgetItem,
+)
+
+logger = logging.getLogger()
+
+
+class PamhyrModelItem(QTreeWidgetItem):
+ def __init__(self, data=None, trad=None, parent=None):
+ self._data = data
+ self._name = self._get_name()
+
+ super(PamhyrModelItem, self).__init__(
+ parent, [self._name],
+ )
+
+ self._setup_children()
+
+ def _get_name(self):
+ return str(self._data)
+
+ def _setup_children(self):
+ return
+
+ def get_value(self):
+ return self._data
+
+ def find_item_by_value(self, data):
+ if self.get_value() == data:
+ return self
+
+ for i in range(self.childCount()):
+ item = self.child(i)
+ if item.get_value() == data:
+ return item
+
+ found = item.find_item_by_value(data)
+ if found:
+ return found
+
+ return None
+
+
+class PamhyrModelListItem(QTreeWidgetItem):
+ def __init__(self, name=None, lst=None,
+ child_item_ctor=PamhyrModelItem,
+ trad=None, parent=None):
+ super(PamhyrModelListItem, self).__init__(
+ parent, [name],
+ )
+
+ self._name = name
+ self._lst = lst
+ self._trad = trad
+ self._child_item_ctor = child_item_ctor
+
+ self._setup_children()
+
+ def _setup_children(self):
+ for el in self._lst.lst:
+ self.addChild(
+ self._child_item_ctor(
+ data=el, trad=self._trad, parent=self
+ )
+ )
+
+ def get_value(self):
+ return None
+
+ def find_item_by_value(self, data):
+ if self.get_value() == data:
+ return self
+
+ for i in range(self.childCount()):
+ item = self.child(i)
+ if item.get_value() == data:
+ return item
+
+ found = item.find_item_by_value(data)
+ if found:
+ return found
+
+ return None
diff --git a/src/View/Translate.py b/src/View/Translate.py
index 0a5ce138..1874f1be 100644
--- a/src/View/Translate.py
+++ b/src/View/Translate.py
@@ -40,6 +40,12 @@ class CommonWordTranslate(PamhyrTranslate):
self._dict["time"] = _translate("CommonWord", "Time")
self._dict["date"] = _translate("CommonWord", "Date")
+ self._dict["low"] = _translate("CommonWord", "Low")
+ self._dict["high"] = _translate("CommonWord", "High")
+
+ self._dict["loc"] = _translate("CommonWord", "Loc")
+ self._dict["scale"] = _translate("CommonWord", "Scale")
+
self._dict["reach"] = _translate("CommonWord", "Reach")
self._dict["reaches"] = _translate("CommonWord", "Reaches")
self._dict["Select reach"] = _translate("CommonWord", "Select reach")
@@ -65,6 +71,9 @@ class CommonWordTranslate(PamhyrTranslate):
self._dict["file_csv"] = _translate(
"CommonWord", "CSV file (*.csv)"
)
+ self._dict["file_json"] = _translate(
+ "CommonWord", "JSON file (*.json)"
+ )
class UnitTranslate(CommonWordTranslate):
@@ -103,6 +112,12 @@ class UnitTranslate(CommonWordTranslate):
self._dict["unit_water_elevation"] = _translate(
"Unit", "Water Elevation (m)"
)
+ self._dict["unit_mean_water_elevation"] = _translate(
+ "Unit", "Mean water elevation (m)"
+ )
+ self._dict["unit_std_deriv_elevation"] = _translate(
+ "Unit", "Elevation standard derivation (m)"
+ )
self._dict["unit_water_elevation_envelop"] = _translate(
"Unit", "Water Elevation Envelop (m)"
)
@@ -122,8 +137,16 @@ class UnitTranslate(CommonWordTranslate):
self._dict["unit_min_velocity"] = _translate(
"Unit", "Min Velocity (m/s)"
)
- self._dict["unit_discharge"] = _translate(
- "Unit", "Discharge") + f" (m³/s)"
+ self._dict["unit_discharge"] = (
+ _translate("Unit", "Discharge") + f" (m³/s)"
+ )
+ self._dict["unit_mean_discharge"] = (
+ _translate("Unit", "Mean discharge") + f" (m³/s)"
+ )
+ self._dict["unit_std_deriv_discharge"] = (
+ _translate("Unit", "Discharge standard derivation")
+ + f" (m³/s)"
+ )
self._dict["unit_discharge_envelop"] = _translate(
"Unit", "Discharge Envelop") + " (m³/s)"
self._dict["unit_max_discharge"] = _translate(
diff --git a/src/View/ui/CustomExportDialog.ui b/src/View/ui/CustomExportDialog.ui
new file mode 100644
index 00000000..dc287a81
--- /dev/null
+++ b/src/View/ui/CustomExportDialog.ui
@@ -0,0 +1,111 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 418
+ 220
+
+
+
+ Dialog
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+
-
+
+
+ X axis:
+
+
+
+
+
+
+
+ -
+
+
+ Y axis:
+
+
+
+
+
+
+
+ -
+
+
+ Solver:
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+ -
+
+
+ TextLabel
+
+
+
+
+
+
+
+
+ buttonBox
+ rejected()
+ Dialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+ buttonBox
+ accepted()
+ Dialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+
diff --git a/src/View/ui/DebugTimer.ui b/src/View/ui/DebugTimer.ui
index cc8a2132..151fa718 100644
--- a/src/View/ui/DebugTimer.ui
+++ b/src/View/ui/DebugTimer.ui
@@ -18,6 +18,13 @@
-
+ -
+
+
+ TextLabel
+
+
+