Compare commits

...
43 Commits
Author SHA1 Message Date
Dylan Jeannin 656b337499 RubarBE: notify when attempting to use RubarBE with solid boundary conditions missing 2026-10-14 19:14:51 +02:00
Dylan Jeannin ed2ed0ac2a RubarBE: Integration of the solver in the list 2026-10-11 20:33:25 +02:00
Dylan Jeannin ab95f06700 Rubar parameters: prohibit simultaneous selection of REGIME=1 and CONDAM=0 2026-10-08 10:12:26 +02:00
Dylan Jeannin 2a699f46ca Solver: revamp Rubar parameter interface and export validation 2026-10-07 17:11:27 +02:00
Dylan Jeannin 33ad91d1a6 Plot widget: Fix plot selection crash on mouse wheel events 2026-10-06 11:15:14 +02:00
Dylan Jeannin 7d2ef35128 Results Rubar3: fix opening results window from the solver log window 2026-10-05 16:19:41 +02:00
Dylan Jeannin 40b48a2080 Merge branch 'master' into dev_dylan 2026-10-05 09:55:57 +02:00
Theophile Terraz bddaad0193 debug mesher 2026-09-23 09:28:42 +02:00
Dylan Jeannin 49eb28056e Mage: fix missing $ markers in VAR rules and separate exported check valve entries 2026-09-18 03:10:21 +02:00
Dylan Jeannin ddb682dff6 Mage: fix missing $ markers in VAR rules and separate exported check valve entries 2026-09-18 03:09:30 +02:00
Dylan Jeannin 89c6e8bb29 Fix pep8 2026-09-16 13:56:51 +02:00
Dylan Jeannin d4c596a6fe Time/date: preserve calendar dates on start date changes + fix solver time exports with older format 2026-09-16 13:34:58 +02:00
Dylan Jeannin d15fa684b7 Time/date format: warning message for users when they change format to another start date 2026-09-15 11:34:20 +02:00
Dylan Jeannin 9879094661 Time/date format: back to relative timestamps storage 2026-09-15 11:33:04 +02:00
Dylan Jeannin 5ce5478bf4 Interpolated profiles: fix undo/redo misconnection of undo_stack between geometry and profile window, adding a new data in the profile model, common to both window 2026-09-11 18:48:45 +02:00
Dylan Jeannin 5fb36e7fbe BoundaryConditions: fix index error in deletion for boundary cond. 2026-09-11 16:51:37 +02:00
Dylan Jeannin 0dbea26a2e BoundaryConditions: fix index error in deletion for boundary cond. 2026-09-11 16:50:47 +02:00
Dylan Jeannin b0e0dc75f1 Fix pep8 2026-09-11 14:17:04 +02:00
Dylan Jeannin 318f6a4668 Geometry: undo/redo restores interpolated data in the model 2026-09-11 14:10:55 +02:00
Dylan Jeannin fdb5cd23e7 Translation: correct albedo display to water albedo, + update translation 2026-09-11 10:12:59 +02:00
Dylan Jeannin e064c1aee5 Interpolated profiles: modify an interpolated profile, or something relative to it in the model, will no longer considerate this profile as an interpolated one 2026-09-10 19:17:23 +02:00
Dylan Jeannin e0bc481ae2 Geometry: Orange color instead of red for interpolated profiles in the graph in geometry window 2026-09-10 15:18:59 +02:00
Dylan Jeannin 081e6e7322 Geometry:toggle interpolated profiles visibility in geometry table and plots 2026-09-10 13:56:46 +02:00
Dylan Jeannin a0d8b9ea43 Split reach : disabled endpoint and struture-linked profiles in reach split selector, instead of deleting them from the list 2026-09-09 13:54:01 +02:00
Dylan Jeannin d97620ad56 Replace missing reach warning with a reach selection dropdown list on opening geometry, frictions, sediment layers, and initial conditions 2026-09-09 10:31:22 +02:00
Dylan Jeannin 6d4dc3a22b Merge branch 'new_design_main_page' into dev_dylan 2026-09-08 17:31:54 +02:00
Dylan Jeannin 74d23386a7 Network: cleanest version of drag clic to move on the graph + network graph view only on the main tab (no more interaction on mouse hover) + graph on main tab 2026-09-08 17:31:01 +02:00
Dylan Jeannin 9f46bcd853 View main window: fix refresh network preview when selecting a reach 2026-09-08 15:42:04 +02:00
Dylan Jeannin bcc9dd1a0d View main window: keep zoom on the geographical graph after a change in the geometry 2026-09-08 14:02:53 +02:00
Dylan Jeannin fc40552d68 View main window: add a splitter to resize both network graph and geometry graph 2026-09-08 13:28:51 +02:00
Dylan Jeannin f41050fe5c Design main window: UI improvement for the graph tab, QBox, titles... 2026-09-07 17:37:52 +02:00
Dylan Jeannin 97f487c0af Design main window: Network graph is now connected with the edition window to refresh after every change 2026-09-07 17:06:24 +02:00
Dylan Jeannin 499e16975f Design main window: New tab with network of the study 2026-09-07 16:31:35 +02:00
Dylan Jeannin 75f3c07cd9 Solver: export the relative time for solver files, in the case of a study in date mode, which is the date mentionned in the data minus the study start date 2026-09-07 15:35:27 +02:00
Dylan Jeannin 0a0c00d877 Results with disabled profiles: fix a bug happening when we are looking at results, and we modify enable/disable some profiles at the same time. Now the plot uses the profiles stored with the results, and no more in the model 2026-09-07 13:48:17 +02:00
Dylan Jeannin 8d2c18b6af Timeseries: fix problem with copy/paste data in timeseries for the date mode 2026-09-07 12:10:45 +02:00
Dylan Jeannin 2d2f83436c Date/time: now save global timestamp in the model, instead of relative 2026-09-07 11:17:04 +02:00
Dylan Jeannin 71914fdf57 Merge branch 'master' of https://gitlab.com/pamhyr/pamhyr2 2026-09-06 23:15:24 +02:00
Dylan Jeannin 2d7409debb Export csv results: fix export flowrate as a function of time, on a study without sediments transport 2026-09-04 16:45:59 +02:00
Dylan Jeannin beece68498 SolverParameters: new combobox instead of index in the value field for critical shields, shields correction and solid capacity 2026-09-04 11:40:04 +02:00
parouby 92fb653676 Geometry: Point: Try to create index at each update. 2026-09-04 11:30:30 +02:00
Dylan Jeannin d8e9d786d9 LateralContributionsAdisTS: correctly highlight the profiles in the interval + refacto function name 2026-09-03 16:29:32 +02:00
Dylan Jeannin 7ef305e724 Pollutants: ref to pol_name in the title of the lateral_contribution_pollutant 2026-09-03 13:00:53 +02:00
63 changed files with 2510 additions and 535 deletions

No files matched your search

+96 -60
View File
@@ -172,8 +172,8 @@ class InternalMeshing(AMeshingTool):
ltot1 = 0.0 ltot1 = 0.0
ltot2 = 0.0 ltot2 = 0.0
alpha = [] alpha = [0.0]
beta = [] beta = [0.0]
# remove names # remove names
sect2.points[start2].name = '' sect2.points[start2].name = ''
sect2.points[end2].name = '' sect2.points[end2].name = ''
@@ -196,67 +196,38 @@ class InternalMeshing(AMeshingTool):
else: # regular case else: # regular case
alpha = list(map(lambda x: x/ltot1, alpha)) # target ratios alpha = list(map(lambda x: x/ltot1, alpha)) # target ratios
beta = list(map(lambda x: x/ltot2, beta)) # current ratios beta = list(map(lambda x: x/ltot2, beta)) # current ratios
for i in range(len1 - len2): match = self._match_indices(alpha, beta)
beta.append(1.0)
beta.append(0.0) # beta[-1]
# loop on the points to insert
for k in range(len1-len2):
trouve = False
for j0 in range(len2):
if trouve:
break
undeplus = True
for j1 in range(j0, len2):
if undeplus:
if beta[j1] <= alpha[j1]:
undeplus = False
break
if undeplus:
if j0 == len2:
trouve = True
else:
j1 = j0+1
if beta[j0]-alpha[j0] > alpha[j1]-beta[j0]:
trouve = True
if trouve: insertions_per_segment = [] # liste de (index, [points])
for j in range(len2):
i_start = match[j]
i_end = match[j + 1]
q_start = sect2.point(start2 + j)
q_end = sect2.point(start2 + j + 1)
new_points = []
if i_end - i_start > 1:
u_start, u_end = alpha[i_start], alpha[i_end]
span = u_end - u_start
for k in range(i_start + 1, i_end):
ratio = (alpha[k] - u_start) / span # dans [0,1]
p = q_start.copy()
p.name = ''
p.x = (1.0-ratio) * q_start.x + ratio * q_end.x
p.y = (1.0-ratio) * q_start.y + ratio * q_end.y
p.z = (1.0-ratio) * q_start.z + ratio * q_end.z
new_points.append(p)
# à insérer après l'indice j
insertions_per_segment.append((j, new_points))
# Insertion en ordre décroissant de j
# pour ne pas perturber les indices
for j, new_points in reversed(insertions_per_segment):
for offset, p in enumerate(new_points):
sect2.insert_point(start2 + j + 1 + offset, p)
len2 += 1 len2 += 1
for i in reversed(range(j0, len2)):
beta[i+1] = beta[i]
if (beta[j0] == beta[j0-1]):
# rien
pass
else:
ratio = (alpha[j0] - beta[j0-1]) \
/ (beta[j0] - beta[j0-1])
if ratio < 0.0:
logger.warning(f"ratio négatif {ratio}")
# on double le point a gauche
p = sect2.point(start2+j0-1).copy()
sect2.insert_point(start2+j0-1, p)
sect2.point(start2+j0-1).name = ''
beta[j0] = beta[j0-1]
else:
p = sect2.point(start2+j0).copy()
sect2.insert_point(start2+j0, p)
sect2.point(start2+j0+1).name = ''
sect2.point(start2+j0+1).x = \
(1.0-ratio) * sect2.point(start2+j0).x + \
ratio * sect2.point(start2+j0+2).x
sect2.point(start2+j0+1).y = \
(1.0-ratio) * sect2.point(start2+j0).y + \
ratio * sect2.point(start2+j0+2).y
sect2.point(start2+j0+1).z = \
(1.0-ratio) * sect2.point(start2+j0).z + \
ratio * sect2.point(start2+j0+2).z
beta[j0] = (1.-ratio)*beta[j0-1] \
+ ratio*beta[j0+1]
if len1 > len2:
for i in range(len1 - len2):
p = sect2.point(start2+len2).copy()
sect2.insert_point(start2+len2, p)
sect2.point(start2+len2).name = ''
if tag1 != "un": if tag1 != "un":
sect2.point(start2).name = tag1 sect2.point(start2).name = tag1
if tag2 != "np": if tag2 != "np":
@@ -264,6 +235,71 @@ class InternalMeshing(AMeshingTool):
sect2.modified() sect2.modified()
def _match_indices(self, u_ref, v_target):
# appariement optimal (ordre croissant) par programmation dynamique
"""
Trouve, pour chaque v_target[j], un indice i_j dans u_ref tel que
i_0 < i_1 < ... < i_{m-1} et
Sum |u_ref[i_j] - v_target[j]| soit minimal.
Retourne la liste des indices i_j (longueur m),
avec i_0 = 0 et i_{m-1} = n-1 imposés
(les extrémités se correspondent forcément).
"""
n = len(u_ref)
m = len(v_target)
# Les extrémités sont fixées.
fixed_first, fixed_last = 0, n - 1
# DP sur les indices disponibles entre fixed_first+1 et fixed_last-1
# dp[j][i] = coût minimal pour apparier v_target[1..j] aux indices
# se terminant à l'indice i (i croissant strict), j de 1 à m-2.
if m == 2:
return [fixed_first, fixed_last]
# indices internes possibles
candidates = list(range(fixed_first + 1, fixed_last))
inner_targets = v_target[1:-1] # cibles internes (j = 1..m-2)
INF = float("inf")
# dp[j] : dict indice_candidat -> (coût_min, provenance)
dp = [{} for _ in range(len(inner_targets))]
for j, vt in enumerate(inner_targets):
for idx_pos, i in enumerate(candidates):
cost_here = abs(u_ref[i] - vt)
if j == 0:
# doit être > fixed_first
dp[j][i] = (cost_here, fixed_first)
else:
best = (INF, None)
for i_prev in candidates[:idx_pos]:
if i_prev in dp[j - 1]:
c = dp[j - 1][i_prev][0] + cost_here
if c < best[0]:
best = (c, i_prev)
if best[1] is not None:
dp[j][i] = best
# Reconstruction : on part du dernier niveau, on choisit le
# meilleur indice tel que la suite jusqu'à fixed_last
# reste croissante et valide.
last_level = dp[-1]
valid = {i: c for i, c in last_level.items() if i < fixed_last}
if not valid:
raise RuntimeError("Alignement impossible avec ces données.")
best_i = min(valid, key=lambda i: valid[i][0])
chain = [best_i]
for j in range(len(inner_targets) - 1, 0, -1):
prev_i = dp[j][chain[-1]][1]
chain.append(prev_i)
chain.reverse()
return [fixed_first] + chain + [fixed_last]
def update_rk(self, reach, begin_rk, end_rk, def update_rk(self, reach, begin_rk, end_rk,
step: float = 50, step: float = 50,
limites=[-1, -1], limites=[-1, -1],
@@ -531,7 +531,7 @@ class BoundaryConditionAdisTS(SQLSubModel):
new_0 = self._types[0](data[0].replace(",", ".")) new_0 = self._types[0](data[0].replace(",", "."))
new_1 = self._types[1](data[1].replace(",", ".")) new_1 = self._types[1](data[1].replace(",", "."))
return (new_0, new_1) return Data(new_0, new_1, types=self._types, status=self._status)
def add(self, index: int): def add(self, index: int):
value = Data( value = Data(
@@ -543,6 +543,9 @@ class BoundaryConditionAdisTS(SQLSubModel):
return value return value
def insert(self, index: int, value): def insert(self, index: int, value):
if value in self._data:
value.set_as_not_deleted()
else:
self._data.insert(index, value) self._data.insert(index, value)
self.modified() self.modified()
@@ -419,7 +419,7 @@ class BoundaryConditionTemperature(SQLSubModel):
new_0 = self._types[0](data[0].replace(",", ".")) new_0 = self._types[0](data[0].replace(",", "."))
new_1 = self._types[1](data[1].replace(",", ".")) new_1 = self._types[1](data[1].replace(",", "."))
return (new_0, new_1) return Data(new_0, new_1, types=self._types, status=self._status)
def add(self, index: int): def add(self, index: int):
value = Data( value = Data(
@@ -431,6 +431,9 @@ class BoundaryConditionTemperature(SQLSubModel):
return value return value
def insert(self, index: int, value): def insert(self, index: int, value):
if value in self._data:
value.set_as_not_deleted()
else:
self._data.insert(index, value) self._data.insert(index, value)
self.modified() self.modified()
+3
View File
@@ -55,8 +55,11 @@ class Point(SQLSubModel):
@name.setter @name.setter
def name(self, name): def name(self, name):
changed = self._name != name
self._name = name self._name = name
self.modified() self.modified()
if changed and self._profile is not None:
self._profile.content_modified()
def is_named(self): def is_named(self):
""" """
+11 -3
View File
@@ -95,10 +95,9 @@ class PointXYZ(Point):
"ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE" "ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE"
) )
if major == "0" and minor == "2": # Try to create index at each update
if int(release) < 5:
execute( execute(
"CREATE INDEX idx_point " + "CREATE INDEX IF NOT EXISTS idx_point " +
"ON geometry_pointXYZ(profile, scenario);" "ON geometry_pointXYZ(profile, scenario);"
) )
@@ -329,8 +328,11 @@ class PointXYZ(Point):
@x.setter @x.setter
def x(self, value): def x(self, value):
changed = self._x != float(value)
self._x = float(value) self._x = float(value)
self.modified() self.modified()
if changed and self._profile is not None:
self._profile.content_modified()
@property @property
def y(self): def y(self):
@@ -338,8 +340,11 @@ class PointXYZ(Point):
@y.setter @y.setter
def y(self, value): def y(self, value):
changed = self._y != float(value)
self._y = float(value) self._y = float(value)
self.modified() self.modified()
if changed and self._profile is not None:
self._profile.content_modified()
@property @property
def z(self): def z(self):
@@ -347,8 +352,11 @@ class PointXYZ(Point):
@z.setter @z.setter
def z(self, value): def z(self, value):
changed = self._z != float(value)
self._z = float(value) self._z = float(value)
self.modified() self.modified()
if changed and self._profile is not None:
self._profile.content_modified()
def shift(self, x, y, z): def shift(self, x, y, z):
self.x += x self.x += x
+54 -10
View File
@@ -47,6 +47,8 @@ class Profile(object):
self._sl = None self._sl = None
self._points: List[Point] = [] self._points: List[Point] = []
self._modification_count = 0
self._old_interpolated = ""
self._profile_type = _type self._profile_type = _type
@@ -160,7 +162,12 @@ class Profile(object):
@rk.setter @rk.setter
def rk(self, value: float): def rk(self, value: float):
self._rk = float(value) value = float(value)
changed = value != self._rk
self._rk = value
if changed:
self.content_modified()
else:
self.modified() self.modified()
@property @property
@@ -173,7 +180,12 @@ class Profile(object):
@name.setter @name.setter
def name(self, value: str): def name(self, value: str):
self._name = value.strip() value = value.strip()
changed = value != self._name
self._name = value
if changed:
self.content_modified()
else:
self.modified() self.modified()
@property @property
@@ -217,6 +229,38 @@ class Profile(object):
return [point for point in self.points return [point for point in self.points
if point.is_named()] if point.is_named()]
@property
def modification_count(self):
"""Content revision for this session; not stored in the database."""
return self._modification_count
def increase_modification_count(self):
"""Increase content revision for this session;
not stored in the database."""
self._modification_count += 1
def decrease_modification_count(self):
"""Decrease content revision for this session;
not stored in the database."""
self._modification_count -= 1
@property
def old_interpolated(self):
"""Old interpolated value for this session;
not stored in the database."""
return self._old_interpolated
@old_interpolated.setter
def old_interpolated(self, value):
self._old_interpolated = value
def content_modified(self):
"""An edited profile is no longer an interpolation result."""
# self._modification_count += 1
if hasattr(self, "_interpolated"):
self._interpolated = False
self.modified()
def insert_point(self, index: int, point: Point): def insert_point(self, index: int, point: Point):
"""Insert point at index. """Insert point at index.
@@ -232,7 +276,7 @@ class Profile(object):
else: else:
self._points.insert(index, point) self._points.insert(index, point)
self.modified() self.content_modified()
def delete_i(self, indexes: list): def delete_i(self, indexes: list):
list( list(
@@ -245,7 +289,7 @@ class Profile(object):
) )
) )
self.modified() self.content_modified()
def delete_points(self, points): def delete_points(self, points):
"""Delete some elements in profile list """Delete some elements in profile list
@@ -262,7 +306,7 @@ class Profile(object):
points points
) )
) )
self.modified() self.content_modified()
# Move # Move
@@ -272,7 +316,7 @@ class Profile(object):
p = self._points p = self._points
p[index], p[next] = p[next], p[index] p[index], p[next] = p[next], p[index]
self.modified() self.content_modified()
def move_down_point(self, index: int): def move_down_point(self, index: int):
if index >= 0: if index >= 0:
@@ -280,7 +324,7 @@ class Profile(object):
p = self._points p = self._points
p[index], p[prev] = p[prev], p[index] p[index], p[prev] = p[prev], p[index]
self.modified() self.content_modified()
# Sort # Sort
@@ -297,7 +341,7 @@ class Profile(object):
key=predicate, key=predicate,
reverse=is_reversed reverse=is_reversed
) )
self.modified() self.content_modified()
@timer @timer
def sort_with_indexes(self, indexes: list): def sort_with_indexes(self, indexes: list):
@@ -313,12 +357,12 @@ class Profile(object):
) )
) )
) )
self.modified() self.content_modified()
@timer @timer
def reverse(self): def reverse(self):
self._points.reverse() self._points.reverse()
self.modified() self.content_modified()
# Sediment Layers # Sediment Layers
+5 -4
View File
@@ -559,7 +559,7 @@ class ProfileXYZ(Profile, SQLSubModel):
) )
self._points.append(pt) self._points.append(pt)
self.modified() self.content_modified()
def get_point_i(self, index: int) -> PointXYZ: def get_point_i(self, index: int) -> PointXYZ:
"""Get point at index. """Get point at index.
@@ -624,7 +624,7 @@ class ProfileXYZ(Profile, SQLSubModel):
) )
self.points.append(point_xyz) self.points.append(point_xyz)
self.modified() self.content_modified()
def insert(self, index: int): def insert(self, index: int):
"""Insert a new point at index. """Insert a new point at index.
@@ -643,7 +643,7 @@ class ProfileXYZ(Profile, SQLSubModel):
point = PointXYZ(x=x, y=y, z=z, profile=self, status=self._status) point = PointXYZ(x=x, y=y, z=z, profile=self, status=self._status)
self._points.insert(index, point) self._points.insert(index, point)
self.modified() self.content_modified()
return point return point
@@ -1172,8 +1172,9 @@ class ProfileXYZ(Profile, SQLSubModel):
interpolated=self.interpolated) interpolated=self.interpolated)
for i, k in enumerate(self.points): for i, k in enumerate(self.points):
p.insert_point(i, k.copy()) p.insert_point(i, k.cloned_for(p))
p._interpolated = self.interpolated
return p return p
def purge_aligned_points(self): def purge_aligned_points(self):
+28 -3
View File
@@ -21,7 +21,7 @@ import shutil
import logging import logging
from datetime import datetime from datetime import datetime
from tools import timer, timestamp from tools import timer, timestamp, parse_datetime
from functools import reduce from functools import reduce
from Model.Tools.PamhyrListExt import PamhyrModelList from Model.Tools.PamhyrListExt import PamhyrModelList
@@ -32,6 +32,7 @@ from Model.Scenario import Scenario
from Model.Status import StudyStatus from Model.Status import StudyStatus
from Model.Except import NotImplementedMethodeError from Model.Except import NotImplementedMethodeError
from Model.River import River from Model.River import River
from Model.TimeShift import shift_river_times, shift_database_times
from Model.Geometry.Reach import Reach from Model.Geometry.Reach import Reach
from Model.HydraulicStructures.HydraulicStructures import ( from Model.HydraulicStructures.HydraulicStructures import (
HydraulicStructure HydraulicStructure
@@ -72,6 +73,7 @@ class Study(SQLModel):
self.description = "" self.description = ""
# Time system # Time system
self._time_system = "time" self._time_system = "time"
self._pending_time_offset = 0.0
self._date = datetime.fromtimestamp(0) self._date = datetime.fromtimestamp(0)
if init_new: if init_new:
@@ -198,6 +200,16 @@ class Study(SQLModel):
self._date = date self._date = date
self.status.modified() self.status.modified()
def change_start_date(self, date, preserve_dates=False):
"""Change the origin, optionally preserving series calendar dates."""
new_date = parse_datetime(date)
offset = (parse_datetime(self.date) - new_date).total_seconds()
if preserve_dates and offset:
shift_river_times(self.river, offset)
self._pending_time_offset += offset
self._river_scenario_cache.clear()
self.use_date(new_date)
@property @property
def date(self): def date(self):
return self._date return self._date
@@ -217,8 +229,7 @@ class Study(SQLModel):
me.name = name me.name = name
me.description = description me.description = description
if date is not None: if date is not None:
me.use_date() me.use_date(date)
me.date = date
return me return me
@classmethod @classmethod
@@ -461,6 +472,17 @@ class Study(SQLModel):
) )
progress() progress()
if self._pending_time_offset:
self._db.execute('SAVEPOINT shift_time_origin')
try:
shift_database_times(self._db, self._pending_time_offset)
self._db.execute('RELEASE SAVEPOINT shift_time_origin')
except Exception:
self._db.execute('ROLLBACK TO SAVEPOINT shift_time_origin')
self._db.execute('RELEASE SAVEPOINT shift_time_origin')
raise
self._pending_time_offset = 0.0
self._save_submodel( self._save_submodel(
[self.scenarios, self._river], [self.scenarios, self._river],
data=progress data=progress
@@ -595,6 +617,9 @@ class Study(SQLModel):
self.status.stop_loading() self.status.stop_loading()
self.status.scenario = old_scenario self.status.scenario = old_scenario
if self._pending_time_offset:
shift_river_times(river, self._pending_time_offset)
# FIXME: Disable scenario cache to save memory usage # FIXME: Disable scenario cache to save memory usage
# self._river_scenario_cache[scenario] = river # self._river_scenario_cache[scenario] = river
+82
View File
@@ -0,0 +1,82 @@
"""Shift temporal series when preserving calendar dates across a new origin."""
from Model.BoundaryCondition.BoundaryCondition import BoundaryCondition
from Model.LateralContribution.LateralContribution import LateralContribution
from Model.WeatherParameters.WeatherParameters import WeatherParameters
from Model.BoundaryConditionsAdisTS.BoundaryConditionAdisTS import (
BoundaryConditionAdisTS
)
from Model.LateralContributionsAdisTS.LateralContributionAdisTS import (
LateralContributionAdisTS
)
from Model.BoundaryConditionsTemperature.BoundaryConditionTemperature import (
BoundaryConditionTemperature
)
def shift_river_times(river, offset):
changes = []
seen = set()
def collect(obj, data):
temporal = isinstance(obj, (
BoundaryConditionAdisTS,
LateralContributionAdisTS,
BoundaryConditionTemperature
))
if isinstance(obj, (
BoundaryCondition, LateralContribution, WeatherParameters
)):
temporal = bool(obj.header) and obj.header[0] == 'time'
if temporal:
for point in obj._data:
if id(point) not in seen:
changes.append((point, float(point[0]) + offset))
seen.add(id(point))
river._data_traversal(modifier=collect)
for point, value in changes:
# This is a global change of origin, not a scenario-specific edit.
point[0] = value
def shift_database_times(connection, offset):
tables = [
(
'boundary_condition_data', 'boundary_condition',
'bc', BoundaryCondition
),
(
'lateral_contribution_data', 'lateral_contribution',
'lc', LateralContribution
),
(
'weather_parameters_data', 'weather_parameters',
'wp', WeatherParameters
),
('boundary_condition_data_adists', None, None, None),
('lateral_contribution_data_adists', None, None, None),
('boundary_condition_data_temperature', None, None, None),
]
for table, parent, fk, model in tables:
if model is None:
rows = connection.execute(
f'SELECT rowid, data0 FROM {table}'
).fetchall()
else:
rows = []
for kind, in connection.execute(
f'SELECT DISTINCT type FROM {parent}'
):
headers = model._get_ctor_from_type(kind)().header
if not headers or headers[0] != 'time':
continue
rows.extend(connection.execute(
f'SELECT d.rowid, d.data0 FROM {table} d JOIN {parent} p '
f'ON p.pamhyr_id=d.{fk} AND p.scenario=d.scenario '
f'WHERE p.type=?',
(kind,)
).fetchall())
connection.executemany(
f'UPDATE {table} SET data0=? WHERE rowid=?',
[(float(value) + offset, rowid) for rowid, value in rows]
)
+6 -1
View File
@@ -25,6 +25,7 @@ from PyQt5.QtCore import QProcess
from Scripts.AScript import AScript from Scripts.AScript import AScript
from Model.Study import Study from Model.Study import Study
from View.RunSolver.Translate import RunSolverTranslate
logger = logging.getLogger() logger = logging.getLogger()
@@ -89,12 +90,16 @@ class ScriptRun(AScript):
# Export and Run # Export and Run
logger.info(f"~Export~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") logger.info(f"~Export~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
solver.export(study, workdir, qlog=self._q) ok = solver.export(study, workdir, qlog=self._q)
while self._q.qsize() != 0: while self._q.qsize() != 0:
s = self._q.get() s = self._q.get()
logger.info(s) logger.info(s)
if not ok:
logger.warning(RunSolverTranslate()["export_failed"])
return
if command == "run": if command == "run":
logger.info(f"~Run~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") logger.info(f"~Run~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
solver.run( solver.run(
+6 -1
View File
@@ -19,8 +19,9 @@
import os import os
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from tools import timer, parse_command_line, get_version, logger_exception from tools import timer, parse_command_line, get_version, logger_exception
from tools import parse_datetime
try: try:
# Installation allow Unix-like signal # Installation allow Unix-like signal
@@ -158,11 +159,15 @@ class CommandLineSolver(AbstractSolver):
txt = add_file.text txt = add_file.text
txt = txt.replace("@version", get_version()) txt = txt.replace("@version", get_version())
txt = txt.replace("@date", datetime.now().isoformat(sep=" ")) txt = txt.replace("@date", datetime.now().isoformat(sep=" "))
txt = self._prepare_additional_file_text(add_file.path, txt)
f.write(txt) f.write(txt)
return files return files
def _prepare_additional_file_text(self, path, text):
return text
def export_study_description(self, study, repertory, qlog, name="0"): def export_study_description(self, study, repertory, qlog, name="0"):
files = [] files = []
+39 -11
View File
@@ -303,7 +303,7 @@ class Mage(CommandLineSolver):
# Point line # Point line
wfile.write(f"{x} {y} {z} {n} {sediment}\n") wfile.write(f"{x} {y} {z} {n} {sediment}\n")
def _export_BC(self, t, bounds, repertory, qlog, name="0"): def _export_BC(self, study, t, bounds, repertory, qlog, name="0"):
files = [] files = []
if len(bounds) == 0: if len(bounds) == 0:
@@ -333,7 +333,7 @@ class Mage(CommandLineSolver):
v1 = d[1] v1 = d[1]
if t in ["HYD", "LIM"]: if t in ["HYD", "LIM"]:
v0 /= 60 # Convert first column to minute v0 /= 60 # Convert first column to minutes
f.write(f"{v0:9} {v1:9} \n") f.write(f"{v0:9} {v1:9} \n")
@@ -365,10 +365,17 @@ class Mage(CommandLineSolver):
elif bound.bctype == "SL": elif bound.bctype == "SL":
QSO.append(bound) QSO.append(bound)
files = files + self._export_BC("AVA", AVA, repertory, qlog, name=name) files = files + self._export_BC(
files = files + self._export_BC("HYD", HYD, repertory, qlog, name=name) study, "AVA", AVA, repertory, qlog, name=name
files = files + self._export_BC("LIM", LIM, repertory, qlog, name=name) )
files = files + self._export_QSO(QSO, repertory, qlog, name=name) files = files + self._export_BC(
study, "HYD", HYD, repertory, qlog, name=name
)
files = files + self._export_BC(
study, "LIM", LIM, repertory, qlog, name=name
)
files = files + self._export_QSO(study, QSO, repertory,
qlog, name=name)
return files return files
@@ -422,10 +429,11 @@ class Mage(CommandLineSolver):
if d.is_deleted(): if d.is_deleted():
continue continue
time = d[0]
if lateral.lctype in ["EV"]: if lateral.lctype in ["EV"]:
f.write(f"{d[0]:10.3f}{-d[1]:10.3f}\n") f.write(f"{time:10.3f}{-d[1]:10.3f}\n")
else: else:
f.write(f"{d[0]:10.3f}{d[1]:10.3f}\n") f.write(f"{time:10.3f}{d[1]:10.3f}\n")
def _export_RUG(self, study, repertory, qlog, name="0"): def _export_RUG(self, study, repertory, qlog, name="0"):
files = [] files = []
@@ -686,6 +694,23 @@ class Mage(CommandLineSolver):
return res return res
def _prepare_additional_file_text(self, path, text):
if os.path.splitext(path)[1].lower() != ".var":
return text
lines = []
for line in text.splitlines(keepends=True):
stripped = line.lstrip()
fields = stripped.split()
if stripped.startswith("$"):
# MAGE only recognizes rule markers in the first column.
line = stripped
elif (not stripped.startswith("*") and len(fields) >= 2
and fields[1] == "W2Z"):
line = "$ " + stripped
lines.append(line)
return "".join(lines)
def _export_VAR(self, study, repertory, qlog, name="0"): def _export_VAR(self, study, repertory, qlog, name="0"):
files = [] files = []
@@ -736,9 +761,11 @@ class Mage(CommandLineSolver):
name = bhs.name name = bhs.name
if name == "": if name == "":
name = f"HS_{bhs.id:>3}".replace(" ", "0") name = f"HS_{bhs.id:>3}".replace(" ", "0")
else:
name = name.replace(" ", "_")
f.write( f.write(
f"${name} clapet" f"${name} clapet\n"
) )
return files return files
@@ -964,7 +991,7 @@ class Mage8(Mage):
return files return files
def _export_QSO(self, bounds, repertory, qlog, name="0"): def _export_QSO(self, study, bounds, repertory, qlog, name="0"):
files = [] files = []
if len(bounds) == 0: if len(bounds) == 0:
@@ -994,7 +1021,8 @@ class Mage8(Mage):
# Data # Data
for d in bound.data: for d in bound.data:
f.write(f"{d[0]/60:10.3f}{d[1]:10.3f}\n") time = d[0] / 60
f.write(f"{time:10.3f}{d[1]:10.3f}\n")
return files return files
+261 -106
View File
@@ -18,11 +18,14 @@
import os import os
import logging import logging
import math
from decimal import Decimal, InvalidOperation, localcontext
import numpy as np import numpy as np
from tools import timer, trace, old_pamhyr_date_to_timestamp, logger_exception from tools import timer, trace, old_pamhyr_date_to_timestamp, logger_exception
from Solver.CommandLine import CommandLineSolver from Solver.CommandLine import CommandLineSolver
from View.RunSolver.Translate import RunSolverTranslate
from Model.Results.Results import Results from Model.Results.Results import Results
from Model.Results.River.River import River, Reach, Profile from Model.Results.River.River import River, Reach, Profile
@@ -44,7 +47,7 @@ class Rubar3(CommandLineSolver):
@classmethod @classmethod
def default_parameters(cls): def default_parameters(cls):
# lst = super(Rubar3, cls).default_parameters() # Keep historical rubarbe_* keys for existing Rubar3 studies.
lst = [ lst = [
("rubarbe_cfl", "0.50000E+00"), ("rubarbe_cfl", "0.50000E+00"),
@@ -52,13 +55,13 @@ class Rubar3(CommandLineSolver):
("rubarbe_condav", "3"), ("rubarbe_condav", "3"),
("rubarbe_regime", "0"), ("rubarbe_regime", "0"),
("rubarbe_iodev", "n"), ("rubarbe_iodev", "n"),
("rubarbe_iodebord", ""), ("rubarbe_iodebord", "o"),
("rubarbe_iostockage", ""), ("rubarbe_iostockage", "o"),
("rubarbe_iopdt", "y"), ("rubarbe_iopdt", "y"),
("rubarbe_iovis", "n"), ("rubarbe_iovis", "n"),
("rubarbe_rep", "n"), ("rubarbe_rep", "n"),
("rubarbe_tinit", "000:00:00:00"), ("rubarbe_tinit", "000:00:00:00"),
("rubarbe_tmax", "000:01:00:00"), # 1 day ("rubarbe_tmax", "001:00:00:00"), # 1 day
("rubarbe_tiopdt", "000:00:00:00"), ("rubarbe_tiopdt", "000:00:00:00"),
("rubarbe_dt", "5.0"), ("rubarbe_dt", "5.0"),
("rubarbe_ts", "999:99:99:00"), ("rubarbe_ts", "999:99:99:00"),
@@ -89,8 +92,8 @@ class Rubar3(CommandLineSolver):
("rubarbe_sediment_mult_3", ""), ("rubarbe_sediment_mult_3", ""),
("rubarbe_sediment_mult_4", ""), ("rubarbe_sediment_mult_4", ""),
("rubarbe_sediment_mult_5", ""), ("rubarbe_sediment_mult_5", ""),
("rubarbe_sediment_visc", "0.047"), ("rubarbe_sediment_visc", "1.3E-06"),
("rubarbe_sediment_opts", "6"), ("rubarbe_sediment_opts", "3"),
("rubarbe_sediment_odchar", "0"), ("rubarbe_sediment_odchar", "0"),
("rubarbe_sediment_unisol", "1"), ("rubarbe_sediment_unisol", "1"),
("rubarbe_sediment_typdef", "3"), ("rubarbe_sediment_typdef", "3"),
@@ -140,8 +143,34 @@ class Rubar3(CommandLineSolver):
self._study = study self._study = study
name = study.name.replace(" ", "_") name = study.name.replace(" ", "_")
values = self._parameter_values(study)
if (values.get("rubarbe_regime") == "1" and
values.get("rubarbe_condam") == "0"):
trad = RunSolverTranslate()
message = trad["incompatible_upstream_boundary"]
logger.warning(message)
if qlog is not None:
qlog.put(trad["warning"].format(message=message))
return False
trased = self._parameter_flag(study, "trased") == "O"
if trased:
# Notice §3.6: HYDROS is required at each upstream boundary
missing = self._reaches_without_solid_boundary(study)
if missing:
trad = RunSolverTranslate()
message = trad["missing_solid_boundary"].format(
reaches=", ".join(missing)
)
logger.warning(message)
if qlog is not None:
qlog.put(trad["warning"].format(message=message))
return False
self._export_donnee(study, repertory, qlog, name=name) self._export_donnee(study, repertory, qlog, name=name)
self._export_ts(study, repertory, qlog, name=name) if trased:
if not self._export_ts(study, repertory, qlog, name=name):
return False
self._export_geomac_i(study, repertory, qlog, name=name) self._export_geomac_i(study, repertory, qlog, name=name)
self._export_mail(study, repertory, qlog, name=name) self._export_mail(study, repertory, qlog, name=name)
self._export_devers(study, repertory, qlog, name=name) self._export_devers(study, repertory, qlog, name=name)
@@ -153,108 +182,205 @@ class Rubar3(CommandLineSolver):
return True return True
def _export_donnee(self, study, repertory, qlog, name="0"): # Notice Rubar3/RubarBE, annex 1: records are positional, not keyed.
# Each record starts with an A50 label, including records of integers.
_donnee_fields = (
("cfl", "real"),
("condam", "integer"), ("condav", "integer"),
("regime", "integer"), ("iodev", "flags"),
("iopdt", "flag"), ("iovis", "flag"), ("rep", "flag"),
("tinit", "time"), ("tmax", "time"), ("tiopdt", "time"),
("dt", "real"), ("ts", "time"), ("dtsauv", "time"),
("psave", "time"), ("fdeb1", "real"), ("fdeb2", "real"),
("fdeb3", "real"),
("tf_1", "flag"), ("tf_2", "flag"), ("tf_3", "flag"),
("tf_4", "flag"), ("tf_5", "flag"), ("tf_6", "flag"),
("trased", "flag"),
)
_ts_fields = (
("ros", "real"), ("por", "real"), ("dcharg", "real"),
("halfa", "real"), ("mult_1", "multipliers"),
("visc", "real"), ("opts", "law"), ("odchar", "integer"),
("unisol", "integer"), ("typdef", "integer"),
("depot", "depot"), ("choixc", "integer"),
("option", "options"), ("capsol", "integer"),
("bmiu", "real"), ("demix", "integer"),
("defond", "integer"), ("varcons", "integer"),
)
@staticmethod
def _reaches_without_solid_boundary(study):
river = study.river
solid = [
bc for bc in river.boundary_condition.get_tab("solid")
if not bc.is_deleted() and bc.bctype == "SL" and len(bc) > 0
]
return [
edge.name for edge in river.enable_edges()
if river.is_upstream_node(edge.node1)
and not any(bc.node == edge.node1 for bc in solid)
]
def _parameter_values(self, study):
return {p.name: str(p.value).strip()
for p in study.river.get_params(self.type).parameters}
@staticmethod
def _required(values, key):
value = values.get(key, "")
if not value:
raise ValueError(f"Missing Rubar parameter: {key}")
return value
@staticmethod
def _flag(value, key, mixed=False):
result = value.upper()
if result == "Y":
result = "O"
if result not in (("O", "N", "M") if mixed else ("O", "N")):
raise ValueError(f"{key}: expected O/Y or N" +
(" or M" if mixed else ""))
return result
def _parameter_flag(self, study, suffix):
key = "rubarbe_" + suffix
return self._flag(self._required(self._parameter_values(study), key),
key)
@staticmethod
def _integer(value, key, width=1):
if not value.isascii() or not value.isdigit() or len(value) > width:
raise ValueError(f"{key}: expected an integer in {width} columns")
return value.rjust(width)
@staticmethod
def _real(value, key, scientific=False):
try:
number = Decimal(value.replace("d", "E").replace("D", "E"))
except InvalidOperation:
raise ValueError(f"{key}: invalid real value {value!r}") from None
if not number.is_finite():
raise ValueError(f"{key}: expected a finite real value")
if scientific:
# DONNEE: E12.5. Reject overflow instead of shifting columns.
floating = float(number)
if not math.isfinite(floating) or (number and floating == 0):
raise ValueError(f"{key}: real value outside E12.5 range")
result = f"{floating:12.5E}"
width = 12
else:
# TS: F10.0 input accepts an explicit decimal point/exponent.
# Preserve the value exactly; do not round porosity etc. to int.
with localcontext() as context:
context.prec = max(28, len(number.as_tuple().digits))
number = number.normalize()
result = str(number).upper()
if len(result) > 10:
result = format(number, "E")
width = 10
if len(result) > width:
raise ValueError(f"{key}: value {value!r} exceeds {width} columns")
return result.rjust(width)
@staticmethod
def _write_parameters(repertory, filename, lines):
# Validate all records before opening/truncating the destination.
path = os.path.join(repertory, filename)
with open(path, "w", encoding="ascii") as f:
f.writelines(lines)
def _export_donnee(self, study, repertory, qlog=None, name="0"):
values = self._parameter_values(study)
fields = list(self._donnee_fields)
# The diagnostic transport-capacity extension is optional as a block.
if values.get("rubarbe_optfpc", ""):
fields += [("optfpc", "integer"), ("ros", "real"),
("dm", "real"), ("segma", "real")]
elif any(values.get("rubarbe_" + key, "")
for key in ("ros", "dm", "segma")):
raise ValueError("rubarbe_optfpc: required before ROS/DM/SEGMA")
lines = []
for suffix, kind in fields:
key = "rubarbe_" + suffix
value = self._required(values, key)
if kind == "integer":
result = self._integer(value, key)
elif kind in ("flag", "flags"):
result = self._flag(value, key, mixed=suffix == "iopdt")
if kind == "flags":
# Missing optional flags mean O (notice section 3.1).
# Write both explicitly so STOCKAGE cannot shift left.
for extra in ("iodebord", "iostockage"):
extra_key = "rubarbe_" + extra
result += self._flag(values.get(extra_key) or "O",
extra_key)
else:
if kind == "time" and ":" in value:
parts = value.split(":")
if len(parts) != 4 or not all(p.isdigit() for p in parts):
raise ValueError(
f"{key}: expected days:hours:minutes:seconds")
value = str(old_pamhyr_date_to_timestamp(value))
result = self._real(value, key, scientific=True)
lines.append(f"{key:<50}{result}\n")
if qlog is not None: if qlog is not None:
qlog.put("Export DONNEE file") qlog.put("Export DONNEE file")
self._write_parameters(repertory, f"donnee.{name}", lines)
with open( def _export_ts(self, study, repertory, qlog=None, name="0"):
os.path.join( trad = RunSolverTranslate()
repertory, f"donnee.{name}" values = self._parameter_values(study)
), "w+" prefix = "rubarbe_sediment_"
) as f: fields = list(self._ts_fields)
params = filter( demix = self._required(values, prefix + "demix")
lambda p: "rubarbe_sediment_" not in p.name, if demix not in ("0", "1"):
study.river.get_params(self._type).parameters raise ValueError(f"{prefix}demix: expected 0 or 1")
) if demix == "1":
fields += [("dchard", "real"), ("dchars", "real")]
it = iter(params) lines = []
for suffix, kind in fields:
param = next(it, None) key = prefix + suffix
while param is not None: value = self._required(values, key)
name = param.name if kind == "real":
value = param.value result = self._real(value, key)
elif kind == "multipliers":
if "all_" in name: result = self._real(value, key)
continue extras = [values.get(prefix + f"mult_{i}", "")
for i in range(2, 6)]
if value != "": # Preserve empty interior F10.0 fields; omit trailing fields
# Value format # to retain the solver's defaults for optional coefficients.
if value.count(':') == 3: while extras and not extras[-1]:
value = old_pamhyr_date_to_timestamp(value) extras.pop()
value = f"{value:>12.5e}".upper() for i, extra in enumerate(extras, 2):
result += (self._real(extra, prefix + f"mult_{i}")
if value.count('.') == 1: if extra else " " * 10)
value = f"{float(value):>12.5e}".upper() elif kind == "options":
if len(value) != 3 or any(c not in "12" for c in value):
if value == "y" or value == "n": message = trad["invalid_options"].format(key=key)
value = "O" if value == "y" else "N" logger.warning(message)
if qlog is not None:
# Write value qlog.put(trad["warning"].format(message=message))
f.write(f"{name:<50}{value}") return False
result = value
# Add values of 'rubarbe_iodebord' and elif kind == "law":
# 'rubarbe_iostockage' # Notice section 3.10 / 8.7: 4--7 are not listed.
if name == "rubarbe_iodev": if value not in {str(i) for i in [1, 2, 3, *range(8, 31)]}:
v2 = next(it).value message = trad["unsupported_transport_law"].format(
v3 = next(it).value key=key, value=value)
logger.warning(message)
# f.write(f"{v2}{v3}") if qlog is not None:
qlog.put(trad["warning"].format(message=message))
# New line return False
f.write(f"\n") result = self._integer(value, key, width=2)
else:
param = next(it, None) result = self._integer(value, key,
width=10 if kind == "depot" else 1)
def _export_ts(self, study, repertory, qlog, name="0"): lines.append(f"{key:<50}{result}\n")
if qlog is not None: if qlog is not None:
qlog.put("Export TS file") qlog.put("Export TS file")
self._write_parameters(repertory, f"ts.{name}", lines)
with open( return True
os.path.join(
repertory, f"ts.{name}"
), "w+"
) as f:
def float_format(string):
if "." in string:
return f"{float(string):>10.0f}"
return ""
params = filter(
lambda p: "rubarbe_sediment_" in p.name,
study.river.get_params(self.type).parameters
)
it = iter(params)
line = 0
while line < 20:
param = next(it)
name = param.name
value = param.value
if value != "":
# Value format
if value.count('.') == 1:
value = f"{float_format(value)}"
else:
value = f"{value:>10}"
# Write value
f.write(f"{name:<50}{value}")
# Add values of 'rubarbe_iodebord' and
# 'rubarbe_iostockage'
if name == "rubarbe_sediment_mult_1":
m2 = f"{float_format(next(it).value)}"
m3 = f"{float_format(next(it).value)}"
m4 = f"{float_format(next(it).value)}"
m5 = f"{float_format(next(it).value)}"
f.write(f"{m2}{m3}{m4}{m5}")
# New line
f.write(f"\n")
line += 1
def _export_geomac_i(self, study, repertory, qlog, name="0"): def _export_geomac_i(self, study, repertory, qlog, name="0"):
if qlog is not None: if qlog is not None:
@@ -616,6 +742,7 @@ class Rubar3(CommandLineSolver):
)).value )).value
if timestamp.count(':') == 3: if timestamp.count(':') == 3:
timestamp = old_pamhyr_date_to_timestamp(timestamp) timestamp = old_pamhyr_date_to_timestamp(timestamp)
timestamp = float(timestamp)
ts.add(timestamp) ts.add(timestamp)
# add initial condition # add initial condition
@@ -636,8 +763,7 @@ class Rubar3(CommandLineSolver):
while True: while True:
line = f.readline() line = f.readline()
if line == "": if line == "":
results.set("timestamps", ts) break
return
timestamp = float(line) timestamp = float(line)
ts.add(timestamp) ts.add(timestamp)
@@ -671,6 +797,24 @@ class Rubar3(CommandLineSolver):
h, s, q, z = read_data_line(f) h, s, q, z = read_data_line(f)
set_and_compute_limites(reach, ind, z+h, q, s) set_and_compute_limites(reach, ind, z+h, q, s)
# Tables and plots use timestamp rows and global profile columns.
timestamps = sorted(ts)
profiles = []
for reach, _ in reachs:
offset = len(profiles)
profiles.extend(reach.profiles)
reach.set_global_index(range(offset, len(profiles)))
results.set("timestamps", timestamps)
results.compute_timestamp_index()
results.set("table", {
key: np.array([
[profile.get_ts_key(t, key) for profile in profiles]
for t in timestamps
], dtype=float)
for key in ("Z", "Q", "V")
})
@timer @timer
def write_bin(self, study, fname, results, qlog=None, name="0"): def write_bin(self, study, fname, results, qlog=None, name="0"):
logger.info(f"write_bin: Start writing '{fname}' ...") logger.info(f"write_bin: Start writing '{fname}' ...")
@@ -800,3 +944,14 @@ class RubarBE(Rubar3):
self._cmd_input = "" self._cmd_input = ""
self._cmd_solver = "@path @args @input" self._cmd_solver = "@path @args @input"
self._cmd_output = "" self._cmd_output = ""
@classmethod
def default_parameters(cls):
# New BE studies evolve the bed. Keep Rubar3 defaults unchanged.
overrides = {
"rubarbe_trased": "y",
"rubarbe_sediment_opts": "1", # Meyer-Peter and Mueller
"rubarbe_sediment_option": "212", # no Ikeda, given Shields, total
}
return [(key, overrides.get(key, value))
for key, value in super().default_parameters()]
+2 -2
View File
@@ -35,7 +35,7 @@ solver_long_name = {
# "mage_fake7": "Mage fake v7", # "mage_fake7": "Mage fake v7",
"adistswc": "Adis-TS_WC", "adistswc": "Adis-TS_WC",
"adisttwc": "Adis-TT_WC", "adisttwc": "Adis-TT_WC",
# "rubarbe": "RubarBE", "rubarbe": "RubarBE",
"rubar3": "Rubar3", "rubar3": "Rubar3",
} }
@@ -46,6 +46,6 @@ solver_type_list = {
# "mage_fake7": MageFake7, # "mage_fake7": MageFake7,
"adistswc": AdisTSwc, "adistswc": AdisTSwc,
"adisttwc": AdisTTwc, "adisttwc": AdisTTwc,
# "rubarbe": RubarBE, "rubarbe": RubarBE,
"rubar3": Rubar3, "rubar3": Rubar3,
} }
+12 -10
View File
@@ -24,7 +24,7 @@ from datetime import date, time, datetime, timedelta
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
old_pamhyr_date_to_timestamp, format_datetime old_pamhyr_date_to_timestamp, format_datetime, parse_datetime
) )
from View.Tools.PamhyrTable import PamhyrTableModel from View.Tools.PamhyrTable import PamhyrTableModel
@@ -94,11 +94,12 @@ class TableModel(PamhyrTableModel):
v = v.replace(',', '.') v = v.replace(',', '.')
value = f"{float(v):.4f}" value = f"{float(v):.4f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date(int(v)) value = timestamp_to_old_pamhyr_date(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -117,7 +118,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
row = self.get_true_data_row(row) row = self.get_true_data_row(row)
@@ -238,17 +239,18 @@ class TableModel(PamhyrTableModel):
if len(data) == 0: if len(data) == 0:
return return
try:
rows = [self._data.new_from_data(header, d) for d in data]
except (ValueError, TypeError, IndexError) as error:
logger.warning(error)
return False
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push( self._undo.push(
PasteCommand( PasteCommand(
self._data, row, self._data, row,
list( rows
map(
lambda d: self._data.new_from_data(header, d),
data
)
)
) )
) )
+8 -1
View File
@@ -18,6 +18,8 @@
import logging import logging
from Modules import Modules
from tools import timer, trace from tools import timer, trace
from numpy import sqrt from numpy import sqrt
@@ -174,7 +176,7 @@ class EditBoundaryConditionWindow(PamhyrWindow):
data=self._data, data=self._data,
undo=self._undo_stack, undo=self._undo_stack,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -262,6 +264,11 @@ class EditBoundaryConditionWindow(PamhyrWindow):
self._d50sigma.set_d50(self._data.d50) self._d50sigma.set_d50(self._data.d50)
self._d50sigma.set_sigma(self._data.sigma) self._d50sigma.set_sigma(self._data.sigma)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
+1 -1
View File
@@ -124,7 +124,7 @@ class DelCommand(QUndoCommand):
self._bcs.insert(self._tab, row, el) self._bcs.insert(self._tab, row, el)
def redo(self): def redo(self):
self._bcs.delete_i(self._tab, self._rows) self._bcs.delete(self._tab, [el for _, el in self._bc])
class SortCommand(QUndoCommand): class SortCommand(QUndoCommand):
@@ -21,6 +21,7 @@ import traceback
from datetime import date, time, datetime, timedelta from datetime import date, time, datetime, timedelta
from tools import parse_datetime
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
@@ -84,11 +85,12 @@ class TableModel(PamhyrTableModel):
if self._data._types[column] == float: if self._data._types[column] == float:
value = f"{v:.4f}" value = f"{v:.4f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date(int(v)) value = timestamp_to_old_pamhyr_date(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -107,7 +109,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
row = self.get_true_data_row(row) row = self.get_true_data_row(row)
@@ -169,17 +171,18 @@ class TableModel(PamhyrTableModel):
if len(data) == 0: if len(data) == 0:
return return
try:
rows = [self._data.new_from_data(header, d) for d in data]
except (ValueError, TypeError, IndexError) as error:
logger.warning(error)
return False
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push( self._undo.push(
PasteCommand( PasteCommand(
self._data, row, self._data, row,
list( rows
map(
lambda d: self._data.new_from_data(header, d),
data
)
)
) )
) )
@@ -18,6 +18,8 @@
import logging import logging
from Modules import Modules
from tools import timer, trace from tools import timer, trace
from View.Tools.PamhyrWindow import PamhyrWindow from View.Tools.PamhyrWindow import PamhyrWindow
@@ -129,7 +131,7 @@ class EditBoundaryConditionWindow(PamhyrWindow):
data=self._data, data=self._data,
undo=self._undo_stack, undo=self._undo_stack,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -162,6 +164,11 @@ class EditBoundaryConditionWindow(PamhyrWindow):
self.find(QAction, "action_sort").triggered.connect(self.sort) self.find(QAction, "action_sort").triggered.connect(self.sort)
self._table.dataChanged.connect(self.update) self._table.dataChanged.connect(self.update)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
self.plot.fit_to_data() self.plot.fit_to_data()
@@ -21,6 +21,7 @@ import traceback
from datetime import date, time, datetime, timedelta from datetime import date, time, datetime, timedelta
from tools import parse_datetime
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
@@ -84,11 +85,12 @@ class TableModel(PamhyrTableModel):
if self._data._types[column] == float: if self._data._types[column] == float:
value = f"{v:.2f}" value = f"{v:.2f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date(int(v)) value = timestamp_to_old_pamhyr_date(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -107,7 +109,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
row = self.get_true_data_row(row) row = self.get_true_data_row(row)
@@ -169,17 +171,18 @@ class TableModel(PamhyrTableModel):
if len(data) == 0: if len(data) == 0:
return return
try:
rows = [self._data.new_from_data(header, d) for d in data]
except (ValueError, TypeError, IndexError) as error:
logger.warning(error)
return False
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push( self._undo.push(
PasteCommand( PasteCommand(
self._data, row, self._data, row,
list( rows
map(
lambda d: self._data.new_from_data(header, d),
data
)
)
) )
) )
@@ -18,6 +18,8 @@
import logging import logging
from Modules import Modules
from tools import timer, trace from tools import timer, trace
from View.Tools.PamhyrWindow import PamhyrWindow from View.Tools.PamhyrWindow import PamhyrWindow
@@ -118,7 +120,7 @@ class EditBoundaryConditionWindow(PamhyrWindow):
data=self._data, data=self._data,
undo=self._undo_stack, undo=self._undo_stack,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -151,6 +153,11 @@ class EditBoundaryConditionWindow(PamhyrWindow):
self.find(QAction, "action_sort").triggered.connect(self.sort) self.find(QAction, "action_sort").triggered.connect(self.sort)
self._table.dataChanged.connect(self.update) self._table.dataChanged.connect(self.update)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
self.plot.fit_to_data() self.plot.fit_to_data()
+7 -4
View File
@@ -112,7 +112,7 @@ class PlotAC(PamhyrPlot):
profile_id = 0 profile_id = 0
profile = self.data.profile(profile_id) profile = self.data.profile(profile_id)
if profile is None: if profile is None or self._parent.tableView.isRowHidden(profile_id):
return return
station = profile.get_station() station = profile.get_station()
@@ -192,21 +192,24 @@ class PlotAC(PamhyrPlot):
self.plot_selected.set_data([], []) self.plot_selected.set_data([], [])
self.next_plot_selected.set_data([], []) self.next_plot_selected.set_data([], [])
if 0 <= previous_id < self.data.number_profiles: if (0 <= previous_id < self.data.number_profiles
and not self._parent.tableView.isRowHidden(previous_id)):
self.previous_plot_selected.set_data( self.previous_plot_selected.set_data(
self.data.profile(previous_id).get_station(), self.data.profile(previous_id).get_station(),
self.data.profile(previous_id).z() self.data.profile(previous_id).z()
) )
self.previous_plot_selected.set_visible(True) self.previous_plot_selected.set_visible(True)
if 0 <= profile_id < self.data.number_profiles: if (0 <= profile_id < self.data.number_profiles
and not self._parent.tableView.isRowHidden(profile_id)):
self.plot_selected.set_data( self.plot_selected.set_data(
self.data.profile(profile_id).get_station(), self.data.profile(profile_id).get_station(),
self.data.profile(profile_id).z() self.data.profile(profile_id).z()
) )
self.plot_selected.set_visible(True) self.plot_selected.set_visible(True)
if 0 <= next_id < self.data.number_profiles: if (0 <= next_id < self.data.number_profiles
and not self._parent.tableView.isRowHidden(next_id)):
self.next_plot_selected.set_data( self.next_plot_selected.set_data(
self.data.profile(next_id).get_station(), self.data.profile(next_id).get_station(),
self.data.profile(next_id).z() self.data.profile(next_id).z()
+17 -16
View File
@@ -20,6 +20,7 @@ import logging
from tools import timer from tools import timer
from View.Tools.PamhyrPlot import PamhyrPlot from View.Tools.PamhyrPlot import PamhyrPlot
from matplotlib.backend_bases import MouseButton
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, QItemSelectionModel, Qt, QItemSelectionModel,
@@ -64,7 +65,7 @@ class PlotRKZ(PamhyrPlot):
def onpick(self, event): def onpick(self, event):
if event.mouseevent.inaxes != self.canvas.axes: if event.mouseevent.inaxes != self.canvas.axes:
return return
if event.mouseevent.button.value != 1: if event.mouseevent.button != MouseButton.LEFT:
return return
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
@@ -110,9 +111,9 @@ class PlotRKZ(PamhyrPlot):
def _closest_rk(self, event): def _closest_rk(self, event):
s = event.artist.get_segments() s = event.artist.get_segments()
x = [i[0, 0] for i in s]
mx = event.mouseevent.xdata mx = event.mouseevent.xdata
points = enumerate(x) points = [(i, segment[0, 0]) for i, segment in enumerate(s)
if len(segment)]
def dist_mouse(point): def dist_mouse(point):
x = point[1] x = point[1]
@@ -136,6 +137,7 @@ class PlotRKZ(PamhyrPlot):
if len(ind) == 0: if len(ind) == 0:
return return
ind = [i for i in ind if not self._table.isRowHidden(i)]
for i in ind: for i in ind:
index.append(QItemSelectionRange(self._table.model().index(i, 0))) index.append(QItemSelectionRange(self._table.model().index(i, 0)))
@@ -150,18 +152,7 @@ class PlotRKZ(PamhyrPlot):
self._table.scrollTo(self._table.model().index(ind[-1], 0)) self._table.scrollTo(self._table.model().index(ind[-1], 0))
def _select_range_in_table(self, ind1, ind2): def _select_range_in_table(self, ind1, ind2):
if self._table is not None: self._select_in_table(list(range(ind1, ind2 + 1)))
self._table.setFocus()
selection = self._table.selectionModel()
index = QItemSelection(self._table.model().index(ind1, 0),
self._table.model().index(ind2, 0))
selection.select(
index,
QItemSelectionModel.Rows |
QItemSelectionModel.ClearAndSelect |
QItemSelectionModel.Select
)
self._table.scrollTo(self._table.model().index(ind2, 0))
@timer @timer
def redraw(self, data): def redraw(self, data):
@@ -213,13 +204,23 @@ class PlotRKZ(PamhyrPlot):
picker=10, picker=10,
) )
if self._table is not None:
segments = self.line_rk_zmin_zmax.get_segments()
self.line_rk_zmin_zmax.set_segments([
segment[:0] if self._table.isRowHidden(row) else segment
for row, segment in enumerate(segments)
])
def color_highlight(self): def color_highlight(self):
rows = sorted(list( rows = sorted(list(
set( set(
(i.row() for i in self.parent.tableView.selectedIndexes()) (i.row() for i in self.parent.tableView.selectedIndexes())
) )
)) ))
colors = [self.color_plot for row in range(len(self._data))] colors = [
"darkorange" if profile.interpolated else self.color_plot
for profile in self._data.profiles
]
style = ["-" for row in range(len(self._data))] style = ["-" for row in range(len(self._data))]
if len(rows) > 0: if len(rows) > 0:
for row in rows: for row in rows:
+17 -19
View File
@@ -18,6 +18,7 @@
from tools import timer, trace from tools import timer, trace
from View.Tools.PamhyrPlot import PamhyrPlot from View.Tools.PamhyrPlot import PamhyrPlot
from matplotlib.backend_bases import MouseButton
from matplotlib import collections from matplotlib import collections
import numpy as np import numpy as np
@@ -60,7 +61,7 @@ class PlotXY(PamhyrPlot):
def onpick(self, event): def onpick(self, event):
if event.mouseevent.inaxes != self.canvas.axes: if event.mouseevent.inaxes != self.canvas.axes:
return return
if event.mouseevent.button.value != 1: if event.mouseevent.button != MouseButton.LEFT:
return return
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
@@ -136,6 +137,7 @@ class PlotXY(PamhyrPlot):
index = QItemSelection() index = QItemSelection()
if len(ind) == 0: if len(ind) == 0:
return return
ind = [i for i in ind if not self._table.isRowHidden(i)]
for i in ind: for i in ind:
index.append(QItemSelectionRange(self._table.model().index(i, 0))) index.append(QItemSelectionRange(self._table.model().index(i, 0)))
selection.select( selection.select(
@@ -149,18 +151,7 @@ class PlotXY(PamhyrPlot):
self._table.scrollTo(self._table.model().index(ind[-1], 0)) self._table.scrollTo(self._table.model().index(ind[-1], 0))
def _select_range_in_table(self, ind1, ind2): def _select_range_in_table(self, ind1, ind2):
if self._table is not None: self._select_in_table(list(range(ind1, ind2 + 1)))
self._table.setFocus()
selection = self._table.selectionModel()
index = QItemSelection(self._table.model().index(ind1, 0),
self._table.model().index(ind2, 0))
selection.select(
index,
QItemSelectionModel.Rows |
QItemSelectionModel.ClearAndSelect |
QItemSelectionModel.Select
)
self._table.scrollTo(self._table.model().index(ind2, 0))
@timer @timer
def redraw(self, data): def redraw(self, data):
@@ -189,10 +180,14 @@ class PlotXY(PamhyrPlot):
def draw_xy(self): def draw_xy(self):
self.line_xy = [] self.line_xy = []
for xy in zip(self.data.get_x(), self.data.get_y()): for row, profile in enumerate(self.data.profiles):
self.line_xy.append(np.column_stack(xy)) hidden = self._table is not None and self._table.isRowHidden(row)
self.line_xy.append(
np.empty((0, 2)) if hidden or len(profile) == 0
else np.column_stack((profile.x(), profile.y()))
)
self._colors, self._style = self.color_hightlight() self._colors, self._style = self.color_highlight()
self.line_xy_collection = collections.LineCollection( self.line_xy_collection = collections.LineCollection(
self.line_xy, self.line_xy,
colors=self._colors, colors=self._colors,
@@ -201,13 +196,16 @@ class PlotXY(PamhyrPlot):
) )
self.canvas.axes.add_collection(self.line_xy_collection) self.canvas.axes.add_collection(self.line_xy_collection)
def color_hightlight(self): def color_highlight(self):
rows = sorted(list( rows = sorted(list(
set( set(
(i.row() for i in self.parent.tableView.selectedIndexes()) (i.row() for i in self.parent.tableView.selectedIndexes())
) )
)) ))
colors = [self.color_plot for row in range(len(self._data))] colors = [
"darkorange" if profile.interpolated else self.color_plot
for profile in self._data.profiles
]
style = ["-" for row in range(len(self._data))] style = ["-" for row in range(len(self._data))]
if len(rows) > 0: if len(rows) > 0:
for row in rows: for row in rows:
@@ -320,7 +318,7 @@ class PlotXY(PamhyrPlot):
def update_current(self): def update_current(self):
if self._current_data_update: if self._current_data_update:
self._colors, self._style = self.color_hightlight() self._colors, self._style = self.color_highlight()
self.line_xy_collection.set_colors(self._colors) self.line_xy_collection.set_colors(self._colors)
self.line_xy_collection.set_linestyle(self._style) self.line_xy_collection.set_linestyle(self._style)
+4 -3
View File
@@ -22,6 +22,7 @@ from math import dist, sqrt
from tools import timer, trace from tools import timer, trace
from View.Tools.PamhyrPlot import PamhyrPlot from View.Tools.PamhyrPlot import PamhyrPlot
from matplotlib.backend_bases import MouseButton
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, QCoreApplication, QItemSelectionModel, Qt, QCoreApplication, QItemSelectionModel,
@@ -111,7 +112,7 @@ class Plot(PamhyrPlot):
def onpick(self, event): def onpick(self, event):
if event.mouseevent.inaxes != self.canvas.axes: if event.mouseevent.inaxes != self.canvas.axes:
return return
if event.mouseevent.button.value != 1: if event.mouseevent.button != MouseButton.LEFT:
return return
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
@@ -126,11 +127,11 @@ class Plot(PamhyrPlot):
def onclick(self, event): def onclick(self, event):
if event.inaxes != self.canvas.axes: if event.inaxes != self.canvas.axes:
return return
if event.button.value == 1: if event.button not in (MouseButton.MIDDLE, MouseButton.RIGHT):
return return
z = self._get_z_from_click(event) z = self._get_z_from_click(event)
if z < self.data.z_min() or event.button.value == 2: if z < self.data.z_min() or event.button == MouseButton.MIDDLE:
self.highlight = None self.highlight = None
self.draw_highligth() self.draw_highligth()
self.update_idle() self.update_idle()
+106 -1
View File
@@ -37,6 +37,11 @@ class SetDataCommand(QUndoCommand):
self._index = index self._index = index
self._old = old_value self._old = old_value
self._new = self.type(new_value) self._new = self.type(new_value)
if self._profile.old_interpolated == "":
if self._profile.interpolated:
self._profile.old_interpolated = "interpolated"
else:
self._profile.old_interpolated = "not_interpolated"
class SetXCommand(SetDataCommand): class SetXCommand(SetDataCommand):
@@ -46,10 +51,19 @@ class SetXCommand(SetDataCommand):
def undo(self): def undo(self):
self._profile.point(self._index).x = self._old self._profile.point(self._index).x = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.point(self._index).x = self._new self._profile.point(self._index).x = self._new
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -60,10 +74,19 @@ class SetYCommand(SetDataCommand):
def undo(self): def undo(self):
self._profile.point(self._index).y = self._old self._profile.point(self._index).y = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.point(self._index).y = self._new self._profile.point(self._index).y = self._new
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -74,10 +97,19 @@ class SetZCommand(SetDataCommand):
def undo(self): def undo(self):
self._profile.point(self._index).z = self._old self._profile.point(self._index).z = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.point(self._index).z = self._new self._profile.point(self._index).z = self._new
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -89,9 +121,20 @@ class SetNameCommand(SetDataCommand):
def undo(self): def undo(self):
self._profile.point(self._index).name = self._old self._profile.point(self._index).name = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified()
def redo(self): def redo(self):
self._profile.point(self._index).name = self._new self._profile.point(self._index).name = self._new
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified()
class AddCommand(QUndoCommand): class AddCommand(QUndoCommand):
@@ -104,6 +147,13 @@ class AddCommand(QUndoCommand):
def undo(self): def undo(self):
self._profile.delete_i([self._index]) self._profile.delete_i([self._index])
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
@@ -111,7 +161,8 @@ class AddCommand(QUndoCommand):
self._point = self._profile.insert(self._index) self._point = self._profile.insert(self._index)
else: else:
self._profile.insert_point(self._index, self._point) self._profile.insert_point(self._index, self._point)
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -125,10 +176,19 @@ class DelCommand(QUndoCommand):
def undo(self): def undo(self):
for point in self._points: for point in self._points:
point.set_as_not_deleted() point.set_as_not_deleted()
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.delete_points(self._points) self._profile.delete_points(self._points)
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -153,10 +213,19 @@ class SortCommand(QUndoCommand):
def undo(self): def undo(self):
self._profile.sort_with_indexes(self._indexes) self._profile.sort_with_indexes(self._indexes)
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.sort(self._column, self._reverse) self._profile.sort(self._column, self._reverse)
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -173,6 +242,13 @@ class MoveCommand(QUndoCommand):
self._profile.move_up_point(self._i) self._profile.move_up_point(self._i)
else: else:
self._profile.move_down_point(self._i) self._profile.move_down_point(self._i)
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
@@ -180,6 +256,8 @@ class MoveCommand(QUndoCommand):
self._profile.move_up_point(self._i) self._profile.move_up_point(self._i)
else: else:
self._profile.move_down_point(self._i) self._profile.move_down_point(self._i)
self._profile.increase_modification_count()
self._profile.interpolated = False
self._profile.modified() self._profile.modified()
@@ -191,10 +269,19 @@ class ReverseCommand(QUndoCommand):
def undo(self): def undo(self):
self._profile.reverse() self._profile.reverse()
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._profile.reverse() self._profile.reverse()
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -209,10 +296,19 @@ class PurgeCommand(QUndoCommand):
def undo(self): def undo(self):
for point in self._deleted: for point in self._deleted:
point.set_as_not_deleted() point.set_as_not_deleted()
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
self._deleted = self._profile.purge(self._np_purge) self._deleted = self._profile.purge(self._np_purge)
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
@@ -228,9 +324,18 @@ class PasteCommand(QUndoCommand):
def undo(self): def undo(self):
self._profile.delete_points(self._points) self._profile.delete_points(self._points)
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
self._profile.modified() self._profile.modified()
def redo(self): def redo(self):
for point in self._points: for point in self._points:
self._profile.insert_point(self._row, point) self._profile.insert_point(self._row, point)
self._profile.interpolated = False
self._profile.increase_modification_count()
self._profile.modified() self._profile.modified()
+3
View File
@@ -45,6 +45,9 @@ class GeometryTranslate(MainTranslate):
self._dict["cross_sections"] = _translate("Geometry", "cross-sections") self._dict["cross_sections"] = _translate("Geometry", "cross-sections")
self._dict["profile"] = _translate("Geometry", "cross-section") self._dict["profile"] = _translate("Geometry", "cross-section")
self._dict["profiles"] = _translate("Geometry", "cross-sections") self._dict["profiles"] = _translate("Geometry", "cross-sections")
self._dict["show_interpolated_profiles"] = _translate(
"Geometry", "Show interpolated profiles"
)
self._dict["cross_section_enabled_count"] = _translate( self._dict["cross_section_enabled_count"] = _translate(
"Geometry", "Cross-section enabled ({count} disabled)" "Geometry", "Cross-section enabled ({count} disabled)"
) )
+33 -4
View File
@@ -41,6 +41,7 @@ class SetDataCommand(QUndoCommand):
self._index = index self._index = index
self._old = old_value self._old = old_value
self._new = self.type(new_value) self._new = self.type(new_value)
self._profile = self._reach.profile(self._index)
class SetNameCommand(SetDataCommand): class SetNameCommand(SetDataCommand):
@@ -48,24 +49,52 @@ class SetNameCommand(SetDataCommand):
self.type = str self.type = str
super(SetNameCommand, self).__init__( super(SetNameCommand, self).__init__(
reach, index, old_value, new_value) reach, index, old_value, new_value)
if self._profile.old_interpolated == "":
if self._profile.interpolated:
self._profile.old_interpolated = "interpolated"
else:
self._profile.old_interpolated = "not_interpolated"
def undo(self): def undo(self):
self._reach.profile(self._index).name = self._old self._profile.name = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
def redo(self): def redo(self):
self._reach.profile(self._index).name = self._new # Name setter will automatically update the interpolated attribute
self._profile.name = self._new
self._profile.increase_modification_count()
class SetRKCommand(SetDataCommand): class SetRKCommand(SetDataCommand):
def __init__(self, reach, index, old_value, new_value): def __init__(self, reach, index, old_value, new_value):
self.type = float self.type = float
super(SetRKCommand, self).__init__(reach, index, old_value, new_value) super(SetRKCommand, self).__init__(reach, index, old_value, new_value)
if self._profile.old_interpolated == "":
if self._profile.interpolated:
self._profile.old_interpolated = "interpolated"
else:
self._profile.old_interpolated = "not_interpolated"
def undo(self): def undo(self):
self._reach.profile(self._index).rk = self._old self._profile.rk = self._old
self._profile.decrease_modification_count()
if self._profile.modification_count == 0:
if self._profile.old_interpolated == "interpolated":
self._profile.interpolated = True
else:
self._profile.interpolated = False
self._profile.old_interpolated = ""
def redo(self): def redo(self):
self._reach.profile(self._index).rk = self._new # RK setter will automatically update the interpolated attribute
self._profile.rk = self._new
self._profile.increase_modification_count()
class SetEnabledCommand(QUndoCommand): class SetEnabledCommand(QUndoCommand):
+35
View File
@@ -127,6 +127,9 @@ class GeometryWindow(PamhyrWindow):
table.setAlternatingRowColors(True) table.setAlternatingRowColors(True)
def setup_checkbox(self): def setup_checkbox(self):
self.find(QCheckBox, "checkBox_show_interpolated").setText(
self._trad["show_interpolated_profiles"]
)
self._checkbox = self.find(QCheckBox, "checkBox_enabled") self._checkbox = self.find(QCheckBox, "checkBox_enabled")
self._set_checkbox_state() self._set_checkbox_state()
@@ -197,6 +200,18 @@ class GeometryWindow(PamhyrWindow):
self._status_label.setText(txt) self._status_label.setText(txt)
def setup_connections(self): def setup_connections(self):
self.find(QCheckBox, "checkBox_show_interpolated").toggled.connect(
self.update_interpolated_profiles_visibility
)
self.find(QCheckBox, "checkBox_show_interpolated").toggled.connect(
self.update_redraw
)
self._table.modelReset.connect(
self.update_interpolated_profiles_visibility)
self._table.rowsInserted.connect(
self.update_interpolated_profiles_visibility)
self._table.rowsRemoved.connect(
self.update_interpolated_profiles_visibility)
if self._study.is_read_only(): if self._study.is_read_only():
actions = { actions = {
"action_export": self.export_to_file, "action_export": self.export_to_file,
@@ -237,6 +252,24 @@ class GeometryWindow(PamhyrWindow):
def update(self): def update(self):
self._update() self._update()
def update_interpolated_profiles_visibility(self):
show_interpolated = self.find(
QCheckBox, "checkBox_show_interpolated"
).isChecked()
selection = self.tableView.selectionModel()
for row in range(self._table.rowCount()):
hidden = not show_interpolated \
and self._reach.profile(row).interpolated
self.tableView.setRowHidden(row, hidden)
if hidden:
selection.select(
self._table.index(row, 0),
QItemSelectionModel.Deselect | QItemSelectionModel.Rows
)
if selection.currentIndex().row() == row:
selection.setCurrentIndex(QModelIndex(),
QItemSelectionModel.NoUpdate)
def update_redraw(self): def update_redraw(self):
self._update(redraw=True) self._update(redraw=True)
@@ -248,6 +281,7 @@ class GeometryWindow(PamhyrWindow):
self.find(QAction, "action_meshing").setEnabled(enabled) self.find(QAction, "action_meshing").setEnabled(enabled)
def _update(self, redraw=False, propagate=True): def _update(self, redraw=False, propagate=True):
self.update_interpolated_profiles_visibility()
self.update_meshing_action() self.update_meshing_action()
if redraw: if redraw:
@@ -498,6 +532,7 @@ class GeometryWindow(PamhyrWindow):
data=self._reach, data=self._reach,
trad=self._trad, trad=self._trad,
toolbar=self._toolbar_ac, toolbar=self._toolbar_ac,
parent=self,
) )
self._plot_ac.draw() self._plot_ac.draw()
+2 -1
View File
@@ -18,6 +18,7 @@
from tools import timer from tools import timer
from View.Tools.PamhyrPlot import PamhyrPlot from View.Tools.PamhyrPlot import PamhyrPlot
from matplotlib.backend_bases import MouseButton
from PyQt5.QtCore import ( from PyQt5.QtCore import (
QCoreApplication, Qt, QCoreApplication, Qt,
@@ -243,7 +244,7 @@ class PlotRKC(PamhyrPlot):
def onpick(self, event): def onpick(self, event):
if event.mouseevent.inaxes != self.canvas.axes: if event.mouseevent.inaxes != self.canvas.axes:
return return
if event.mouseevent.button.value != 1: if event.mouseevent.button != MouseButton.LEFT:
return return
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
+12 -9
View File
@@ -20,6 +20,7 @@ import logging
import traceback import traceback
from datetime import date, time, datetime, timedelta from datetime import date, time, datetime, timedelta
from tools import parse_datetime
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
@@ -73,11 +74,12 @@ class TableModel(PamhyrTableModel):
if self._data.get_type_column(column) == float: if self._data.get_type_column(column) == float:
value = f"{v:.4f}" value = f"{v:.4f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date(int(v)) value = timestamp_to_old_pamhyr_date(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -96,7 +98,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
self._undo.push( self._undo.push(
@@ -184,17 +186,18 @@ class TableModel(PamhyrTableModel):
if len(data) == 0: if len(data) == 0:
return return
try:
rows = [self._data.new_from_data(header, d) for d in data]
except (ValueError, TypeError, IndexError) as error:
logger.warning(error)
return False
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push( self._undo.push(
PasteCommand( PasteCommand(
self._data, row, self._data, row,
list( rows
map(
lambda d: self._data.new_from_data(header, d),
data
)
)
) )
) )
+8 -1
View File
@@ -25,6 +25,8 @@ from PyQt5.QtGui import (
QKeySequence, QKeySequence,
) )
from Modules import Modules
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, QVariant, QAbstractTableModel, QCoreApplication, Qt, QVariant, QAbstractTableModel, QCoreApplication,
) )
@@ -110,7 +112,7 @@ class EditLateralContributionWindow(PamhyrWindow):
undo=self._undo_stack, undo=self._undo_stack,
trad=self._trad, trad=self._trad,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -146,6 +148,11 @@ class EditLateralContributionWindow(PamhyrWindow):
self._table.dataChanged.connect(self.update) self._table.dataChanged.connect(self.update)
self._table.layoutChanged.connect(self.update) self._table.layoutChanged.connect(self.update)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
self.plot.fit_to_data() self.plot.fit_to_data()
+3 -3
View File
@@ -127,7 +127,7 @@ class PlotXY(PamhyrPlot):
for xy in zip(self.data.get_x(), self.data.get_y()): for xy in zip(self.data.get_x(), self.data.get_y()):
self.line_xy.append(np.column_stack(xy)) self.line_xy.append(np.column_stack(xy))
self._colors, self._style = self.color_hightlight() self._colors, self._style = self.color_highlight()
self.line_xy_collection = collections.LineCollection( self.line_xy_collection = collections.LineCollection(
self.line_xy, self.line_xy,
colors=self._colors, colors=self._colors,
@@ -136,7 +136,7 @@ class PlotXY(PamhyrPlot):
) )
self.canvas.axes.add_collection(self.line_xy_collection) self.canvas.axes.add_collection(self.line_xy_collection)
def color_hightlight(self): def color_highlight(self):
rk_min, rk_max = (-1, -1) rk_min, rk_max = (-1, -1)
if self._highlight_data is not None: if self._highlight_data is not None:
rk_min, rk_max = self._highlight_data rk_min, rk_max = self._highlight_data
@@ -250,7 +250,7 @@ class PlotXY(PamhyrPlot):
def update_current(self): def update_current(self):
if self._current_data_update: if self._current_data_update:
self._colors, self._style = self.color_hightlight() self._colors, self._style = self.color_highlight()
self.line_xy_collection.set_colors(self._colors) self.line_xy_collection.set_colors(self._colors)
self.line_xy_collection.set_linestyle(self._style) self.line_xy_collection.set_linestyle(self._style)
@@ -20,6 +20,7 @@ import logging
import traceback import traceback
from datetime import date, time, datetime, timedelta from datetime import date, time, datetime, timedelta
from tools import parse_datetime
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
@@ -87,11 +88,12 @@ class TableModel(PamhyrTableModel):
if self._data._types[column] == float: if self._data._types[column] == float:
value = f"{v:.4f}" value = f"{v:.4f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date_adists(int(v)) value = timestamp_to_old_pamhyr_date_adists(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -110,7 +112,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
row = self.get_true_data_row(row) row = self.get_true_data_row(row)
@@ -29,6 +29,8 @@ from PyQt5.QtCore import (
Qt, QVariant, QAbstractTableModel, QCoreApplication, Qt, QVariant, QAbstractTableModel, QCoreApplication,
) )
from Modules import Modules
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialogButtonBox, QPushButton, QLineEdit, QDialogButtonBox, QPushButton, QLineEdit,
QFileDialog, QTableView, QAbstractItemView, QFileDialog, QTableView, QAbstractItemView,
@@ -57,6 +59,15 @@ class EditLateralContributionAdisTSWindow(PamhyrWindow):
name = trad[self._pamhyr_name] name = trad[self._pamhyr_name]
if self._data is not None: if self._data is not None:
pollutant_name = next(
(
pollutant.name
for pollutant in study.river.Pollutants.Pollutants_List
if pollutant.id == self._data.pollutant
),
None
)
if self._data.reach is not None: if self._data.reach is not None:
reach_name = next(filter( reach_name = next(filter(
lambda reach: reach.id == self._data.reach, lambda reach: reach.id == self._data.reach,
@@ -75,12 +86,10 @@ class EditLateralContributionAdisTSWindow(PamhyrWindow):
else: else:
end_rk = trad['not_associated'] end_rk = trad['not_associated']
name += ( name += f" - {study.name}"
f" - {study.name} - " + if pollutant_name is not None:
f"{reach_name} - " + name += f" - {pollutant_name}"
f"({begin_rk} - " + name += f" - {reach_name} - ({begin_rk} - {end_rk})"
f"{end_rk})"
)
super(EditLateralContributionAdisTSWindow, self).__init__( super(EditLateralContributionAdisTSWindow, self).__init__(
title=name, title=name,
@@ -125,7 +134,7 @@ class EditLateralContributionAdisTSWindow(PamhyrWindow):
undo=self._undo_stack, undo=self._undo_stack,
trad=self._trad, trad=self._trad,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -159,6 +168,11 @@ class EditLateralContributionAdisTSWindow(PamhyrWindow):
self._table.dataChanged.connect(self.update) self._table.dataChanged.connect(self.update)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
self.plot.fit_to_data() self.plot.fit_to_data()
@@ -0,0 +1,36 @@
# PlotXY.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 <https://www.gnu.org/licenses/>.
from View.Geometry.PlotXY import PlotXY as GeometryPlotXY
class PlotXY(GeometryPlotXY):
"""Geometry plot highlighting the lateral-contribution extent."""
def color_highlight(self):
rk_min, rk_max = (-1, -1)
if self.highlight is not None:
rk_min, rk_max = self.highlight
colors = [
self.color_plot_river_bottom for _ in range(len(self.data))
]
for index, rk in enumerate(self.data.get_rk_complete_profiles()):
if rk_min <= rk <= rk_max:
colors[index] = self.color_plot_current
styles = ["-" for _ in range(len(self.data))]
return colors, styles
@@ -45,7 +45,7 @@ from View.LateralContributionsAdisTS.Table import (
) )
from View.Tools.Plot.PamhyrCanvas import MplCanvas from View.Tools.Plot.PamhyrCanvas import MplCanvas
from View.Geometry.PlotXY import PlotXY from View.LateralContributionsAdisTS.PlotXY import PlotXY
from View.LateralContributionsAdisTS.translate \ from View.LateralContributionsAdisTS.translate \
import LCTranslate import LCTranslate
from View.LateralContributionsAdisTS.Edit.Window \ from View.LateralContributionsAdisTS.Edit.Window \
+52 -18
View File
@@ -61,6 +61,7 @@ from View.WaitingDialog import WaitingDialog
from View.MainWindowTabInfo import WidgetInfo from View.MainWindowTabInfo import WidgetInfo
from View.MainWindowTabChecker import WidgetChecker from View.MainWindowTabChecker import WidgetChecker
from View.MainWindowTabGraph import WidgetGraph
from View.Configure.Window import ConfigureWindow from View.Configure.Window import ConfigureWindow
from View.Study.Window import NewStudyWindow from View.Study.Window import NewStudyWindow
@@ -235,6 +236,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
def setup_tab(self): def setup_tab(self):
self.setup_tab_info() self.setup_tab_info()
self.setup_tab_graph()
self.setup_tab_checker() self.setup_tab_checker()
def setup_tab_info(self): def setup_tab_info(self):
@@ -261,6 +263,19 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
self._trad["tab_checker_name"] self._trad["tab_checker_name"]
) )
def setup_tab_graph(self):
tab_widget = self.findChild(QTabWidget, "tabWidget")
self._tab_widget_graph = WidgetGraph(
study=self._study, trad=self._trad, parent=self
)
tab_widget.insertTab(
0, self._tab_widget_graph,
self._trad["tab_graph_name"]
)
tab_widget.setCurrentWidget(self._tab_widget_graph)
def enable_actions(self, action: str, enable: bool): def enable_actions(self, action: str, enable: bool):
"""Enable of disable an action componant """Enable of disable an action componant
@@ -545,6 +560,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
self._do_propagate_update_rec(window, keys) self._do_propagate_update_rec(window, keys)
self._tab_widget_checker.update(modules=keys) self._tab_widget_checker.update(modules=keys)
self._tab_widget_graph.update()
def _do_propagate_update_info_tab(self, keys): def _do_propagate_update_info_tab(self, keys):
modules = Modules.modelling_list() modules = Modules.modelling_list()
@@ -570,6 +586,10 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
self._tab_widget_info.study = self._study self._tab_widget_info.study = self._study
self._tab_widget_info.update() self._tab_widget_info.update()
if self._tab_widget_graph.study != self._study:
self._tab_widget_graph.study = self._study
self._tab_widget_graph.update()
if self._tab_widget_checker.study != self._study: if self._tab_widget_checker.study != self._study:
self._tab_widget_checker.study = self._study self._tab_widget_checker.study = self._study
self._tab_widget_checker.update(modules=Modules.STUDY) self._tab_widget_checker.update(modules=Modules.STUDY)
@@ -898,13 +918,32 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
# MSG AND DIALOG # # MSG AND DIALOG #
################## ##################
def msg_select_reach(self): def ensure_current_reach(self):
self.message_box( """Offer a reach selection when needed;
window_title=self._trad["Warning"], return False on cancellation."""
text=self._trad["mb_select_reach_title"], if self._study is None:
informative_text=self._trad["mb_select_reach_msg"] return False
river = self._study.river
if river.has_current_reach():
return True
edges = list(river.edges())
if not edges:
QMessageBox.information(
self, self._trad["Select reach"], self._trad["no_reach"]
) )
self.open_network() return False
labels = [f"{edge.name or self._trad['reach']} (#{edge.id})"
for edge in edges]
selected, accepted = QInputDialog.getItem(
self, self._trad["Select reach"],
self._trad["reach"] + ":", labels, 0, False
)
if not accepted:
return False
river.set_current_reach(edges[labels.index(selected)])
self._tab_widget_graph.update()
self._propagate_update(key=Modules.NETWORK)
return True
def dialog_reopen_study(self): def dialog_reopen_study(self):
dlg = QMessageBox(self) dlg = QMessageBox(self)
@@ -1287,6 +1326,9 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
return return
self.network = NetworkWindow(study=self._study, parent=self) self.network = NetworkWindow(study=self._study, parent=self)
self.network.networkChanged.connect(
self._tab_widget_graph.update, Qt.QueuedConnection
)
self.network.show() self.network.show()
def open_scenarios(self): def open_scenarios(self):
@@ -1336,7 +1378,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
Returns: Returns:
Nothing Nothing
""" """
if (self._study is not None and self._study.river.has_current_reach()): if self.ensure_current_reach():
reach = self._study.river.current_reach().reach reach = self._study.river.current_reach().reach
if self.sub_window_exists( if self.sub_window_exists(
@@ -1356,8 +1398,6 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
) )
geometry.show() geometry.show()
return geometry return geometry
else:
self.msg_select_reach()
return None return None
def open_meshing(self): def open_meshing(self):
@@ -1441,8 +1481,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
strick.show() strick.show()
def open_frictions(self): def open_frictions(self):
if self._study is not None: if self.ensure_current_reach():
if self._study.river.has_current_reach():
reach = self._study.river.current_reach() reach = self._study.river.current_reach()
if self.sub_window_exists( if self.sub_window_exists(
@@ -1457,11 +1496,9 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
parent=self parent=self
) )
frictions.show() frictions.show()
else:
self.msg_select_reach()
def open_initial_conditions(self): def open_initial_conditions(self):
if self._study.river.has_current_reach(): if self.ensure_current_reach():
reach = self._study.river.current_reach() reach = self._study.river.current_reach()
if self.sub_window_exists( if self.sub_window_exists(
@@ -1477,8 +1514,6 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
parent=self parent=self
) )
initial.show() initial.show()
else:
self.msg_select_reach()
def open_additional_files(self): def open_additional_files(self):
if self._study is not None: if self._study is not None:
@@ -1552,8 +1587,7 @@ class ApplicationWindow(QMainWindow, ListedSubWindow, WindowToolKit):
) )
return return
if not self._study.river.has_current_reach(): if not self.ensure_current_reach():
self.msg_select_reach()
return return
reach = self._study.river.current_reach().reach reach = self._study.river.current_reach().reach
+139
View File
@@ -0,0 +1,139 @@
# MainWindowTabGraph.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.
# -*- coding: utf-8 -*-
from View.Tools.Plot.PamhyrCanvas import MplCanvas
from View.Tools.Plot.PamhyrToolbar import PamhyrPlotToolbar
from View.PlotXY import PlotXY
from View.Network.GraphWidget import GraphWidget
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QWidget, QGroupBox, QLabel, QHBoxLayout, QVBoxLayout, QSplitter,
)
class WidgetGraph(QWidget):
"""Read-only network graph displayed in the main window."""
def __init__(self, study=None, trad=None, parent=None):
super(WidgetGraph, self).__init__(parent)
self._study = study
self._trad = trad
self._plot_river = None
layout = QHBoxLayout(self)
self._splitter = QSplitter(Qt.Horizontal, self)
self._splitter.setChildrenCollapsible(False)
layout.addWidget(self._splitter)
self._graph_widget = None
self._graph_group = QGroupBox(
self._trad["graph_network_title"], self
)
self._graph_layout = QVBoxLayout(self._graph_group)
self._graph_title = QLabel(self._graph_group)
self._graph_title.setTextFormat(Qt.PlainText)
self._graph_title.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self._graph_title.setWordWrap(True)
self._graph_layout.addWidget(self._graph_title)
self._update_graph_title()
self._splitter.addWidget(self._graph_group)
self.canvas = MplCanvas(width=5, height=4, dpi=100)
self.canvas.setObjectName("canvas_graph")
self.toolbar = PamhyrPlotToolbar(
self.canvas, self,
items=["home", "zoom", "save", "iso", "back/forward", "move"]
)
self._plot_group = QGroupBox(
self._trad["graph_geographic_title"], self
)
self._plot_layout = QVBoxLayout(self._plot_group)
self._plot_layout.addWidget(self.toolbar)
self._plot_layout.addWidget(self.canvas, 1)
self._splitter.addWidget(self._plot_group)
self._splitter.setStretchFactor(0, 1)
self._splitter.setStretchFactor(1, 1)
self._splitter.setSizes([1000, 1000])
self.plot = PlotXY(
canvas=self.canvas,
data=None,
trad=self._trad,
toolbar=self.toolbar,
parent=self
)
@property
def study(self):
return self._study
@study.setter
def study(self, study):
self._study = study
self._update_graph_title()
def _update_graph_title(self):
self._graph_title.setText(
self._study.name if self._study is not None else ""
)
def update(self):
river = self._study.river if self._study is not None else None
view = None
if river is not None and river is self._plot_river:
axes = self.canvas.axes
if axes.has_data():
view = (axes.get_xlim(), axes.get_ylim(),
axes.get_aspect(), axes.get_adjustable())
self._update_graph_title()
if (self._graph_widget is not None
and (self._study is None
or self._graph_widget.graph is not self._study.river)):
self._graph_layout.removeWidget(self._graph_widget)
self._graph_widget.deleteLater()
self._graph_widget = None
if self._study is not None and self._graph_widget is None:
self._graph_widget = GraphWidget(
self._study.river,
parent=self,
only_display=True,
trad=self._trad,
)
self._graph_layout.addWidget(self._graph_widget, 1)
elif self._graph_widget is not None:
self._graph_widget.display_update()
data = None
geotiff = None
if self._study is not None:
data = self._study.river.enable_edges()
geotiff = self._study.river.geotiff
self.plot = PlotXY(
canvas=self.canvas,
data=data,
geotiff=geotiff,
trad=self._trad,
toolbar=self.toolbar,
parent=self
)
self.plot.update()
self._plot_river = river
if view is not None:
x_limits, y_limits, aspect, adjustable = view
self.canvas.axes.set_aspect(aspect, adjustable=adjustable)
self.canvas.axes.set_xlim(x_limits)
self.canvas.axes.set_ylim(y_limits)
self.canvas.draw_idle()
+47 -17
View File
@@ -448,6 +448,7 @@ class NewEdgeLine(QGraphicsItem):
class GraphWidget(QGraphicsView): class GraphWidget(QGraphicsView):
changeEdge = pyqtSignal(object) changeEdge = pyqtSignal(object)
changeNode = pyqtSignal(object) changeNode = pyqtSignal(object)
currentEdgeChanged = pyqtSignal()
def __init__(self, graph, parent=None, def __init__(self, graph, parent=None,
min_size=(400, 400), max_size=None, min_size=(400, 400), max_size=None,
@@ -476,6 +477,7 @@ class GraphWidget(QGraphicsView):
self.m_origin_x = 0.0 self.m_origin_x = 0.0
self.m_origin_y = 0.0 self.m_origin_y = 0.0
self.clicked = False self.clicked = False
self._panning = False
self.setup_scene(min_size, max_size, size) self.setup_scene(min_size, max_size, size)
@@ -491,8 +493,8 @@ class GraphWidget(QGraphicsView):
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.AnchorViewCenter) self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
self.scale(1, 1) self.scale(0.7, 0.7)
self.previousScale = 1 self.previousScale = 0.7
self.centerOn(1000.0, 1000.0) self.centerOn(1000.0, 1000.0)
if min_size: if min_size:
@@ -667,6 +669,13 @@ class GraphWidget(QGraphicsView):
Returns: Returns:
Nothing Nothing
""" """
# Scene items are destroyed by clear(); do not retain hover/selection
# references to them when refreshing another view of the same graph.
self._selected_item = None
self._selected_new_edge_src_node = None
self._current_edge = None
self._current_moved_node = None
self.tmp_line = None
self.scene().clear() self.scene().clear()
self.create_items() self.create_items()
@@ -875,6 +884,9 @@ class GraphWidget(QGraphicsView):
if previous_edge: if previous_edge:
previous_edge.update() previous_edge.update()
edge.update()
if previous_edge is not edge:
self.currentEdgeChanged.emit()
except Exception as e: except Exception as e:
logger.warning(str(e)) logger.warning(str(e))
@@ -937,6 +949,8 @@ class GraphWidget(QGraphicsView):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if self._only_display: if self._only_display:
if event.button() == Qt.LeftButton:
self._start_pan(event)
return return
if event.button() == Qt.RightButton \ if event.button() == Qt.RightButton \
@@ -959,6 +973,12 @@ class GraphWidget(QGraphicsView):
self._selected_new_edge_src_node = None self._selected_new_edge_src_node = None
items = self.items(event.pos()) items = self.items(event.pos())
if event.button() == Qt.LeftButton and not any(
isinstance(item, (NodeItem, EdgeItem, NodeText))
for item in items
):
self._start_pan(event)
return
if items and type(items[0]) is EdgeItem: if items and type(items[0]) is EdgeItem:
edge = items[0] edge = items[0]
if edge: if edge:
@@ -1007,7 +1027,10 @@ class GraphWidget(QGraphicsView):
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
self.clicked = False self.clicked = False
if self._only_display: if self._panning or self._only_display:
self._panning = False
self.viewport().unsetCursor()
event.accept()
return return
locked = self.graph._status.is_read_only() locked = self.graph._status.is_read_only()
@@ -1029,7 +1052,28 @@ class GraphWidget(QGraphicsView):
self.update() self.update()
super(GraphWidget, self).mouseReleaseEvent(event) super(GraphWidget, self).mouseReleaseEvent(event)
def _start_pan(self, event):
self.clicked = True
self._panning = True
self.m_origin_x = event.x()
self.m_origin_y = event.y()
self.viewport().setCursor(Qt.ClosedHandCursor)
event.accept()
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
if self._panning or self._only_display:
if self._panning and event.buttons() & Qt.LeftButton:
dx = event.x() - self.m_origin_x
dy = event.y() - self.m_origin_y
horizontal = self.horizontalScrollBar()
vertical = self.verticalScrollBar()
horizontal.setValue(horizontal.value() - dx)
vertical.setValue(vertical.value() - dy)
self.m_origin_x = event.x()
self.m_origin_y = event.y()
event.accept()
return
pos = self.mapToScene(event.pos()) pos = self.mapToScene(event.pos())
locked = self.graph._status.is_read_only() locked = self.graph._status.is_read_only()
@@ -1061,20 +1105,6 @@ class GraphWidget(QGraphicsView):
# If state is "move" # If state is "move"
if self._state == "move": if self._state == "move":
# Move on scene
if not self.selected_item():
if event.buttons() & Qt.LeftButton:
old_p = self.mapToScene(
int(self.m_origin_x), int(self.m_origin_y)
)
new_p = self.mapToScene(event.pos())
translation = new_p - old_p
self.translate(translation.x(), translation.y())
self.m_origin_x = event.x()
self.m_origin_y = event.y()
# Propagate event # Propagate event
self.update() self.update()
super(GraphWidget, self).mouseMoveEvent(event) super(GraphWidget, self).mouseMoveEvent(event)
+24 -5
View File
@@ -64,7 +64,17 @@ class SelectProfileDialog(PamhyrDialog):
self._reach = reach self._reach = reach
self._profile = None self._profile = None
self._profiles = self._reach.reach.enabled_profiles self._profiles = list(self._reach.reach.profiles)
structure_profiles = [
structure.input_section
for structure in study.river.hydraulic_structures.lst
if structure.input_reach is reach and not structure.is_deleted()
]
self._selectable_indices = [
index for index, profile in enumerate(self._profiles)
if 0 < index < len(self._profiles) - 1
and profile not in structure_profiles
]
self.setup_combobox() self.setup_combobox()
@@ -74,19 +84,28 @@ class SelectProfileDialog(PamhyrDialog):
list( list(
map( map(
lambda p: p.display_name(), lambda p: p.display_name(),
self._reach.reach.profiles[1:-1] self._profiles
) )
) )
) )
if len(self._profiles) == 0: combobox = self.find(QComboBox, "comboBox")
for index in range(len(self._profiles)):
if index not in self._selectable_indices:
item = combobox.model().item(index)
item.setFlags(item.flags() &
~(Qt.ItemIsEnabled | Qt.ItemIsSelectable))
combobox.setCurrentIndex(
self._selectable_indices[0] if self._selectable_indices else -1
)
self.find(QDialogButtonBox, "buttonBox")\ self.find(QDialogButtonBox, "buttonBox")\
.button(QDialogButtonBox.Ok)\ .button(QDialogButtonBox.Ok)\
.setEnabled(False) .setEnabled(bool(self._selectable_indices))
def accept(self): def accept(self):
index = self.find(QComboBox, "comboBox").currentIndex() index = self.find(QComboBox, "comboBox").currentIndex()
if not 0 <= index < len(self._profiles): if index not in self._selectable_indices:
return return
self._profile = self._profiles[index] self._profile = self._profiles[index]
+4
View File
@@ -58,6 +58,8 @@ _translate = QCoreApplication.translate
class NetworkWindow(PamhyrWindow): class NetworkWindow(PamhyrWindow):
networkChanged = pyqtSignal()
_pamhyr_ui = "Network" _pamhyr_ui = "Network"
_pamhyr_name = "River network" _pamhyr_name = "River network"
@@ -140,6 +142,8 @@ class NetworkWindow(PamhyrWindow):
self._graph_layout.addWidget(self._graph_widget) self._graph_layout.addWidget(self._graph_widget)
def setup_connections(self): def setup_connections(self):
self._undo_stack.indexChanged.connect(self.networkChanged)
self._graph_widget.currentEdgeChanged.connect(self.networkChanged)
self._nodes_model.dataChanged.connect(self.update) self._nodes_model.dataChanged.connect(self.update)
self._reachs_model.dataChanged.connect( self._reachs_model.dataChanged.connect(
self._graph_widget.display_update) self._graph_widget.display_update)
+6 -3
View File
@@ -76,10 +76,13 @@ class PlotRKC(PamhyrPlot):
@staticmethod @staticmethod
def reach_geometry(reach): def reach_geometry(reach):
"""Return geometry values for profiles present in the results.""" """Return geometry values for profiles present in the results."""
# The study's enabled profiles can change after a calculation. Keep
# coordinates in the same order as the result profiles and columns.
profiles = reach.profiles
return ( return (
reach.geometry.get_rk_enabled_profiles(), [profile.rk for profile in profiles],
reach.geometry.get_z_min_enabled_profiles(), [profile.geometry.z_min() for profile in profiles],
reach.geometry.get_z_max_enabled_profiles() [profile.geometry.z_max() for profile in profiles],
) )
@timer @timer
+2 -1
View File
@@ -24,6 +24,7 @@ import numpy as np
from tools import timer, trace, logger_exception from tools import timer, trace, logger_exception
from View.Tools.PamhyrPlot import PamhyrPlot from View.Tools.PamhyrPlot import PamhyrPlot
from matplotlib.backend_bases import MouseButton
from matplotlib import collections from matplotlib import collections
from PyQt5.QtCore import ( from PyQt5.QtCore import (
@@ -89,7 +90,7 @@ class PlotXY(PamhyrPlot):
def onpick(self, event): def onpick(self, event):
if event.mouseevent.inaxes != self.canvas.axes: if event.mouseevent.inaxes != self.canvas.axes:
return return
if event.mouseevent.button.value != 1: if event.mouseevent.button != MouseButton.LEFT:
return return
modifiers = QApplication.keyboardModifiers() modifiers = QApplication.keyboardModifiers()
+55
View File
@@ -0,0 +1,55 @@
# 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 <https://www.gnu.org/licenses/>.
# -*- coding: utf-8 -*-
from PyQt5.QtCore import QCoreApplication
from View.Tools.PamhyrTranslate import PamhyrTranslate
_translate = QCoreApplication.translate
class RunSolverTranslate(PamhyrTranslate):
def __init__(self):
super(RunSolverTranslate, self).__init__()
self._dict["invalid_options"] = _translate(
"Solver",
"Export cancelled: {key}: expected three digits, each 1 or 2, "
"Select valid options in the parameters (tab Solid Transport)."
)
self._dict["unsupported_transport_law"] = _translate(
"Solver",
"Export cancelled: {key}: unsupported transport law {value!r}. "
"Please choose another transport law from the list."
)
self._dict["warning"] = _translate("Solver", "Warning: {message}")
self._dict["incompatible_upstream_boundary"] = _translate(
"Solver",
"Export cancelled: REGIME=1 is incompatible with CONDAM=0. "
"A reflecting upstream boundary cannot impose the water depth."
)
self._dict["missing_solid_boundary"] = _translate(
"Solver",
"Export cancelled: sediment transport (TRASED) is enabled but "
"no solid boundary condition is defined at the upstream node "
"of reach(es): {reaches}. Add a solid boundary condition, or "
"disable sediment transport in the solver parameters."
)
self._dict["export_failed"] = _translate(
"Solver", "Export failed; task cancelled"
)
+346
View File
@@ -0,0 +1,346 @@
"""Rubar parameter descriptions (notice sections 3.1, 3.10 and annex 1).
Keys and values keep their historical database representation.
"""
from dataclasses import dataclass
from decimal import Decimal, DecimalException, InvalidOperation
from Solver.RubarBE import Rubar3
GROUPS = ('Temps et calcul', 'Hydraulique', 'Transport solide',
'Évolution du fond', 'Sauvegardes')
YES_NO = {'y': 'Oui', 'n': 'Non'}
ON_OFF = {'1': 'Oui', '0': 'Non'}
LAWS = {
'1': 'Meyer-Peter et Müller', '2': 'Engelund et Hansen', '3': 'Bagnold',
'8': 'Ackers et White (1973)', '9': 'Smart et Jaeggi', '10': 'van Rijn',
'11': 'Rickenmann', '12': 'Camenen et Larson', '13': 'Schoklitsch',
'14': 'Sato, Hikkawa et Ashida', '15': 'Recking et al. (2008)',
'16': 'Brownlie', '17': 'Laursen', '18': 'Ackers et White (1990)',
'19': 'Karim', '20': 'Karim et Kennedy', '21': 'Yang et Lim (2003)',
'22': 'Yang (1984)', '23': 'Yang (1979)', '24': 'Yang (1973)',
'25': 'van Rijn (1984) — suspension', '26': 'van Rijn (1984) — charriage',
'27': 'Recking et al. (2016)', '28': 'Piton et al. (2017)',
'29': 'Lefort (2007)', '30': 'Lefort (2015)',
}
OPTION_CHOICES = (
('Correction de pente',
{'1': 'Correction d’Ikeda', '2': 'Sans correction'},
'Permet d\'introduire que la répartition de la contrainte critique dans '
'une section en travers est influencée par la pente transversale'),
('Seuil critique', {'1': 'Fourni par l’utilisateur',
'2': 'Calculé par le diagramme de Shields'},
'Permet de choisir pour certaines formules de capacité, entre un seuil '
'critique fourni par l’utilisateur ou '
'un seuil critique calculé par le diagramme de Shields.'),
('Contrainte utilisée', {'1': 'Contrainte efficace',
'2': 'Contrainte non réduite'},
'Permet de choisir pour certaines formules de capacité, entre la '
'contrainte efficace calculée ou la contrainte non réduite.'),
)
@dataclass(frozen=True)
class Field:
key: str
group: int
label: str
help: str
kind: str = 'real'
choices: dict = None
minimum: float = 0
maximum: float = None
positive: bool = False
optional: bool = False
def fields():
"""Describe historical Rubar fields without changing their defaults."""
result = []
def add(key, group, label, help, **kwargs):
result.append(Field('rubarbe_' + key, group, label, help, **kwargs))
def choice(key, group, label, help, choices):
add(key, group, label, help, kind='choice', choices=choices)
add('cfl', 0, 'Nombre de Courant (CFL)',
'Limite supérieure du nombre de Courant, afin d\'assurer la stabilité '
'du schéma numérique ; comprise entre 0 et 1.',
positive=True, maximum=1)
for key, label, help in (
('tinit', 'Début du calcul',
'Doit correspondre aux conditions initiales.'),
('tmax', 'Fin du calcul', 'Doit être postérieure au début du calcul.'),
):
add(key, 0, label + ' (j:h:min:s ou s)', help, kind='time')
choice('iopdt', 0, 'Gestion du pas de temps',
'En mode mixte, le pas reste fixe jusqu’à la date de bascule.',
{'y': 'Automatique', 'n': 'Fixe',
'm': 'Mixte : fixe puis automatique'})
add('dt', 0, 'Pas de temps initial ou fixe (s)',
'Premier pas en mode automatique, pas demandé en mode fixe.',
positive=True)
add('tiopdt', 0, 'Fin de la période à pas fixe' + ' (j:h:min:s ou s)',
'Utilisée uniquement avec un pas de temps mixte.', kind='time')
choice('rep', 0, 'Reprendre un calcul',
'Nécessite les fichiers de reprise du calcul précédent.', YES_NO)
choice('condam', 1, 'Condition à la limite amont',
'Le choix doit correspondre aux données de la limite amont.',
{'0': 'Réflexion', '1': 'Débit imposé Q(t)',
'2': 'Cote imposée Z(t)', '4': 'Confluence'})
choice('condav', 1, 'Condition à la limite aval',
'Le choix doit correspondre aux données de la limite aval.',
{'0': 'Sortie libre', '1': 'Courbe de tarage Q(Z)',
'2': 'Réflexion',
'3': 'Cote imposée Z(t)', '4': 'Confluence / défluence',
'5': 'Régime uniforme', '7': 'Régime critique'})
choice('regime', 1, 'Imposer aussi le tirant d’eau amont',
'Complète le débit imposé ; incompatible avec une limite '
'amont de type "Réflexion".',
ON_OFF)
choice('iodev', 1, 'Déversement latéral',
'Nécessite les données de déversements latéraux.',
YES_NO)
for key, label, help in (
('iodebord', 'Formule Debord',
'Oui: utilise la formule Debord pour les lits composés.\nNon:'
' pas de perte de charge complémentaire entre lit mineur et moyen.'),
('iostockage', 'Stockage dans le lit majeur',
'Traite le lit majeur sous la berge la plus basse comme zone '
'de stockage.'),
):
choice(key, 1, label, help, YES_NO)
choice('optfpc', 1, 'Calcul du frottement',
'Strickler fourni ou frottement calculé à partir des sédiments.',
{'0': 'Coefficients de Strickler fournis', '1': 'Brownlie (1983)',
'2': 'Griffith (1981) - pour un fond mobile', '3': 'Karim (1995)',
'4': 'Wu et Wang (1999)', '5': 'Yu et Lim (2003)',
'6': 'Recking et al. (2008)', '7': 'Garde et Raju (1966)'})
for key, label in (
('ros', 'Masse volumique des sédiments (kg/m³)'),
('dm', 'Diamètre médian des sédiments (m)'),
('segma', 'Étendue granulométrique des sédiments (—)'),
):
add(key, 1, label,
'Utilisé pour le frottement calculé sans transport solide '
'(disponible uniquement si le transport solide est désactivé '
'et que le calcul du frottement se fait avec les sédiments).',
positive=True)
choice('trased', 2, 'Activer le transport solide',
'Fonction de RubarBE. L’évolution du fond se règle séparément.',
YES_NO)
add('sediment_ros', 2, 'Masse volumique des sédiments (kg/m³)',
'Masse volumique des grains.', positive=True)
add('sediment_por', 2, 'Porosité des sédiments (—)',
'Fraction du volume occupée par les vides : de 0 inclus à 1 exclu.',
positive=True, maximum=1)
choice('sediment_opts', 2, 'Loi de capacité solide',
'Formule de capacité solide ; choisir selon le domaine '
'physique.',
LAWS)
choice('sediment_unisol', 2, 'Unité des débits ou concentrations solides',
'Unité des débits ou concentrations solides.',
{'1': 'Débit massique (kg/s)', '2': 'Concentration (kg/m³)'})
choice('sediment_odchar', 2, 'Distance de chargement',
'Option de calcul de la ditance de chargement.',
{'0': 'Distance constante', '1': 'Loi de Han', '2': 'Wu et Wang'})
add('sediment_dcharg', 2, 'Distance de chargement constante (m)',
'Utilisée uniquement si la distance de chargement est constante.')
add('sediment_halfa', 2, 'Paramètre alpha de Han (m)',
'Utilisé uniquement avec la loi de chargement de Han.')
add('sediment_option', 2, 'Calcul de la contrainte critique',
'Trois choix indépendants, enregistrés ensemble dans OPTION.',
kind='options')
add('sediment_visc', 2, 'Viscosité ou seuil critique',
'Selon la configuration : seuil critique adimensionnel fourni, ou '
'viscosité cinématique de l’eau. À 15 °C : environ 1,30 × 10⁻⁶ m²/s. '
'Un changement de mode ne convertit pas la valeur.', positive=True)
choice('sediment_choixc', 2, 'Calcul de la contrainte hydrodynamique de '
'cisaillement',
'MPC : méthode des perpendiculaires confondues (notice §8.5).',
{'1': 'MPC', '2': 'Répartition uniforme',
'3': 'MPC — pente moyenne sur 3 segments',
'4': 'MPC — moyenne sur 3 segments',
'5': 'MPC — moyenne sur une distance de périmètre de '
'longueur égale à la hauteur d’eau',
'6': 'MPC — proportionnelle au diamètre de la couche '
'puissance 1/3',
'7': 'MPC — proportionnelle au Strickler puissance −3/2',
'8': 'MPC — proportionnelle à la hauteur d’eau'})
choice('sediment_capsol', 2, 'Calcul de la capacité solide',
'Les méthodes 2 et 3 nécessitent une répartition MPC.',
{'1': 'Contrainte moyenne, largeur active imposée',
'2': 'Contrainte moyenne, largeur active calculée',
'3': 'Répartition locale des contraintes'})
add('sediment_mult_1', 2, 'Multiplicateur de la capacité de chariage (—)',
'1 conserve la capacité calculée par la loi choisie.')
choice('sediment_defond', 3, 'Faire évoluer la géométrie du lit',
'Le transport solide peut être calculé sans déformation du lit.',
ON_OFF)
choice('sediment_demix', 3, 'Activer le démixage',
'Option pour le demixage de deux compartiments sédimentaires.',
ON_OFF)
for key, label in (
('dchard', 'Distance de chargement pour le diamètre de sédiment (m)'),
('dchars', 'Distance de chargement de l’étendue granulométrique des '
'sédiments (m)'),
):
add('sediment_' + key, 3, label,
'Utilisée uniquement lorsque le démixage est activé.')
choice('sediment_typdef', 3, 'Actualisation de la masse active',
'Actualise la masse active après une variation relative de 10⁻ⁿ.',
{'0': 'À chaque pas', '1': 'Variation de 10 %',
'2': 'Variation de 1 %', '3': 'Variation de 0,1 %',
'4': 'Variation de 0,01 %'})
choice('sediment_depot', 3, 'Méthode d\'application du dépôt',
'Méthodes de déformation de la section décrites au §8.5.3.',
{'1': 'Couches horizontales',
'2': 'Uniforme sur le périmètre mouillé',
'3': 'Inverse de la contrainte locale (1/τ)',
'4': 'Selon le seuil critique — tout le lit actif mouillé',
'5': 'Selon le seuil critique — une partie du lit actif mouillé',
'6': 'Méthode 6 — fonction des contraintes (notice §8.5.3)',
'7': 'Méthode 7 — fonction des contraintes (notice §8.5.3)',
'8': 'Méthode 8 — code cité dans l’annexe 1'})
choice('sediment_varcons', 3, 'Grandeurs conservées après déformation',
'Les autres grandeurs hydrauliques sont recalculées.',
{'1': 'Section mouillée et débit', '2': 'Niveau d’eau et débit',
'3': 'Niveau d’eau et vitesse'})
add('sediment_bmiu', 3, 'Tangente de l’angle de frottement interne (—)',
'Saisir tan(angle), et non l’angle en degrés, de la valeur moyenne '
'de la tangente de l\'angle de frottement interne de sédiments.',
positive=True)
for i, label in enumerate((
'Épaisseur de couche active : multiplicateur du d84',
'Épaisseur de fines infiltrantes : multiplicateur du d16',
'Coefficient de mélange des couches superficielles',
'Coefficient de création d’une couche de dépôt',
), 2):
add(f'sediment_mult_{i}', 3, label + ' (—)',
'Facultatif : laisser vide pour utiliser le comportement '
'du solveur.',
optional=True)
for key, label, help in (
('ts', 'Instant de sauvegarde pour reprise',
'Écrit les fichiers de reprise lorsque cet instant est dépassé.'),
('dtsauv', 'Intervalle de sauvegarde des profils',
'0 demande une sauvegarde à chaque pas de calcul.'),
('psave', 'Intervalle de sauvegarde des hydrogrammes',
'0 demande une sauvegarde à chaque pas de calcul.'),
):
add(key, 4, label + ' (j:h:min:s ou s)', help, kind='time')
for i, label in enumerate((
'Fichiers de reprise', 'Hydrogrammes aux abscisses choisies',
'Enveloppes des résultats', 'Profils aux instants choisis',
'Profils à intervalle fixe', 'Temps de dépassement des débits',
), 1):
choice(f'tf_{i}', 4, label,
'Les abscisses ou instants demandés '
'doivent être définis dans les données correspondantes.',
YES_NO)
for i in range(1, 4):
add(f'fdeb{i}', 4, f'Seuil de débit du front {i} (m³/s)',
'Débit pour lequel à chaque abscisse, est enregistré le temps '
'de premier dépassement (fichier TRAJEC si demandé).',
positive=True)
choice('iovis', 4, 'Sorties complémentaires',
'Largeurs et sorties complémentaires hydrauliques et '
'sédimentaires.',
YES_NO)
return {field.key: field for field in result}
FIELDS = fields()
def canonical(field, value):
value = str(value).strip()
if field.choices and 'y' in field.choices:
aliases = {'o': 'y', 'y': 'y', 'n': 'n', 'm': 'm'}
return aliases.get(value.lower(), value)
return value
def validate(field, value):
"""Return a storage value or raise a user-facing validation error."""
value = canonical(field, value)
if field.kind == 'choice':
if value not in field.choices:
raise ValueError('Choisissez une valeur dans la liste.')
return value
if field.kind == 'options':
if len(value) != 3 or any(c not in '12' for c in value):
raise ValueError('Renseignez les trois choix de contrainte.')
return value
if field.kind == 'text':
return value
if not value and field.optional:
return value
value = value.replace(',', '.').replace('d', 'E').replace('D', 'E')
try:
if field.kind == 'time' and ':' in value:
parts = value.split(':')
valid = all(p.isascii() and p.isdigit() for p in parts)
if len(parts) != 4 or not valid:
raise ValueError(
'Utilisez j:h:min:s ou un nombre de secondes.')
number = sum(Decimal(p) * factor for p, factor in
zip(parts, (86400, 3600, 60, 1)))
else:
number = Decimal(value)
except InvalidOperation:
raise ValueError('Saisissez un nombre fini.') from None
if not number.is_finite():
raise ValueError('Saisissez un nombre fini.')
if number < field.minimum or (field.positive and number == 0):
raise ValueError('La valeur doit être strictement positive.'
if field.positive else
'La valeur doit être positive ou nulle.')
if field.maximum is not None and number > field.maximum:
raise ValueError(f'La valeur ne doit pas dépasser {field.maximum}.')
if field.key == 'rubarbe_sediment_por' and number >= 1:
raise ValueError('La porosité doit être inférieure à 1.')
scientific = not field.key.startswith('rubarbe_sediment_')
try:
Rubar3._real(str(number), field.key, scientific=scientific)
except (ValueError, DecimalException, OverflowError):
raise ValueError(
'Valeur trop longue ou hors plage pour le format du solveur '
'(12 colonnes).' if scientific else
'Valeur trop précise ou hors plage pour le format du solveur '
'(10 colonnes).') from None
return value
def active(field, values):
"""Dependencies only disable editing; stored values are never erased."""
key = field.key.removeprefix('rubarbe_')
transport = values.get('rubarbe_trased', 'n').lower() in ('y', 'o')
if key.startswith('sediment_') and not transport:
return False
if key == 'tiopdt':
return values.get('rubarbe_iopdt', '').lower() == 'm'
if key in ('ros', 'dm', 'segma'):
return (not transport and
values.get('rubarbe_optfpc', '0') not in ('', '0'))
dependencies = {
'sediment_dcharg': ('sediment_odchar', ('0',)),
'sediment_halfa': ('sediment_odchar', ('1',)),
'sediment_dchard': ('sediment_demix', ('1',)),
'sediment_dchars': ('sediment_demix', ('1',)),
'sediment_varcons': ('sediment_defond', ('1',)),
'sediment_depot': ('sediment_defond', ('1',)),
}
if key in dependencies:
source, accepted = dependencies[key]
return values.get('rubarbe_' + source) in accepted
if key.startswith('fdeb'):
return values.get('rubarbe_tf_6', 'n').lower() in ('y', 'o')
return True
+365
View File
@@ -0,0 +1,365 @@
"""Grouped Rubar parameter editor, backed by the existing parameter list."""
from functools import partial
from PyQt5.QtCore import QCoreApplication, QEvent, Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import (
QComboBox, QFormLayout, QLabel, QLineEdit, QScrollArea,
QSizePolicy, QTabWidget, QVBoxLayout, QWidget,
)
from View.SolverParameters.RubarFields import (
FIELDS, GROUPS, OPTION_CHOICES, Field, active, canonical, validate,
)
from View.SolverParameters.UndoCommand import SetCommand
def tr(text):
return QCoreApplication.translate('RubarParameters', text)
class RubarForm(QWidget):
def __init__(self, parameters, undo, editable=True, parent=None):
super().__init__(parent)
self.parameters = parameters
self.stack = undo
self.editable = editable
self.editors = {}
self.labels = {}
self.errors = {}
self.descriptions = dict(FIELDS)
self.indices = {p.name: i for i, p in enumerate(parameters.parameters)}
self._refreshing = False
self._options_pending = False
layout = QVBoxLayout(self)
intro = QLabel(tr('Les valeurs valides sont appliquées à l’étude. '
'Pensez à enregistrer l’étude pour les conserver.'))
intro.setWordWrap(True)
layout.addWidget(intro)
self.notice = QLabel()
self.notice.setWordWrap(True)
self.notice.setTextFormat(Qt.PlainText)
layout.addWidget(self.notice)
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
forms = []
for group in GROUPS:
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
form.setVerticalSpacing(12)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(page)
self.tabs.addTab(scroll, tr(group))
forms.append(form)
# Metadata order is stable even when stored parameter order changes.
keys = [key for key in FIELDS if key in self.indices]
keys += [key for key in self.indices if key not in FIELDS]
for key in keys:
field = self.descriptions.setdefault(key, Field(
key, 0, key, 'Paramètre supplémentaire conservé tel quel.',
kind='text'))
self._add_field(field, forms[field.group])
self.help = QLabel(tr(
'Sélectionnez un réglage pour afficher son aide.'))
self.help.setWordWrap(True)
self.help.setTextFormat(Qt.PlainText)
self.help.setMinimumHeight(60)
layout.addWidget(self.help)
self.stack.indexChanged.connect(self.refresh)
self.refresh()
def _add_field(self, field, form):
key = field.key
container = QWidget()
box = QVBoxLayout(container)
box.setContentsMargins(0, 0, 0, 0)
if field.kind == 'options':
editor = QWidget()
subform = QFormLayout(editor)
subform.setContentsMargins(0, 0, 0, 0)
self.option_editors = []
for title, choices, tooltip in OPTION_CHOICES:
combo = QComboBox()
combo.setSizeAdjustPolicy(
QComboBox.AdjustToMinimumContentsLengthWithIcon)
combo.addItem(tr('À renseigner'), None)
for value, label in choices.items():
combo.addItem(tr(label), value)
combo.currentIndexChanged.connect(self._commit_options)
combo.setProperty('parameter', key)
combo.installEventFilter(self)
combo.setToolTip(tr(tooltip) + '\n' + key)
subform.addRow(tr(title), combo)
self.option_editors.append(combo)
elif field.kind == 'choice':
editor = QComboBox()
editor.setSizeAdjustPolicy(
QComboBox.AdjustToMinimumContentsLengthWithIcon)
for value, label in field.choices.items():
editor.addItem(tr(label), value)
editor.currentIndexChanged.connect(
partial(self._choice_changed, key))
else:
editor = QLineEdit()
editor.setPlaceholderText(tr('Valeur du solveur') if field.optional
else tr('Saisir une valeur'))
editor.textEdited.connect(partial(self._check_draft, key))
editor.editingFinished.connect(partial(self._finish_text, key))
editor.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
editor.setObjectName(key)
editor.setProperty('parameter', key)
editor.installEventFilter(self)
editor.setToolTip(
tr(field.help) + '\n' + key + '\nNotice §3.1 / §3.10')
box.addWidget(editor)
error = QLabel()
error.setWordWrap(True)
error.setTextFormat(Qt.PlainText)
error.setStyleSheet('color: #b42318;')
box.addWidget(error)
label = QLabel(tr(field.label))
label.setWordWrap(True)
label.setFixedWidth(310)
label.setMinimumHeight(label.heightForWidth(310))
label.setBuddy(editor)
label.setToolTip(editor.toolTip())
form.addRow(label, container)
self.editors[key] = editor
self.labels[key] = label
self.errors[key] = error
def eventFilter(self, watched, event):
if event.type() in (QEvent.FocusIn, QEvent.Enter):
key = watched.property('parameter')
if key:
field = self.descriptions[key]
self.help.setText(tr(field.help) + '\n' + key)
# Use the model undo stack even inside a line editor.
if event.type() in (QEvent.ShortcutOverride, QEvent.KeyPress):
if (event.matches(QKeySequence.Undo) or
event.matches(QKeySequence.Redo)):
if event.type() == QEvent.KeyPress:
if event.matches(QKeySequence.Undo):
self.undo()
else:
self.redo()
event.accept()
return True
return super().eventFilter(watched, event)
def values(self):
return {p.name: str(p.value) for p in self.parameters.parameters}
def _error(self, key, message):
self.errors[key].setText(message)
self.errors[key].setVisible(bool(message))
self.editors[key].setStyleSheet(
'QLineEdit { border: 1px solid #b42318; }' if message else '')
def _validated(self, key, value):
try:
value = validate(self.descriptions[key], value)
except ValueError as error:
self._error(key, tr(str(error)) + tr(' Valeur non enregistrée.'))
return None
self._error(key, '')
return value
def _check_draft(self, key, value):
self._validated(key, value)
def _finish_text(self, key):
editor = self.editors[key]
if editor.isModified():
if self.commit(key, editor.text()):
editor.setModified(False)
def _choice_changed(self, key, index):
if not self._refreshing:
value = self.editors[key].itemData(index)
if value is not None:
self.commit(key, value)
def _commit_options(self):
if self._refreshing:
return
values = [combo.currentData() for combo in self.option_editors]
key = 'rubarbe_sediment_option'
if any(value is None for value in values):
self._options_pending = True
self._error(key, tr(
'Renseignez les trois choix. Valeur non enregistrée.'))
else:
self._options_pending = False
self.commit(key, ''.join(values))
def apply_pending(self):
"""Commit focused text before Ctrl+S; leave invalid drafts visible."""
values = self.values()
if (values.get('rubarbe_condam') == '0' and
values.get('rubarbe_regime') == '1'):
self.tabs.setCurrentIndex(1)
self.editors['rubarbe_condam'].setFocus()
return False
for key, editor in self.editors.items():
if (isinstance(editor, QLineEdit) and editor.isModified() and
active(self.descriptions[key], self.values())):
if not self.commit(key, editor.text()):
self.tabs.setCurrentIndex(self.descriptions[key].group)
editor.setFocus()
return False
if (self._options_pending and
active(FIELDS['rubarbe_sediment_option'], self.values())):
self.tabs.setCurrentIndex(2)
self.option_editors[0].setFocus()
return False
return True
def commit(self, key, value):
field = self.descriptions[key]
if not self.editable or not active(field, self.values()):
return False
value = self._validated(key, value)
if value is None:
return False
values = self.values()
values[key] = value
if (key in ('rubarbe_condam', 'rubarbe_regime') and
values.get('rubarbe_condam') == '0' and
values.get('rubarbe_regime') == '1'):
self.refresh()
return False
if isinstance(self.editors[key], QLineEdit):
self.editors[key].setModified(False)
if self.values()[key] != value:
self.stack.push(SetCommand(self.parameters, self.indices[key],
'value', value))
elif isinstance(self.editors[key], QLineEdit):
self.editors[key].setText(value)
return True
def refresh(self, *args):
self._refreshing = True
values = self.values()
for key, editor in self.editors.items():
field = self.descriptions[key]
value = canonical(field, values[key])
enabled = active(field, values)
editor.setEnabled(self.editable and enabled)
self.labels[key].setEnabled(enabled)
if isinstance(editor, QLineEdit) and editor.isModified():
self._validated(key, editor.text())
continue
if field.kind == 'options' and self._options_pending:
self.errors[key].setVisible(enabled)
continue
if field.kind == 'options':
valid = len(value) == 3 and all(c in '12' for c in value)
for i, combo in enumerate(self.option_editors):
index = combo.findData(value[i]) if valid else 0
combo.setCurrentIndex(index)
elif isinstance(editor, QComboBox):
# Display unknown historical values without replacing them.
for i in reversed(range(editor.count())):
if editor.itemData(i) is None:
editor.removeItem(i)
index = editor.findData(value)
if index < 0:
editor.addItem(tr('Valeur non reconnue : ') + value, None)
index = editor.count() - 1
editor.setCurrentIndex(index)
else:
editor.setText(values[key])
try:
validate(field, values[key])
self._error(key, '')
except ValueError as error:
self._error(key, tr('Valeur enregistrée à vérifier : ') +
values[key] + ' — ' + tr(str(error)))
self.errors[key].setVisible(
enabled and bool(self.errors[key].text()))
self._update_context(values)
for key, forbidden, blocked in (
('rubarbe_regime', '1', values.get('rubarbe_condam') == '0'),
('rubarbe_condam', '0', values.get('rubarbe_regime') == '1'),
):
editor = self.editors.get(key)
if editor is None:
continue
index = editor.findData(forbidden)
if index >= 0:
editor.model().item(index).setEnabled(not blocked)
self._refreshing = False
def _update_context(self, values):
option = values.get('rubarbe_sediment_option', '')
label = self.labels.get('rubarbe_sediment_visc')
if label is not None:
if len(option) == 3 and all(c in '12' for c in option):
title = ('Seuil critique adimensionnel (—)' if option[1] == '1'
else 'Viscosité cinématique de l’eau (m²/s)')
label.setText(tr(title))
else:
label.setText(tr(
'Viscosité / seuil : préciser les trois choix'))
warnings = []
if not self.editable:
warnings.append(tr('Étude en lecture seule.'))
transport = values.get('rubarbe_trased', '').lower() in ('y', 'o')
if not transport:
warnings.append(tr('Transport solide désactivé : les réglages '
'sédimentaires sont conservés mais inactifs.'))
elif (values.get('rubarbe_sediment_capsol') in ('2', '3') and
values.get('rubarbe_sediment_choixc') == '2'):
warnings.append(tr(
'La capacité solide choisie nécessite une méthode MPC ; '
'la répartition uniforme est incompatible.'))
if (values.get('rubarbe_condam') == '0' and
values.get('rubarbe_regime') == '1'):
warnings.append(tr('Une limite amont réfléchissante ne permet pas '
'd’imposer aussi le tirant d’eau.'))
try:
from tools import old_pamhyr_date_to_timestamp
def seconds(value):
return (old_pamhyr_date_to_timestamp(value) if ':' in value
else float(value.replace(',', '.')))
start = seconds(values['rubarbe_tinit'])
if seconds(values['rubarbe_tmax']) <= start:
warnings.append(tr('La fin du calcul doit suivre son début.'))
except (KeyError, ValueError):
pass
if transport and len(option) == 3 and option[1] == '2':
try:
if float(values.get('rubarbe_sediment_visc', '0')) > 0.001:
warnings.append(tr(
'Viscosité élevée pour de l’eau : vérifiez la valeur, '
'qui n’a pas été convertie lors du changement '
'de mode.'))
except ValueError:
pass
self.notice.setText('\n'.join(warnings))
self.notice.setVisible(bool(warnings))
def undo(self):
if self.editable:
self._options_pending = False
for editor in self.editors.values():
if isinstance(editor, QLineEdit):
editor.setModified(False)
self.stack.undo()
self.refresh()
def redo(self):
if self.editable:
self._options_pending = False
for editor in self.editors.values():
if isinstance(editor, QLineEdit):
editor.setModified(False)
self.stack.redo()
self.refresh()
+51 -1
View File
@@ -45,6 +45,36 @@ logger = logging.getLogger()
_translate = QCoreApplication.translate _translate = QCoreApplication.translate
class ValueDelegate(QItemDelegate):
"""Use a combo box for solver parameters backed by an index."""
def createEditor(self, parent, option, index):
choices = index.model().value_choices(index)
if choices is None:
return QLineEdit(parent)
editor = QComboBox(parent)
for value, label in choices.items():
editor.addItem(label, value)
return editor
def setEditorData(self, editor, index):
if isinstance(editor, QComboBox):
current = index.model().raw_value(index)
editor.setCurrentIndex(editor.findData(current))
else:
editor.setText(str(index.model().raw_value(index)))
def setModelData(self, editor, model, index):
if isinstance(editor, QComboBox):
model.setData(index, editor.currentData())
else:
model.setData(index, editor.text())
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
class TableModel(PamhyrTableModel): class TableModel(PamhyrTableModel):
def _setup_lst(self): def _setup_lst(self):
self._tab = self._opt_data self._tab = self._opt_data
@@ -68,6 +98,10 @@ class TableModel(PamhyrTableModel):
elif cname == "value": elif cname == "value":
value = self._lst.get(row)[cname] value = self._lst.get(row)[cname]
choices = self.value_choices(index)
if choices is not None:
return choices.get(value, value)
if value in self._trad.get_dict("yes_no"): if value in self._trad.get_dict("yes_no"):
value = self._trad.get_dict("yes_no")[value] value = self._trad.get_dict("yes_no")[value]
@@ -79,7 +113,14 @@ class TableModel(PamhyrTableModel):
if not index.isValid() or role != Qt.EditRole: if not index.isValid() or role != Qt.EditRole:
return False return False
if self.is_same_data(index, value): choices = self.value_choices(index)
if choices is not None:
reverse_choices = {label: key for key, label in choices.items()}
value = reverse_choices.get(value, value)
if str(self.raw_value(index)) == str(value):
return False
elif self.is_same_data(index, value):
return False return False
row = index.row() row = index.row()
@@ -103,6 +144,15 @@ class TableModel(PamhyrTableModel):
self.dataChanged.emit(index, index) self.dataChanged.emit(index, index)
return True return True
def value_choices(self, index):
if not index.isValid():
return None
name = self._lst.get(index.row())["name"]
return self._trad.get_dict("indexed_values").get(name)
def raw_value(self, index):
return self._lst.get(index.row())["value"]
def undo(self): def undo(self):
self._undo.undo() self._undo.undo()
self.layoutChanged.emit() self.layoutChanged.emit()
+26 -4
View File
@@ -41,7 +41,8 @@ from PyQt5.QtWidgets import (
) )
from View.SolverParameters.UndoCommand import * from View.SolverParameters.UndoCommand import *
from View.SolverParameters.Table import TableModel from View.SolverParameters.Table import TableModel, ValueDelegate
from View.SolverParameters.RubarForm import RubarForm
from View.SolverParameters.translate import ParamTranslate from View.SolverParameters.translate import ParamTranslate
from Solver.Solvers import solver_long_name, solver_type_list from Solver.Solvers import solver_long_name, solver_type_list
@@ -62,7 +63,7 @@ class SolverParametersWindow(PamhyrWindow):
title=name, title=name,
study=study, study=study,
config=config, config=config,
options=['copy'], options=['undo', 'copy'],
trad=trad, trad=trad,
parent=parent parent=parent
) )
@@ -88,6 +89,15 @@ class SolverParametersWindow(PamhyrWindow):
editable_headers = [] editable_headers = []
for st in solver_type_list: for st in solver_type_list:
if st in ('rubar3', 'rubarbe'):
widget = RubarForm(
self._study.river.get_params(st), self._undo_stack[st],
editable=self._study.is_editable(),
)
widget.setObjectName(f"tab_{st}")
self._table[st] = widget
self._tab_widget.addTab(widget, solver_long_name[st])
continue
# Create widgets # Create widgets
widget = QWidget() widget = QWidget()
grid = QGridLayout() grid = QGridLayout()
@@ -103,6 +113,7 @@ class SolverParametersWindow(PamhyrWindow):
opt_data=st, opt_data=st,
table_headers=self._trad.get_dict("table_headers"), table_headers=self._trad.get_dict("table_headers"),
editable_headers=editable_headers, editable_headers=editable_headers,
delegates={"value": ValueDelegate(table)},
trad=self._trad, trad=self._trad,
undo=self._undo_stack[st], undo=self._undo_stack[st],
) )
@@ -134,8 +145,19 @@ class SolverParametersWindow(PamhyrWindow):
tab = self.current_tab() tab = self.current_tab()
self._table[tab].redo() self._table[tab].redo()
def _save(self, source):
for editor in self._table.values():
if isinstance(editor, RubarForm) and not editor.apply_pending():
self._tab_widget.setCurrentWidget(editor)
return
return super()._save(source)
def _copy(self): def _copy(self):
logger.info("TODO: copy") editor = self.focusWidget()
if isinstance(editor, QLineEdit):
editor.copy()
def _paste(self): def _paste(self):
logger.info("TODO: paste") editor = self.focusWidget()
if isinstance(editor, QLineEdit) and self._study.is_editable():
editor.paste()
+24
View File
@@ -50,6 +50,30 @@ class ParamTranslate(MainTranslate):
_translate("SolverParameters", "N"): "n", _translate("SolverParameters", "N"): "n",
} }
self._sub_dict["indexed_values"] = {
"mage_shields_critique": {
"1": _translate("SolverParameters", "Constant (0.047)"),
"2": _translate("SolverParameters", "Soulsby (1997)"),
"3": _translate("SolverParameters", "Camenen (2012)"),
},
"mage_shields_correction": {
"0": _translate("SolverParameters", "No correction"),
"1": _translate("SolverParameters", "Effective Shields"),
},
"mage_capacite_solide": {
"1": _translate(
"SolverParameters", "Meyer-Peter & Muller (1948)"
),
"2": _translate(
"SolverParameters", "Camenen & Larson (2005)"
),
"3": _translate(
"SolverParameters", "Engelund & Hansen (1967)"
),
"4": _translate("SolverParameters", "Recking (2013)"),
},
}
self._sub_dict["names"] = { self._sub_dict["names"] = {
"all_init_time": "all_init_time":
_translate("SolverParameters", _translate("SolverParameters",
+78 -3
View File
@@ -19,7 +19,7 @@
import logging import logging
from datetime import datetime from datetime import datetime
from tools import get_user_name from tools import get_user_name, parse_datetime, format_datetime
from Modules import Modules from Modules import Modules
from Model.Study import Study from Model.Study import Study
@@ -34,7 +34,7 @@ from PyQt5.QtGui import (
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QRadioButton, QLabel, QDateTimeEdit, QRadioButton, QLabel, QDateTimeEdit,
QTextEdit, QPlainTextEdit, QTextBrowser, QTextEdit, QPlainTextEdit, QTextBrowser,
QPushButton, QPushButton, QMessageBox,
) )
_translate = QCoreApplication.translate _translate = QCoreApplication.translate
@@ -155,7 +155,80 @@ All right reserved.
self.find(QLabel, "label_date").setEnabled(True) self.find(QLabel, "label_date").setEnabled(True)
self.find(QDateTimeEdit, "dateTimeEdit_date").setEnabled(True) self.find(QDateTimeEdit, "dateTimeEdit_date").setEnabled(True)
def _close_study_editors(self):
def close_children(parent):
for _, window in list(parent.sub_win_list):
if window is self:
continue
if not close_children(window):
return False
if not window.close():
return False
undo = getattr(window, "_undo_stack", None)
if undo is not None:
undo.clear()
window.deleteLater()
return True
return close_children(self.parent)
def accept(self): def accept(self):
preserve_dates = False
if (
self._study is not None
and self._study.time_system == "date"
and self.get_radio_button("radioButton_date")
):
old_date = parse_datetime(self._study.date)
new_date = parse_datetime(
self.get_datetime_edit("dateTimeEdit_date")
)
if new_date != old_date:
dialog = QMessageBox(self)
dialog.setWindowTitle(_translate(
"Study", "Change simulation start date?"
))
dialog.setText(_translate(
"Study", "How should the data dates change?"
))
dialog.setInformativeText(_translate(
"Study",
"Previous start: {old}\nNew start: {new}\n\n"
"Shift dates: keep elapsed simulation times unchanged.\n"
"Keep dates: adjust elapsed times so calendar dates "
"stay unchanged."
).format(
old=format_datetime(old_date),
new=format_datetime(new_date)
))
shift = dialog.addButton(
_translate("Study", "Shift dates"),
QMessageBox.AcceptRole
)
keep = dialog.addButton(
_translate("Study", "Keep dates"),
QMessageBox.ActionRole
)
cancel = dialog.addButton(QMessageBox.Cancel)
dialog.setDefaultButton(cancel)
dialog.exec()
if dialog.clickedButton() not in (shift, keep):
return
preserve_dates = dialog.clickedButton() is keep
if self._study is not None:
date_mode = self.get_radio_button("radioButton_date")
mode_changed = date_mode != (self._study.time_system == "date")
date_changed = date_mode and (
parse_datetime(self.get_datetime_edit("dateTimeEdit_date"))
!= parse_datetime(self._study.date)
)
if (
(mode_changed or date_changed)
and not self._close_study_editors()
):
return
name = self.get_line_edit_text("lineEdit_name") name = self.get_line_edit_text("lineEdit_name")
description = self.get_plaintext_edit_text("plainTextEdit_description") description = self.get_plaintext_edit_text("plainTextEdit_description")
@@ -172,7 +245,9 @@ All right reserved.
self._study.description = description self._study.description = description
if self.get_radio_button("radioButton_date"): if self.get_radio_button("radioButton_date"):
date = self.get_datetime_edit("dateTimeEdit_date") date = self.get_datetime_edit("dateTimeEdit_date")
self._study.use_date(date) self._study.change_start_date(
date, preserve_dates=preserve_dates
)
else: else:
self._study.use_time() self._study.use_time()
+18 -3
View File
@@ -62,8 +62,22 @@ class PamhyrTextDelegate(QStyledItemDelegate):
class PamhyrTableModel(QAbstractTableModel): class PamhyrTableModel(QAbstractTableModel):
@property
def time_system(self):
return (
self._study.time_system
if self._study is not None else self._opt_data
)
@property
def start_date(self):
return (
self._study.date
if self._study is not None else self._start_date
)
def date_to_elapsed_seconds(self, value): def date_to_elapsed_seconds(self, value):
"""Convert an entered date to seconds since the study start date.""" """Convert an accepted calendar date to elapsed simulation seconds."""
try: try:
entered_date = parse_datetime(value) entered_date = parse_datetime(value)
except ValueError as error: except ValueError as error:
@@ -81,7 +95,7 @@ class PamhyrTableModel(QAbstractTableModel):
) )
raise error raise error
return (entered_date - self._start_date).total_seconds() return (entered_date - parse_datetime(self.start_date)).total_seconds()
def _setup_delegates(self): def _setup_delegates(self):
if self._table_view is None: if self._table_view is None:
@@ -110,7 +124,7 @@ class PamhyrTableModel(QAbstractTableModel):
opt_data=None, opt_data=None,
options=["rows_selection"], options=["rows_selection"],
parent=None, parent=None,
start_date=None): start_date=None, study=None):
super(PamhyrTableModel, self).__init__() super(PamhyrTableModel, self).__init__()
self._table_view = table_view self._table_view = table_view
@@ -128,6 +142,7 @@ class PamhyrTableModel(QAbstractTableModel):
self._undo = undo self._undo = undo
self._lst = [] self._lst = []
self._start_date = start_date self._start_date = start_date
self._study = study
self._setup_delegates() self._setup_delegates()
self._setup_lst() self._setup_lst()
+11 -6
View File
@@ -175,6 +175,15 @@ class MainTranslate(UnitTranslate):
self._dict["tab_checker_name"] = _translate( self._dict["tab_checker_name"] = _translate(
"MainWindow", "Checks" "MainWindow", "Checks"
) )
self._dict["tab_graph_name"] = _translate(
"MainWindow", "Graph"
)
self._dict["graph_network_title"] = _translate(
"MainWindow", "Hydrographic network"
)
self._dict["graph_geographic_title"] = _translate(
"MainWindow", "Geographic view"
)
self._dict["open_debug"] = _translate( self._dict["open_debug"] = _translate(
"MainWindow", "Open debug window" "MainWindow", "Open debug window"
@@ -200,13 +209,9 @@ class MainTranslate(UnitTranslate):
self._dict["Warning"] = _translate( self._dict["Warning"] = _translate(
"MainWindow", "Warning" "MainWindow", "Warning"
) )
self._dict["mb_select_reach_title"] = _translate( self._dict["no_reach"] = _translate(
"MainWindow", "Please select a reach"
)
self._dict["mb_select_reach_msg"] = _translate(
"MainWindow", "MainWindow",
"This edition window need a reach selected " "Create a reach in the network before opening this window."
"into the river network to work on it"
) )
self._dict["mb_last_open_title"] = _translate( self._dict["mb_last_open_title"] = _translate(
+12 -9
View File
@@ -20,6 +20,7 @@ import logging
import traceback import traceback
from datetime import date, time, datetime, timedelta from datetime import date, time, datetime, timedelta
from tools import parse_datetime
from tools import ( from tools import (
trace, timer, trace, timer,
timestamp_to_old_pamhyr_date, timestamp_to_old_pamhyr_date,
@@ -74,11 +75,12 @@ class TableModel(PamhyrTableModel):
if self._data.get_type_column(column) == float: if self._data.get_type_column(column) == float:
value = f"{v:.4f}" value = f"{v:.4f}"
elif self._data.header[column] == "time": elif self._data.header[column] == "time":
if self._opt_data == "time": if self.time_system == "time":
value = timestamp_to_old_pamhyr_date(int(v)) value = timestamp_to_old_pamhyr_date(int(v))
else: else:
value = format_datetime( value = format_datetime(
self._start_date + timedelta(seconds=float(v)) parse_datetime(self.start_date)
+ timedelta(seconds=float(v))
) )
else: else:
value = f"{v}" value = f"{v}"
@@ -97,7 +99,7 @@ class TableModel(PamhyrTableModel):
try: try:
if (self._data.header[column] == "time" if (self._data.header[column] == "time"
and self._opt_data == "date"): and self.time_system == "date"):
value = self.date_to_elapsed_seconds(value) value = self.date_to_elapsed_seconds(value)
self._undo.push( self._undo.push(
@@ -175,17 +177,18 @@ class TableModel(PamhyrTableModel):
if len(data) == 0: if len(data) == 0:
return return
try:
rows = [self._data.new_from_data(header, d) for d in data]
except (ValueError, TypeError, IndexError) as error:
logger.warning(error)
return False
self.layoutAboutToBeChanged.emit() self.layoutAboutToBeChanged.emit()
self._undo.push( self._undo.push(
PasteCommand( PasteCommand(
self._data, row, self._data, row,
list( rows
map(
lambda d: self._data.new_from_data(header, d),
data
)
)
) )
) )
+8 -1
View File
@@ -25,6 +25,8 @@ from PyQt5.QtGui import (
QKeySequence, QKeySequence,
) )
from Modules import Modules
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, QVariant, QAbstractTableModel, QCoreApplication, Qt, QVariant, QAbstractTableModel, QCoreApplication,
) )
@@ -122,7 +124,7 @@ class EditWeatherParametersWindow(PamhyrWindow):
undo=self._undo_stack, undo=self._undo_stack,
trad=self._trad, trad=self._trad,
opt_data=self._study.time_system, opt_data=self._study.time_system,
start_date=self._study.date study=self._study
) )
table.setModel(self._table) table.setModel(self._table)
@@ -158,6 +160,11 @@ class EditWeatherParametersWindow(PamhyrWindow):
self._table.dataChanged.connect(self.update) self._table.dataChanged.connect(self.update)
self._table.layoutChanged.connect(self.update) self._table.layoutChanged.connect(self.update)
def _propagated_update(self, key=Modules(0)):
if Modules.STUDY in key:
self._table.layoutChanged.emit()
self.plot.update()
def update(self): def update(self):
self.plot.update() self.plot.update()
self.plot.fit_to_data() self.plot.fit_to_data()
+1 -1
View File
@@ -40,7 +40,7 @@ class WPETranslate(WeatherParametersTranslate):
"Humidity"), "Humidity"),
"radiation": _translate("WeatherParameters", "Radiation (W/m²)"), "radiation": _translate("WeatherParameters", "Radiation (W/m²)"),
"wind speed": _translate("WeatherParameters", "Wind Speed (m/s)"), "wind speed": _translate("WeatherParameters", "Wind Speed (m/s)"),
"albedo": _translate("WeatherParameters", "Albedo"), "albedo": _translate("WeatherParameters", "Water albedo"),
"shading coefficient": _translate("WeatherParameters", "shading coefficient": _translate("WeatherParameters",
"Shading Coefficient"), "Shading Coefficient"),
"cloud cover fraction": _translate("WeatherParameters", "cloud cover fraction": _translate("WeatherParameters",
+1 -1
View File
@@ -76,7 +76,7 @@ class WeatherParametersTranslate(MainTranslate):
"Groundwater Temperature (°C)"), "Groundwater Temperature (°C)"),
"surface_flow_rate": _translate("WeatherParameters", "surface_flow_rate": _translate("WeatherParameters",
"Groundwater Flow Rate (m³/s)"), "Groundwater Flow Rate (m³/s)"),
"albedo": _translate("WeatherParameters", "Albedo"), "albedo": _translate("WeatherParameters", "Water albedo"),
"shading_coefficient": _translate("WeatherParameters", "shading_coefficient": _translate("WeatherParameters",
"Shading Coefficient"), "Shading Coefficient"),
"cloud_cover_fraction": _translate("WeatherParameters", "cloud_cover_fraction": _translate("WeatherParameters",
@@ -56,7 +56,7 @@
<item row="2" column="0"> <item row="2" column="0">
<widget class="QLabel" name="label_3"> <widget class="QLabel" name="label_3">
<property name="text"> <property name="text">
<string>TextLabel</string> <string></string>
</property> </property>
</widget> </widget>
</item> </item>
+10
View File
@@ -25,6 +25,16 @@
</property> </property>
<widget class="QWidget" name="horizontalLayoutWidget"> <widget class="QWidget" name="horizontalLayoutWidget">
<layout class="QVBoxLayout" name="horizontalLayout"> <layout class="QVBoxLayout" name="horizontalLayout">
<item>
<widget class="QCheckBox" name="checkBox_show_interpolated">
<property name="text">
<string notr="true">Show interpolated profiles</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item> <item>
<widget class="QTableView" name="tableView"/> <widget class="QTableView" name="tableView"/>
</item> </item>
+2 -2
View File
@@ -6,8 +6,8 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>900</width> <width>1000</width>
<height>480</height> <height>680</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
+26 -5
View File
@@ -37,7 +37,7 @@ logger = logging.getLogger()
class Config(SQL): class Config(SQL):
def __init__(self): def __init__(self):
self._version = '0.0.9' self._version = '0.0.10'
self.filename = Config.filename() self.filename = Config.filename()
self.set_default_value() self.set_default_value()
@@ -221,6 +221,26 @@ class Config(SQL):
"VALUES ('display_manning', 'False')" "VALUES ('display_manning', 'False')"
) )
if int(release) < 10:
posix = os.name == 'posix'
ext = "" if posix else ".exe"
path = os.path.join("@install_dir",
"rubar", f"rubarbe{ext}")
self.execute(f"""
INSERT OR IGNORE INTO solver VALUES (
'rubarbe',
'default-RubarBE',
'Default Pamhyr2 RubarBE version',
'', '', '',
'',
'"{path}" @args @input',
''
)
""")
self.execute( self.execute(
f"UPDATE info SET value='{self._version}' " + f"UPDATE info SET value='{self._version}' " +
"WHERE key='version'" "WHERE key='version'"
@@ -417,11 +437,12 @@ class Config(SQL):
new._cmd_solver = f""""{path}" @args @input""" new._cmd_solver = f""""{path}" @args @input"""
self._solvers.append(new) self._solvers.append(new)
# Rubar3 # RubarBE (same executable as Rubar3, see notice §10.3)
ctor = solver_type_list["rubar3"] ctor = solver_type_list["rubarbe"]
new = ctor("default-Rubar3") new = ctor("default-RubarBE")
new._description = "Default Pamhyr2 RubarBE version" new._description = "Default Pamhyr2 RubarBE version"
new._cmd_solver = f""""@install_dir/rubar/rubarbe{ext}" @args @input""" path = os.path.join("@install_dir", "rubar", f"rubarbe{ext}")
new._cmd_solver = f""""{path}" @args @input"""
self._solvers.append(new) self._solvers.append(new)
# Mage fake 7 # Mage fake 7
+227 -147
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -240,6 +240,8 @@ def timestamp(dt: datetime):
SUPPORTED_DATETIME_FORMATS = ( SUPPORTED_DATETIME_FORMATS = (
"%d:%m:%Y, %H:%M:%S",
"%d:%m:%Y",
"%d-%m-%Y %H:%M:%S.%f", "%d-%m-%Y %H:%M:%S.%f",
"%d-%m-%Y %H:%M:%S", "%d-%m-%Y %H:%M:%S",
"%d-%m-%Y %H:%M", "%d-%m-%Y %H:%M",