diff --git a/src/Scripts/Run.py b/src/Scripts/Run.py index 31664d82..0f58690a 100644 --- a/src/Scripts/Run.py +++ b/src/Scripts/Run.py @@ -25,6 +25,7 @@ from PyQt5.QtCore import QProcess from Scripts.AScript import AScript from Model.Study import Study +from View.RunSolver.Translate import RunSolverTranslate logger = logging.getLogger() @@ -89,12 +90,16 @@ class ScriptRun(AScript): # Export and Run logger.info(f"~Export~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - solver.export(study, workdir, qlog=self._q) + ok = solver.export(study, workdir, qlog=self._q) while self._q.qsize() != 0: s = self._q.get() logger.info(s) + if not ok: + logger.warning(RunSolverTranslate()["export_failed"]) + return + if command == "run": logger.info(f"~Run~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") solver.run( diff --git a/src/Solver/RubarBE.py b/src/Solver/RubarBE.py index 82b01537..b333955b 100644 --- a/src/Solver/RubarBE.py +++ b/src/Solver/RubarBE.py @@ -18,11 +18,14 @@ import os import logging +import math +from decimal import Decimal, InvalidOperation, localcontext import numpy as np from tools import timer, trace, old_pamhyr_date_to_timestamp, logger_exception from Solver.CommandLine import CommandLineSolver +from View.RunSolver.Translate import RunSolverTranslate from Model.Results.Results import Results from Model.Results.River.River import River, Reach, Profile @@ -44,7 +47,7 @@ class Rubar3(CommandLineSolver): @classmethod def default_parameters(cls): - # lst = super(Rubar3, cls).default_parameters() + # Keep historical rubarbe_* keys for existing Rubar3 studies. lst = [ ("rubarbe_cfl", "0.50000E+00"), @@ -52,13 +55,13 @@ class Rubar3(CommandLineSolver): ("rubarbe_condav", "3"), ("rubarbe_regime", "0"), ("rubarbe_iodev", "n"), - ("rubarbe_iodebord", ""), - ("rubarbe_iostockage", ""), + ("rubarbe_iodebord", "o"), + ("rubarbe_iostockage", "o"), ("rubarbe_iopdt", "y"), ("rubarbe_iovis", "n"), ("rubarbe_rep", "n"), ("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_dt", "5.0"), ("rubarbe_ts", "999:99:99:00"), @@ -89,8 +92,8 @@ class Rubar3(CommandLineSolver): ("rubarbe_sediment_mult_3", ""), ("rubarbe_sediment_mult_4", ""), ("rubarbe_sediment_mult_5", ""), - ("rubarbe_sediment_visc", "0.047"), - ("rubarbe_sediment_opts", "6"), + ("rubarbe_sediment_visc", "1.3E-06"), + ("rubarbe_sediment_opts", "3"), ("rubarbe_sediment_odchar", "0"), ("rubarbe_sediment_unisol", "1"), ("rubarbe_sediment_typdef", "3"), @@ -141,7 +144,9 @@ class Rubar3(CommandLineSolver): name = study.name.replace(" ", "_") self._export_donnee(study, repertory, qlog, name=name) - self._export_ts(study, repertory, qlog, name=name) + if self._parameter_flag(study, "trased") == "O": + if not self._export_ts(study, repertory, qlog, name=name): + return False self._export_geomac_i(study, repertory, qlog, name=name) self._export_mail(study, repertory, qlog, name=name) self._export_devers(study, repertory, qlog, name=name) @@ -153,108 +158,191 @@ class Rubar3(CommandLineSolver): 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"), + ) + + 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: qlog.put("Export DONNEE file") + self._write_parameters(repertory, f"donnee.{name}", lines) - with open( - os.path.join( - repertory, f"donnee.{name}" - ), "w+" - ) as f: - params = filter( - lambda p: "rubarbe_sediment_" not in p.name, - study.river.get_params(self._type).parameters - ) - - it = iter(params) - - param = next(it, None) - while param is not None: - name = param.name - value = param.value - - if "all_" in name: - continue - - if value != "": - # Value format - if value.count(':') == 3: - value = old_pamhyr_date_to_timestamp(value) - value = f"{value:>12.5e}".upper() - - if value.count('.') == 1: - value = f"{float(value):>12.5e}".upper() - - if value == "y" or value == "n": - value = "O" if value == "y" else "N" - - # Write value - f.write(f"{name:<50}{value}") - - # Add values of 'rubarbe_iodebord' and - # 'rubarbe_iostockage' - if name == "rubarbe_iodev": - v2 = next(it).value - v3 = next(it).value - - # f.write(f"{v2}{v3}") - - # New line - f.write(f"\n") - - param = next(it, None) - - def _export_ts(self, study, repertory, qlog, name="0"): + def _export_ts(self, study, repertory, qlog=None, name="0"): + trad = RunSolverTranslate() + values = self._parameter_values(study) + prefix = "rubarbe_sediment_" + fields = list(self._ts_fields) + demix = self._required(values, prefix + "demix") + if demix not in ("0", "1"): + raise ValueError(f"{prefix}demix: expected 0 or 1") + if demix == "1": + fields += [("dchard", "real"), ("dchars", "real")] + lines = [] + for suffix, kind in fields: + key = prefix + suffix + value = self._required(values, key) + if kind == "real": + result = self._real(value, key) + elif kind == "multipliers": + result = self._real(value, key) + extras = [values.get(prefix + f"mult_{i}", "") + for i in range(2, 6)] + # Preserve empty interior F10.0 fields; omit trailing fields + # to retain the solver's defaults for optional coefficients. + while extras and not extras[-1]: + extras.pop() + for i, extra in enumerate(extras, 2): + result += (self._real(extra, prefix + f"mult_{i}") + if extra else " " * 10) + elif kind == "options": + if len(value) != 3 or any(c not in "12" for c in value): + message = trad["invalid_options"].format(key=key) + logger.warning(message) + if qlog is not None: + qlog.put(trad["warning"].format(message=message)) + return False + result = value + elif kind == "law": + # Notice section 3.10 / 8.7: 4--7 are not listed. + if value not in {str(i) for i in [1, 2, 3, *range(8, 31)]}: + message = trad["unsupported_transport_law"].format( + key=key, value=value) + logger.warning(message) + if qlog is not None: + qlog.put(trad["warning"].format(message=message)) + return False + result = self._integer(value, key, width=2) + else: + result = self._integer(value, key, + width=10 if kind == "depot" else 1) + lines.append(f"{key:<50}{result}\n") if qlog is not None: qlog.put("Export TS file") - - with open( - 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 + self._write_parameters(repertory, f"ts.{name}", lines) + return True def _export_geomac_i(self, study, repertory, qlog, name="0"): if qlog is not None: @@ -818,3 +906,14 @@ class RubarBE(Rubar3): self._cmd_input = "" self._cmd_solver = "@path @args @input" 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()] diff --git a/src/View/RunSolver/Translate.py b/src/View/RunSolver/Translate.py new file mode 100644 index 00000000..9481e490 --- /dev/null +++ b/src/View/RunSolver/Translate.py @@ -0,0 +1,43 @@ +# Translate.py -- Pamhyr +# Copyright (C) 2023-2026 INRAE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# -*- coding: utf-8 -*- + +from PyQt5.QtCore import QCoreApplication + +from View.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["export_failed"] = _translate( + "Solver", "Export failed; task cancelled" + ) diff --git a/src/View/SolverParameters/RubarFields.py b/src/View/SolverParameters/RubarFields.py new file mode 100644 index 00000000..a97f12be --- /dev/null +++ b/src/View/SolverParameters/RubarFields.py @@ -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 diff --git a/src/View/SolverParameters/RubarForm.py b/src/View/SolverParameters/RubarForm.py new file mode 100644 index 00000000..131c8b1e --- /dev/null +++ b/src/View/SolverParameters/RubarForm.py @@ -0,0 +1,342 @@ +"""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.""" + 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 + 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) + 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() diff --git a/src/View/SolverParameters/Window.py b/src/View/SolverParameters/Window.py index 408d9d89..21a1e560 100644 --- a/src/View/SolverParameters/Window.py +++ b/src/View/SolverParameters/Window.py @@ -42,6 +42,7 @@ from PyQt5.QtWidgets import ( from View.SolverParameters.UndoCommand import * from View.SolverParameters.Table import TableModel, ValueDelegate +from View.SolverParameters.RubarForm import RubarForm from View.SolverParameters.translate import ParamTranslate from Solver.Solvers import solver_long_name, solver_type_list @@ -62,7 +63,7 @@ class SolverParametersWindow(PamhyrWindow): title=name, study=study, config=config, - options=['copy'], + options=['undo', 'copy'], trad=trad, parent=parent ) @@ -88,6 +89,15 @@ class SolverParametersWindow(PamhyrWindow): editable_headers = [] 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 widget = QWidget() grid = QGridLayout() @@ -135,8 +145,19 @@ class SolverParametersWindow(PamhyrWindow): tab = self.current_tab() 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): - logger.info("TODO: copy") + editor = self.focusWidget() + if isinstance(editor, QLineEdit): + editor.copy() def _paste(self): - logger.info("TODO: paste") + editor = self.focusWidget() + if isinstance(editor, QLineEdit) and self._study.is_editable(): + editor.paste() diff --git a/src/View/ui/SolverParameters.ui b/src/View/ui/SolverParameters.ui index 2cffbcc5..db2e7efa 100644 --- a/src/View/ui/SolverParameters.ui +++ b/src/View/ui/SolverParameters.ui @@ -6,8 +6,8 @@ 0 0 - 900 - 480 + 1000 + 680