mirror of
https://gitlab.com/pamhyr/pamhyr2.git
synced 2026-10-30 03:20:41 +01:00
Solver: revamp Rubar parameter interface and export validation
This commit is contained in:
1 parent
33ad91d1a6
commit
2a699f46ca
7 files changed
+966
-110
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user