Cette page a été traduite automatiquement de l’anglais et peut contenir des erreurs. Original en anglais

Code source de axqua.core.schema

"""Which settings describe the *river*, and which name a *solver keyword*.

A case config mixes two kinds of statement that look identical in YAML:

* **shared intent** - facts about the reach and the modeling goal. The discharge,
  the roughness, the channel resolution, how converged a run has to be. Change the
  solver and these stay true.
* **solver detail** - the knobs of one particular code. ``IMPLICITATION FOR DEPTH``
  is a Telemac2d keyword; ``MULESCorr`` is an interFoam one. Neither means anything
  to the other solver, and neither is a statement about the river.

Nothing enforced that distinction, so a reader had to know the solvers to tell which
was which. This module states it **as data**, for two consumers that cannot guess:

* a form-based editor (the planned QGIS plugin) which should present "the reach" and
  "TELEMAC numerics" as different pages rather than one flat wall of fields;
* :func:`axqua.config.dump_config` and ``axqua migrate``, which need to know
  what may be renamed without changing meaning.

Why the fields have not been physically moved
----------------------------------------------
The original plan folded ``openfoam.cell_size`` into ``mesh.channel_size`` and
``openfoam.n_layers`` into a shared ``mesh.vertical_layers``. That has since become
the wrong move for the one case that motivated it: ``openfoam.cell_size_factor``
already expresses the OpenFOAM lattice **relative to** the 2D channel size, which is
the shared-intent link the fold was after - and it does so without pretending the two
numbers are the same quantity, which they are not. TELEMAC's is a per-zone
anisotropic target edge length; OpenFOAM's is a uniform Cartesian spacing. Collapsing
them would have replaced a real relationship with a false identity.

The remaining moves (twenty-odd Telemac2d keywords out of ``hydrodynamics:`` into a
``telemac.numerics`` block) are pure renaming: they change no behavior, touch every
case config, and would invalidate the artifact regression baseline for nothing. So the
classification lives here, where a plugin can read it, and the field layout stays put.
:data:`LEGACY_KEYS` is the mechanism for doing the move later without breaking a
single existing config.
"""

from __future__ import annotations

import logging
from enum import Enum

log = logging.getLogger("axqua")


[docs] class Layer(str, Enum): """Who a setting belongs to.""" SHARED = "shared" # a fact about the reach or the goal TELEMAC = "telemac" # a Telemac2d/3d or GAIA keyword OPENFOAM = "openfoam" # an interFoam dictionary entry PROJECT = "project" # paths and bookkeeping, not physics
# Blocks that are wholly one layer. Checked before the per-field table, so only the # genuinely mixed blocks (`mesh`, `hydrodynamics`) need spelling out field by field. BLOCK_LAYER: dict[str, Layer] = { "project": Layer.PROJECT, "outputs": Layer.PROJECT, "geodata": Layer.SHARED, # the reach itself: DEMs, zones, centerline "boundaries": Layer.SHARED, # what the river does at its ends "friction": Layer.SHARED, # the bed, not a keyword "ground_truth": Layer.SHARED, # measurements are measurements "calibration": Layer.SHARED, "morphodynamics": Layer.SHARED, "dem_of_difference": Layer.SHARED, "fine_sediment": Layer.SHARED, # the sediment and the goal; OpenFOAM-only *today* "gain_lose": Layer.SHARED, # a property of the reach; TELEMAC-only *today* "structures": Layer.SHARED, # dams and walls are terrain, and both codes mesh them "drying": Layer.TELEMAC, "initialization": Layer.TELEMAC, "telemac": Layer.TELEMAC, "openfoam": Layer.OPENFOAM, } # The mixed blocks, field by field. Only the SHARED entries are listed: anything in a # mixed block that is not named here is that block's own solver detail, which keeps # this table short and makes "is this shared?" the question that has to be answered # deliberately. SHARED_FIELDS: dict[str, tuple[str, ...]] = { # Resolution is a modeling decision about the reach, not a solver keyword. The # gmsh/BAMG specifics beside them (growth_ratio, max_aspect_ratio, size_scale, # the quality thresholds) are TELEMAC's mesher and stay TELEMAC's. "mesh": ("channel_size", "floodplain_size", "refinement_size", "channel_anisotropy"), # Physics and goals, as opposed to the ~24 Telemac2d numerics keywords beside # them (implicitation, advection schemes, solver accuracies, sub-iterations). "hydrodynamics": ("duration", "turbulence_model", "wet_depth", "flux_tolerance", "initial_velocity_guess", "turbulence_length_scale", "desired_courant"), } # Settings that describe the COMPUTER rather than the reach: where a simulation code is # installed and how its environment is entered. They belong to the profile of the # computer (axqua.core.profile). A case file written for sharing leaves them out # (config.dump_config(portable=True)); a case file that still carries them keeps # loading, with the profile taking precedence. MACHINE_FIELDS: dict[str, tuple[str, ...]] = { "telemac": ("pysource", "environment", "version", "solver_python"), "openfoam": ("bashrc", "environment"), "postproc": ("visit", "environment"), } # Legacy key -> canonical key, applied at load. Dotted, so a block rename and a field # rename use one mechanism. This is what makes a future field move safe: add the # mapping here, and every existing config keeps loading. LEGACY_KEYS: dict[str, str] = { "project.work_dir": "project.preprocessing_dir", # results_dir became calibration_dir, NOT model_dir - the old single "results" # folder is where the HydroBayesCal artifacts land, and the phase split gave the # solver case its own model_dir. Reading it as model_dir would point a migrated # config's solver output at the calibration folder. "project.results_dir": "project.calibration_dir", # `percolation:` -> `gain_lose:` is handled in config._load_gain_lose, which also # remaps two field *values*; it is listed here only so a reader of this table sees # the whole set of renames in one place. } # Settings that are a choice among words of which one is `off`. YAML does not take an # unquoted `off` for a word: it reads "no". Written the natural way, `pick_up: off` was # refused with "got False", and a form showed the choice as False. OFF_IS_A_WORD: dict[str, tuple[str, ...]] = { "fine_sediment": ("pick_up",), "gain_lose": ("mode", "water_table"), "percolation": ("mode", "water_table"), # the older name of gain_lose }
[docs] def restore_words(raw: dict) -> dict: """Put the word ``off`` back where YAML read it as a boolean, in place. Applied wherever a case file is parsed, so that the loader, the check and an editor of the case see the same value. """ for block, names in OFF_IS_A_WORD.items(): section = raw.get(block) if not isinstance(section, dict): continue for name in names: if section.get(name) is False: section[name] = "off" return raw
[docs] def classify_setting(key: str) -> Layer: """Which layer a dotted config key belongs to. Unknown keys inside a known block inherit that block's layer, and an unknown block is SHARED - the reading that fails safe for a form, since showing a setting on the reach page is a smaller mistake than hiding it behind a solver the user has not enabled. """ block, _, fieldname = key.partition(".") if fieldname and block in SHARED_FIELDS and fieldname in SHARED_FIELDS[block]: return Layer.SHARED if block in BLOCK_LAYER: return BLOCK_LAYER[block] if block in SHARED_FIELDS: # a mixed block's non-shared field return Layer.TELEMAC return Layer.SHARED
[docs] def apply_legacy(raw: dict) -> list[str]: """Rewrite deprecated keys in *raw* in place; return what was renamed. Returns the list so the caller can emit **one** aggregated warning naming everything that moved. A per-key warning turns a config written three years ago into a wall of text that hides whichever line actually matters. """ renamed: list[str] = [] for old, new in LEGACY_KEYS.items(): old_block, _, old_field = old.partition(".") new_block, _, new_field = new.partition(".") section = raw.get(old_block) if not isinstance(section, dict) or old_field not in section: continue target = raw.setdefault(new_block, {}) if new_block != old_block else section if not isinstance(target, dict) or new_field in target: continue # the canonical key wins; it was set explicitly target[new_field] = section.pop(old_field) renamed.append(f"{old} -> {new}") return renamed
[docs] def warn_legacy(renamed: list[str], source: str = "") -> None: """Log one aggregated deprecation notice for *renamed*.""" if not renamed: return where = f" in {source}" if source else "" log.warning("deprecated config keys%s, still loaded but rename them: %s", where, "; ".join(sorted(renamed)))
[docs] def strip_machine_fields(data: dict) -> list[str]: """Remove the :data:`MACHINE_FIELDS` from a config mapping, in place. Returns the dotted keys that were removed, so the caller can say what moved to the profile instead of dropping it without a word. """ removed: list[str] = [] for block, names in MACHINE_FIELDS.items(): section = data.get(block) if not isinstance(section, dict): continue for name in names: if name in section: value = section.pop(name) # An empty `environment:` block is a default, not a setting that moved. if value not in (None, "", {}) and not _is_default_environment(value): removed.append(f"{block}.{name}") return removed
def _is_default_environment(value) -> bool: return isinstance(value, dict) and not any( v not in (None, "", {}, []) for v in value.values()) # `classify` reads fine inside this module; at package level it says nothing about # what is being classified, so the exported name is the explicit one. classify = classify_setting