diff --git a/src/Model/Ensembles/Ensemble.py b/src/Model/Ensembles/Ensemble.py
new file mode 100644
index 00000000..fe851a39
--- /dev/null
+++ b/src/Model/Ensembles/Ensemble.py
@@ -0,0 +1,176 @@
+# Ensemble.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 struct
+import logging
+import itertools
+
+from Model.Tools.PamhyrDB import SQLSubModel
+from Model.Scenario import Scenario
+
+logger = logging.getLogger()
+
+
+class Ensemble(SQLSubModel):
+ _sub_classes = []
+
+ def __init__(self, id: int = -1,
+ name: str = "",
+ prob_dist="generic",
+ function="generic",
+ range=[],
+ data_pid=-1,
+ status=None, owner_scenario=-1):
+ super(Ensemble, self).__init__(
+ id=id, status=status,
+ owner_scenario=owner_scenario
+ )
+
+ self._name = name
+ self._type = "generic"
+ self._prob_dist = prob_dist
+ self._function = function
+ self._range = range
+ self._data_pid = data_pid
+
+ @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,
+ prob_dist VARCHAR(8) NOT NULL,
+ function VARCHAR(8) NOT NULL,
+ range BLOB NOT NULL,
+ range_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"]
+ loaded = data['loaded_pid']
+
+ table = execute(
+ "SELECT pamhyr_id, deleted, name, type, " +
+ "prob_dist, function, range, range_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)
+ type = next(it)
+ prob_dist = next(it)
+ function = next(it)
+ range = next(it)
+ range_len = next(it)
+ data_pid = next(it)
+ owner_scenario = next(it)
+
+ rdata = cls._decode_range(range, range_len)
+
+ new_ensemble = cls(
+ id, name=name,
+ prob_dist=prob_dist,
+ function=function,
+ range=rdata,
+ 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}"
+ )
+
+ brange, length = self._encode_range()
+
+ execute(
+ "INSERT INTO " +
+ "ensemble(pamhyr_id, deleted, name, type, " +
+ " prob_dist, function, range, range_len, " +
+ " data_pid, scenario) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ self.pamhyr_id, self.is_deleted(),
+ self._name, self._type,
+ self._prob_dist, self._function, brange, length,
+ self._data_pid, self._status.scenario_id
+ )
+
+ 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 _encode_range(self):
+ length = len(self._range)
+ data_format = ">" + ''.join(itertools.repeat("d", length))
+
+ return struct.pack(data_format, *self._range), length
+
+ @classmethod
+ def _decode_range(cls, brange, length):
+ data_format = ">" + ''.join(itertools.repeat("d", length))
+
+ return struct.unpack(data_format, brange)
diff --git a/src/Model/Ensembles/EnsembleList.py b/src/Model/Ensembles/EnsembleList.py
new file mode 100644
index 00000000..8ebbe94c
--- /dev/null
+++ b/src/Model/Ensembles/EnsembleList.py
@@ -0,0 +1,57 @@
+# EnsembleList.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 copy
+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}"
+ )
+
+ if data is None:
+ data = {}
+
+ 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
diff --git a/src/Model/River.py b/src/Model/River.py
index ce16b7ea..27a2579a 100644
--- a/src/Model/River.py
+++ b/src/Model/River.py
@@ -60,6 +60,7 @@ from Model.LateralContributionsAdisTS.LateralContributionsAdisTSList \
from Model.D90AdisTS.D90AdisTSList import D90AdisTSList
from Model.DIFAdisTS.DIFAdisTSList import DIFAdisTSList
from Model.GeoTIFF.GeoTIFFList import GeoTIFFList
+from Model.Ensembles.EnsembleList import EnsembleList
from Model.Results.Results import Results
logger = logging.getLogger()
@@ -476,6 +477,7 @@ class River(Graph):
D90AdisTSList,
DIFAdisTSList,
GeoTIFFList,
+ EnsembleList,
Results
]
@@ -515,6 +517,9 @@ class River(Graph):
self._geotiff = GeoTIFFList(status=self._status)
+ # Scenario ensemble
+ self._ensembles = EnsembleList(status=self._status)
+
self._results = {}
@classmethod
@@ -629,6 +634,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):
@@ -664,6 +671,8 @@ class River(Graph):
objs.append(self._geotiff)
+ objs.append(self._ensembles)
+
for solv_type in self.results:
objs.append(self.results[solv_type])
@@ -740,7 +749,7 @@ class River(Graph):
self._BoundaryConditionsAdisTS,
self._LateralContributionsAdisTS,
self._D90AdisTS, self._DIFAdisTS,
- self._geotiff,
+ self._geotiff, self._ensembles,
]
for solver in self._parameters:
diff --git a/src/Model/Study.py b/src/Model/Study.py
index ded212ba..8b80efb8 100644
--- a/src/Model/Study.py
+++ b/src/Model/Study.py
@@ -46,7 +46,7 @@ logger = logging.getLogger()
class Study(SQLModel):
- _version = "0.2.7"
+ _version = "0.2.8"
_sub_classes = [
Scenario,