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 + + + diff --git a/src/View/ui/Ensemble1ParamsDialog.ui b/src/View/ui/Ensemble1ParamsDialog.ui new file mode 100644 index 00000000..544abe3d --- /dev/null +++ b/src/View/ui/Ensemble1ParamsDialog.ui @@ -0,0 +1,85 @@ + + + Dialog + + + + 0 + 0 + 416 + 81 + + + + Dialog + + + + + + + + @param0 + + + + + + + -100000.000000000000000 + + + 100000.000000000000000 + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/View/ui/Ensemble2ParamsDialog.ui b/src/View/ui/Ensemble2ParamsDialog.ui new file mode 100644 index 00000000..138067bb --- /dev/null +++ b/src/View/ui/Ensemble2ParamsDialog.ui @@ -0,0 +1,106 @@ + + + Dialog + + + + 0 + 0 + 416 + 81 + + + + Dialog + + + + + + + + @param0 + + + + + + + -100000.000000000000000 + + + 100000.000000000000000 + + + + + + + + + + + @param1 + + + + + + + -100000.000000000000000 + + + 100000.000000000000000 + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/View/ui/EnsembleEditDialog.ui b/src/View/ui/EnsembleEditDialog.ui new file mode 100644 index 00000000..58b596b0 --- /dev/null +++ b/src/View/ui/EnsembleEditDialog.ui @@ -0,0 +1,157 @@ + + + Dialog + + + + 0 + 0 + 875 + 415 + + + + Dialog + + + + + + Name + + + + + + + + + + Distribution + + + + + + + + + + Qt::Horizontal + + + + 313 + 20 + + + + + + + + Target data + + + + + + + 1 + + + + + + + + + + + Parameters + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/View/ui/EnsembleRunNumberDialog.ui b/src/View/ui/EnsembleRunNumberDialog.ui new file mode 100644 index 00000000..578b22e6 --- /dev/null +++ b/src/View/ui/EnsembleRunNumberDialog.ui @@ -0,0 +1,123 @@ + + + Dialog + + + + 0 + 0 + 400 + 152 + + + + Dialog + + + + + + + + Draw function + + + + + + + + + + + + + + Number of run + + + + + + + 1 + + + 1000000 + + + 100 + + + + + + + + + + + Custom seed + + + + + + + false + + + 2147483647 + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/View/ui/Ensembles.ui b/src/View/ui/Ensembles.ui new file mode 100644 index 00000000..6de49991 --- /dev/null +++ b/src/View/ui/Ensembles.ui @@ -0,0 +1,112 @@ + + + MainWindow + + + + 0 + 0 + 800 + 600 + + + + MainWindow + + + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + + + + ressources/add.pngressources/add.png + + + Add + + + Add ensemble + + + QAction::NoRole + + + + + + ressources/del.pngressources/del.png + + + Delete + + + Delete selected ensembles + + + QAction::NoRole + + + + + + ressources/edit.pngressources/edit.png + + + Edit + + + Edit ensembles range + + + QAction::NoRole + + + + + + ressources/export.pngressources/export.png + + + Export + + + Export all ensemble configuration into json file + + + + + + ressources/import.pngressources/import.png + + + Import + + + Import ensemble configuration from json file + + + + + + diff --git a/src/View/ui/ResultsEnsemble.ui b/src/View/ui/ResultsEnsemble.ui new file mode 100644 index 00000000..8d345fcc --- /dev/null +++ b/src/View/ui/ResultsEnsemble.ui @@ -0,0 +1,232 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + MainWindow + + + + + + + + 874 + 0 + + + + + + + Qt::Horizontal + + + + Qt::Vertical + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ressources/media-skip-backward.pngressources/media-skip-backward.png + + + + + + + + + + + ressources/media-seek-backward.pngressources/media-seek-backward.png + + + + + + + true + + + true + + + Qt::DefaultContextMenu + + + true + + + + + + + ressources/media-playback-start.png + ressources/media-playback-pause.pngressources/media-playback-start.png + + + true + + + true + + + + + + + + + + + ressources/media-seek-forward.pngressources/media-seek-forward.png + + + + + + + + + + + ressources/media-skip-forward.pngressources/media-skip-forward.png + + + + + + + Qt::Horizontal + + + + + + + + + 0 + + + true + + + + Water elevation + + + + + + + + + + Discharge time series + + + + + + + + + + + + + + + + + + + 0 + 0 + 1280 + 24 + + + + + + + true + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + ressources/export.pngressources/export.png + + + Export + + + Export data as CSV + + + Ctrl+E + + + + + + ressources/import.pngressources/import.png + + + Import data + + + Import data from SCV file + + + + + + diff --git a/src/View/ui/SolverLogEnsemble.ui b/src/View/ui/SolverLogEnsemble.ui new file mode 100644 index 00000000..f3a60c2c --- /dev/null +++ b/src/View/ui/SolverLogEnsemble.ui @@ -0,0 +1,123 @@ + + + MainWindow + + + + 0 + 0 + 1152 + 648 + + + + MainWindow + + + + + + + + Monospace + 10 + + + + + + + + + + false + + + true + + + + + + + 24 + + + + + + + + + 0 + 0 + 1152 + 24 + + + + + + + true + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + + + ressources/close.pngressources/close.png + + + Stop + + + + + + ressources/run.pngressources/run.png + + + Start + + + + + + ressources/player_pause.pngressources/player_pause.png + + + Pause + + + + + + ressources/zoom.pngressources/zoom.png + + + LogFile + + + + + results + + + + + + diff --git a/src/View/ui/Widgets/Ensemble1ParamsWidget.ui b/src/View/ui/Widgets/Ensemble1ParamsWidget.ui new file mode 100644 index 00000000..8b9c2738 --- /dev/null +++ b/src/View/ui/Widgets/Ensemble1ParamsWidget.ui @@ -0,0 +1,42 @@ + + + Form + + + + 0 + 0 + 482 + 48 + + + + Form + + + + + + + + Parameter 1 + + + + + + + -10000000000.000000000000000 + + + 10000000000.000000000000000 + + + + + + + + + + diff --git a/src/View/ui/Widgets/Ensemble2ParamsWidget.ui b/src/View/ui/Widgets/Ensemble2ParamsWidget.ui new file mode 100644 index 00000000..be1ea11d --- /dev/null +++ b/src/View/ui/Widgets/Ensemble2ParamsWidget.ui @@ -0,0 +1,55 @@ + + + Form + + + + 0 + 0 + 416 + 80 + + + + Form + + + + + + Parameter 1 + + + + + + + -10000000000.000000000000000 + + + 10000000000.000000000000000 + + + + + + + Parameter 2 + + + + + + + -10000000000.000000000000000 + + + 10000000000.000000000000000 + + + + + + + + diff --git a/tests_cases/Saar/Saar_ensemble.pamhyr b/tests_cases/Saar/Saar_ensemble.pamhyr new file mode 100644 index 00000000..392e6c86 Binary files /dev/null and b/tests_cases/Saar/Saar_ensemble.pamhyr differ