From 6888e4135eb1879b3d6a84680274a4ceda9ced82 Mon Sep 17 00:00:00 2001 From: Mattia Almansi Date: Mon, 5 Jul 2021 14:20:04 +0100 Subject: [PATCH] Implement `from_namelist` method (#43) * Introduce namelists in the accessor * Untested, but the main implementation should be in good shape * use class variable introduced * add tests * accessor now handles 999999 * Update accessor.py Adding error handle when importing namelist * introduce _check_namelist_entries * typo * tidy up namelist check * add type hints * minor semplification * add more checks * let Zco handle checks * deprecate ldbletanh * fix comment * better error print * decorate __call__ * clean utils * add tests * use None * ready for review * fix doc and ci * better docs * avoid numpy with type hint bug Co-authored-by: jdha --- .github/workflows/ci.yaml | 23 ++++++ .pre-commit-config.yaml | 2 +- ci/bare-environment.yml | 9 +++ ci/environment.yml | 1 + ci/upstream-dev-env.yml | 1 + docs/developers/whats-new.rst | 4 +- docs/users/api.rst | 1 + docs/users/installing.rst | 7 +- pydomcfg/accessor.py | 128 +++++++++++++++++++++++++++++++- pydomcfg/domzgr/zco.py | 67 ++++++----------- pydomcfg/domzgr/zgr.py | 1 + pydomcfg/tests/data.py | 71 ++++++++++++++++++ pydomcfg/tests/test_accessor.py | 15 +++- pydomcfg/tests/test_utils.py | 32 +++++++- pydomcfg/tests/test_zco.py | 63 ++++++++-------- pydomcfg/utils.py | 126 +++++++++++++++++++++++++++---- setup.cfg | 14 +++- 17 files changed, 463 insertions(+), 102 deletions(-) create mode 100644 ci/bare-environment.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 60dee8e..f38b2a8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,3 +68,26 @@ jobs: - name: Run Tests shell: bash -l {0} run: pytest --no-cov + + bare-environment: + name: bare-environment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: conda-incubator/setup-miniconda@v2 + with: + environment-file: ci/bare-environment.yml + activate-environment: pydomcfg_test_bare + auto-update-conda: false + miniforge-variant: Mambaforge + use-mamba: true + + - name: Set up conda environment + shell: bash -l {0} + run: | + python -m pip install -e . + conda list + + - name: Run Tests + shell: bash -l {0} + run: pytest --no-cov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02f7cde..5bf9394 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: hooks: - id: mypy exclude: docs - additional_dependencies: [xarray, types-pkg_resources] + additional_dependencies: [xarray, types-pkg_resources, numpy!=1.21.0] - repo: https://github.com/PyCQA/doc8 rev: 0.9.0a1 diff --git a/ci/bare-environment.yml b/ci/bare-environment.yml new file mode 100644 index 0000000..6dcbed7 --- /dev/null +++ b/ci/bare-environment.yml @@ -0,0 +1,9 @@ +name: pydomcfg_test_bare +channels: + - conda-forge +dependencies: + - pooch + - pytest-cov + - pytest + - pytest-xdist + - xarray diff --git a/ci/environment.yml b/ci/environment.yml index 41f6cc8..da7f647 100644 --- a/ci/environment.yml +++ b/ci/environment.yml @@ -7,3 +7,4 @@ dependencies: - pytest - pytest-xdist - xarray + - f90nml diff --git a/ci/upstream-dev-env.yml b/ci/upstream-dev-env.yml index 81fe75f..82d63b3 100644 --- a/ci/upstream-dev-env.yml +++ b/ci/upstream-dev-env.yml @@ -9,3 +9,4 @@ dependencies: - pip - pip: - git+https://github.com/pydata/xarray.git + - git+https://github.com/marshallward/f90nml.git diff --git a/docs/developers/whats-new.rst b/docs/developers/whats-new.rst index 2465579..529bd0a 100644 --- a/docs/developers/whats-new.rst +++ b/docs/developers/whats-new.rst @@ -1,4 +1,4 @@ -.. currentmodule:: pydomcfg +.. currentmodule:: xarray What's New ---------- @@ -6,6 +6,8 @@ What's New Unreleased ========== +- Introduced :py:meth:`Dataset.domcfg.from_namelist` to use + ``NEMO DOMAINcfg`` namelists (:pr:`43`) - Introduced the ``domcfg`` accessor (:pr:`36`) - Added :py:meth:`~pydomcfg.tests.bathymetry.Bathymetry.sea_mount` useful to generate classic sea mount test case. (:pr:`17`) diff --git a/docs/users/api.rst b/docs/users/api.rst index e51d1a2..9bd7265 100644 --- a/docs/users/api.rst +++ b/docs/users/api.rst @@ -21,6 +21,7 @@ Methods :toctree: generated/ :template: autosummary/accessor_method.rst + Dataset.domcfg.from_namelist Dataset.domcfg.zco Domzgr diff --git a/docs/users/installing.rst b/docs/users/installing.rst index 34869e9..df21809 100644 --- a/docs/users/installing.rst +++ b/docs/users/installing.rst @@ -9,6 +9,11 @@ Required dependencies - `xarray `_ - `numpy `_ +Optional dependencies +--------------------- + +- `f90nml `_ + Instructions ------------ @@ -18,5 +23,5 @@ The best way to install all dependencies is to use `conda `_. .. code-block:: sh - conda install -c conda-forge xarray pip + conda install -c conda-forge xarray f90nml pip pip install git+https://github.com/pyNEMO/pyDOMCFG.git diff --git a/pydomcfg/accessor.py b/pydomcfg/accessor.py index 0b75a77..6397e5d 100644 --- a/pydomcfg/accessor.py +++ b/pydomcfg/accessor.py @@ -1,11 +1,28 @@ -from typing import Any, Callable, TypeVar, cast +import inspect +import warnings +from collections import ChainMap +from functools import wraps +from pathlib import Path +from typing import IO, Any, Callable, TypeVar, Union, cast import xarray as xr from xarray import Dataset from .domzgr.zco import Zco +try: + import f90nml + + HAS_F90NML = True +except ImportError: + HAS_F90NML = False + F = TypeVar("F", bound=Callable[..., Any]) +ZGR_MAPPER = { + "ln_zco": Zco, + # "ln_zps": TODO, + # "ln_sco": TODO +} def _jpk_check(func: F) -> F: @@ -13,6 +30,7 @@ def _jpk_check(func: F) -> F: Decorator to raise an error if jpk was not set """ + @wraps(func) def wrapper(self, *args, **kwargs): if not self.jpk: raise ValueError( @@ -29,7 +47,9 @@ class Accessor: def __init__(self, xarray_obj: Dataset): self._obj = xarray_obj self._jpk = 0 + self._nml_ref_path = "" + # Set attributes @property def jpk(self) -> int: """ @@ -43,12 +63,112 @@ def jpk(self) -> int: @jpk.setter def jpk(self, value: int): - if value <= 0: - raise ValueError("`jpk` MUST be > 0") + if value < 0: + raise ValueError("`jpk` MUST be >= 0 (use 0 to unset jpk)") self._jpk = value + @property + def nml_ref_path(self) -> str: + """ + Path to reference namelist. + + Returns + ------- + str + """ + return self._nml_ref_path + + @nml_ref_path.setter + def nml_ref_path(self, value: str): + self._nml_ref_path = value + + # domzgr methods + # TODO: + # I think the process of creating the public API and doc + # can be further automatized, but let's not put too much effort into it + # until we settle on the back-end structure: + # See: https://github.com/pyNEMO/pyDOMCFG/issues/45 @_jpk_check def zco(self, *args: Any, **kwargs: Any) -> Dataset: - return Zco(self._obj, self._jpk)(*args, **kwargs) + name = inspect.stack()[0][3] + return ZGR_MAPPER["ln_" + name](self._obj, self._jpk)(*args, **kwargs) zco.__doc__ = Zco.__call__.__doc__ + + # Emulate NEMO DOMAINcfg tools + def from_namelist(self, nml_cfg_path_or_io: Union[str, Path, IO[str]]) -> Dataset: + """ + Auto-populate pydomcfg parameters using NEMO DOMAINcfg namelists. + + Parameters + ---------- + nml_cfg_path_or_io: str, Path, IO + Path pointing to a namelist_cfg, + or namelist_cfg previously opened with open() + + Returns + ------- + Dataset + """ + + nml_chained = self._namelist_parser(nml_cfg_path_or_io) + zgr_initialized, kwargs = self._get_zgr_initialized_and_kwargs(nml_chained) + return zgr_initialized(**kwargs) + + def _namelist_parser( + self, nml_cfg_path_or_io: Union[str, Path, IO[str]] + ) -> ChainMap: + """Parse namelists using f90nml, chaining all namblocks""" + + if not HAS_F90NML: + raise ImportError( + "`f90nml` MUST be installed to use `obj.domcfg.from_namelist()`" + ) + + if not self.nml_ref_path: + raise ValueError( + "Set `nml_ref_path` before calling `obj.domcfg.from_namelist()`" + " For example: obj.domcfg.nml_ref_path = 'path/to/nml_ref'" + ) + + if self.jpk: + warnings.warn( + "`obj.domcfg.jpk` is ignored. `jpk` is inferred from the namelists." + ) + + # Read namelists: cfg overrides ref + nml_cfg = f90nml.read(nml_cfg_path_or_io) + nml = f90nml.patch(self.nml_ref_path, nml_cfg) + + return ChainMap(*nml.todict().values()) + + def _get_zgr_initialized_and_kwargs(self, nml_chained: ChainMap): + + # TODO: Add return type hint when abstraction in base class is implemented + + # Pick the appropriate class + zgr_classes = [ + value for key, value in ZGR_MAPPER.items() if nml_chained.get(key) + ] + if len(zgr_classes) != 1: + raise ValueError( + "One and only one of the following variables MUST be `.true.`:" + f" {tuple(ZGR_MAPPER)}" + ) + zgr_class = zgr_classes[0] + + # Compatibility with NEMO DOMAINcfg + if nml_chained.get("ldbletanh") is False: + for pp in ["ppa2", "ppkth2", "ppacr2"]: + nml_chained[pp] = None + + # Get kwargs, converting 999_999 to None + parameters = list(inspect.signature(zgr_class.__call__).parameters) + parameters.remove("self") + kwargs = { + key: None if nml_chained[key] == 999_999 else nml_chained[key] + for key in parameters + if key in nml_chained + } + + return zgr_class(self._obj, nml_chained["jpkdta"]), kwargs diff --git a/pydomcfg/domzgr/zco.py b/pydomcfg/domzgr/zco.py index 6ec0c14..24a21e5 100644 --- a/pydomcfg/domzgr/zco.py +++ b/pydomcfg/domzgr/zco.py @@ -8,8 +8,7 @@ import numpy as np from xarray import DataArray, Dataset -from pydomcfg.utils import _are_nemo_none, _is_nemo_none - +from ..utils import _check_parameters from .zgr import Zgr @@ -19,6 +18,7 @@ class Zco(Zgr): """ # -------------------------------------------------------------------------- + @_check_parameters def __call__( self, ppdzmin: float, @@ -31,7 +31,6 @@ def __call__( ppa2: Optional[float] = None, ppkth2: Optional[float] = None, ppacr2: Optional[float] = None, - ldbletanh: Optional[bool] = None, ln_e3_dep: bool = True, ) -> Dataset: """ @@ -57,11 +56,6 @@ def __call__( ppa2, ppkth2, ppacr2: float, optional Double tanh stretching function parameters. (None: Double tanh OFF) - ldbletanh: bool, optional - Logical flag to switch ON/OFF the double tanh stretching function. - This flag is only needed for compatibility with NEMO DOMAINcfg tools. - Just set ``ppa2``, ``ppkth2``, and ``ppacr2`` to switch ON - the double tanh stretching function. ln_e3_dep: bool, default = True Logical flag to comp. e3 as fin. diff. (True) oranalyt. (False) (default = True) @@ -115,7 +109,7 @@ def __call__( # Set double tanh flag and coefficients (dummy floats when double tanh is OFF) pp2_in = (ppa2, ppkth2, ppacr2) - self._ldbletanh, pp2_out = self._get_ldbletanh_and_pp2(ldbletanh, pp2_in) + self._add_tanh2, pp2_out = self._set_add_tanh2_and_pp2(pp2_in) self._ppa2, self._ppkth2, self._ppacr2 = pp2_out # Initialize stretching coefficients (dummy floats for uniform case) @@ -147,7 +141,7 @@ def _compute_pp( # Uniform grid, return dummy zeros if self._is_uniform: - if not all(_are_nemo_none((ppsur, ppa0, ppa1))): + if not all(pp is None for pp in (ppsur, ppa0, ppa1)): warnings.warn( "Uniform grid case (no stretching):" " ppsur, ppa0 and ppa1 are ignored when ppacr == ppkth == 0" @@ -163,16 +157,16 @@ def _compute_pp( ee = np.log(np.cosh((1.0 - self._ppkth) / self._ppacr)) # Substitute only if is None or 999999 - ppa1_out = (aa / (bb - cc * (dd - ee))) if _is_nemo_none(ppa1) else ppa1 - ppa0_out = (self._ppdzmin - ppa1_out * bb) if _is_nemo_none(ppa0) else ppa0 + ppa1_out = (aa / (bb - cc * (dd - ee))) if ppa1 is None else ppa1 + ppa0_out = (self._ppdzmin - ppa1_out * bb) if ppa0 is None else ppa0 ppsur_out = ( - -(ppa0_out + ppa1_out * self._ppacr * ee) if _is_nemo_none(ppsur) else ppsur + -(ppa0_out + ppa1_out * self._ppacr * ee) if ppsur is None else ppsur ) return (ppsur_out, ppa0_out, ppa1_out) # -------------------------------------------------------------------------- - def _stretch_zco(self, sigma: DataArray, ldbletanh: bool = False) -> DataArray: + def _stretch_zco(self, sigma: DataArray, use_tanh2: bool = False) -> DataArray: """ Provide the generalised analytical stretching function for NEMO z-coordinates. @@ -181,7 +175,7 @@ def _stretch_zco(self, sigma: DataArray, ldbletanh: bool = False) -> DataArray: sigma: DataArray Uniform non-dimensional sigma-coordinate: MUST BE positive, i.e. 0 <= sigma <= 1 - ldbletanh: bool + use_tanh2: bool True only if used to compute the double tanh stretching Returns @@ -192,7 +186,7 @@ def _stretch_zco(self, sigma: DataArray, ldbletanh: bool = False) -> DataArray: kk = sigma * (self._jpk - 1.0) + 1.0 - if ldbletanh: + if use_tanh2: # Double tanh kth = self._ppkth2 acr = self._ppacr2 @@ -230,9 +224,9 @@ def _zco_z3(self) -> Tuple[DataArray, ...]: a3 = self._ppa1 * self._ppacr z3 = self._compute_z3(su, s1, a1, a2, a3) - if self._ldbletanh: + if self._add_tanh2: # Add double tanh term - ss2 = self._stretch_zco(-sigma, self._ldbletanh) + ss2 = self._stretch_zco(-sigma, self._add_tanh2) a4 = self._ppa2 * self._ppacr2 z3 += ss2 * a4 @@ -266,7 +260,7 @@ def _analyt_e3(self) -> Tuple[DataArray, ...]: tanh1 = np.tanh((kk - self._ppkth) / self._ppacr) e3 = a0 + a1 * tanh1 - if self._ldbletanh: + if self._add_tanh2: # Add double tanh term a2 = self._ppa2 tanh2 = np.tanh((kk - self._ppkth2) / self._ppacr2) @@ -276,41 +270,24 @@ def _analyt_e3(self) -> Tuple[DataArray, ...]: return tuple(both_e3) - def _get_ldbletanh_and_pp2( - self, ldbletanh: Optional[bool], pp2: Tuple[Optional[float], ...] + def _set_add_tanh2_and_pp2( + self, pp2: Tuple[Optional[float], ...] ) -> Tuple[bool, Tuple[float, ...]]: - """ - If ldbletanh is None, its bool value is inferred from pp2. - Return pp2=(0, 0, 0) when double tanh is switched off. - """ + """Infer add_tanh2 from pp2. Switch OFF tanh2 when using uniform grid""" pp_are_none = tuple(pp is None for pp in pp2) prefix_msg = "ppa2, ppkth2 and ppacr2" - ldbletanh_out = ldbletanh if (ldbletanh is not None) else not any(pp_are_none) - # Warnings: Ignore double tanh coeffiecients - if ldbletanh_out and self._is_uniform: - # Uniform and double tanh - warning_msg = ( + # Ignore double tanh coeffiecients + if not all(pp_are_none) and self._is_uniform: + warnings.warn( "Uniform grid case (no stretching):" f" {prefix_msg} are ignored when ppacr == ppkth == 0" ) - elif ldbletanh is False and not all(pp_are_none): - # ldbletanh False and double tanh coefficients specified - warning_msg = f"{prefix_msg} are ignored when ldbletanh is False" - else: - # All good - warning_msg = "" - - if warning_msg: - # Warn and return dummy values - warnings.warn(warning_msg) return (False, (0, 0, 0)) - # Errors: pp have inconsistent types - if ldbletanh is True and any(pp_are_none): - raise ValueError(f"{prefix_msg} MUST be all float when ldbletanh is True") - if ldbletanh is None and (any(pp_are_none) and not all(pp_are_none)): + # pp must be all not or float + if any(pp_are_none) and not all(pp_are_none): raise ValueError(f"{prefix_msg} MUST be all None or all float") - return (ldbletanh_out, tuple(pp or 0 for pp in pp2)) + return (not any(pp_are_none), tuple(pp or 0 for pp in pp2)) diff --git a/pydomcfg/domzgr/zgr.py b/pydomcfg/domzgr/zgr.py index d24b2a9..771147f 100644 --- a/pydomcfg/domzgr/zgr.py +++ b/pydomcfg/domzgr/zgr.py @@ -2,6 +2,7 @@ Base class to generate NEMO v4.0 vertical grids. """ + from typing import Tuple, Union import xarray as xr diff --git a/pydomcfg/tests/data.py b/pydomcfg/tests/data.py index 1c595d1..80795d2 100644 --- a/pydomcfg/tests/data.py +++ b/pydomcfg/tests/data.py @@ -2,6 +2,7 @@ Test data """ import numpy as np +import pooch # Results to replicate: # ORCA2 zco model levels depth and vertical @@ -43,3 +44,73 @@ [5250.2266, 5000.0000, 500.5646, 500.3288], ] ) + +# See pag 62 of v3.6 manual for the input parameters +# TODO: +# ppdzmin and pphmax in NEMO DOMAINcfg README are actually 999999, +# a dummy value is assigned but it's a temporary workaround. +# See: https://github.com/pyNEMO/pyDOMCFG/issues/44 +ORCA2_NAMELIST = """ +!----------------------------------------------------------------------- +&namcfg ! parameters of the configuration +!----------------------------------------------------------------------- + ! + ln_e3_dep = .false. ! =T : e3=dk[depth] in discret sens. + ! ! ===>>> will become the only possibility in v4.0 + ! ! =F : e3 analytical derivative of depth function + ! ! only there for backward compatibility test with v3.6 + ! ! + cp_cfg = "orca" ! name of the configuration + jp_cfg = 2 ! resolution of the configuration + jpidta = 180 ! 1st lateral dimension ( >= jpi ) + jpjdta = 148 ! 2nd " " ( >= jpj ) + jpkdta = 31 ! number of levels ( >= jpk ) + Ni0glo = 180 ! 1st dimension of global domain --> i =jpidta + Nj0glo = 148 ! 2nd - - --> j =jpjdta + jpkglo = 31 + jperio = 4 ! lateral cond. type (between 0 and 6) + ln_use_jattr = .false. ! use (T) the file attribute: open_ocean_jstart, if present + ! in netcdf input files, as the start j-row for reading + ln_domclo = .false. ! computation of closed sea masks (see namclo) +/ +!----------------------------------------------------------------------- +&namdom ! +!----------------------------------------------------------------------- + jphgr_msh = 0 ! type of horizontal mesh + ppglam0 = 999999.0 ! longitude of first raw and column T-point (jphgr_msh = 1) + ppgphi0 = 999999.0 ! latitude of first raw and column T-point (jphgr_msh = 1) + ppe1_deg = 999999.0 ! zonal grid-spacing (degrees) + ppe2_deg = 999999.0 ! meridional grid-spacing (degrees) + ppe1_m = 999999.0 ! zonal grid-spacing (degrees) + ppe2_m = 999999.0 ! meridional grid-spacing (degrees) + ppsur = -4762.96143546300 ! ORCA r4, r2 and r05 coefficients + ppa0 = 255.58049070440 ! (default coefficients) + ppa1 = 245.58132232490 ! + ppkth = 21.43336197938 ! + ppacr = 3.0 ! + ppdzmin = 10.0 ! Minimum vertical spacing + pphmax = 5000.0 ! Maximum depth + ldbletanh = .FALSE. ! Use/do not use double tanf function for vertical coordinates + ppa2 = 999999.0 ! Double tanh function parameters + ppkth2 = 999999.0 ! + ppacr2 = 999999.0 ! +/ +!----------------------------------------------------------------------- +&namzgr ! vertical coordinate (default: NO selection) +!----------------------------------------------------------------------- + ln_zco = .true. ! z-coordinate - full steps + ln_zps = .false. ! z-coordinate - partial steps + ln_sco = .false. ! s- or hybrid z-s-coordinate + ln_isfcav = .false. ! ice shelf cavity + ln_linssh = .false. ! linear free surface +/ +""" + + +NML_REF_PATH = pooch.retrieve( + url=( + "https://forge.ipsl.jussieu.fr/nemo/svn/utils/tools_r4.0-HEAD/DOMAINcfg/" + "namelist_ref?p=12672" + ), + known_hash="cd6a13cd1cd2c97aff3905a482babd79b9449293a269018e7d30b868fc22fe35", +) diff --git a/pydomcfg/tests/test_accessor.py b/pydomcfg/tests/test_accessor.py index 574d3e0..d2268ae 100644 --- a/pydomcfg/tests/test_accessor.py +++ b/pydomcfg/tests/test_accessor.py @@ -16,9 +16,22 @@ def test_jpk(): ds_bathy.domcfg.zco() # jpk must be > 0 - with pytest.raises(ValueError, match="`jpk` MUST be > 0"): + with pytest.raises(ValueError, match="`jpk` MUST be >= 0"): ds_bathy.domcfg.jpk = -1 # Has been set correctly. ds_bathy.domcfg.jpk = 1 assert ds_bathy.domcfg.jpk == 1 + + +def test_nml_ref_path(): + pytest.importorskip("f90nml") + + # Initialize test class + ds_bathy = Bathymetry(1.0e3, 1.2e3, 1, 1).flat(5.0e3) + + with pytest.raises( + ValueError, + match=r"Set `nml_ref_path` before calling `obj.domcfg.from_namelist\(\)`", + ): + ds_bathy.domcfg.from_namelist("dummy") diff --git a/pydomcfg/tests/test_utils.py b/pydomcfg/tests/test_utils.py index 648f1fb..c48cc83 100644 --- a/pydomcfg/tests/test_utils.py +++ b/pydomcfg/tests/test_utils.py @@ -3,9 +3,10 @@ """ import numpy as np +import pytest import xarray as xr -from pydomcfg.utils import generate_cartesian_grid +from pydomcfg.utils import _check_namelist_entries, generate_cartesian_grid def test_generate_cartesian_grid(): @@ -50,3 +51,32 @@ def test_generate_cartesian_grid(): # nav_lon, nav_lat for expected, actual in zip(["glamt", "gphit"], ["nav_lon", "nav_lat"]): xr.testing.assert_equal(ds[expected], ds[actual]) + + +def test_check_namelist_entries(): + + error_match = ( + "Value does not match expected types for 'pptest'." + "\nValue: 'wrong'\nType: 'str'" + "\nExpected types: \\('NoneType', 'int', 'float'\\)" + ) + with pytest.raises(TypeError, match=error_match): + _check_namelist_entries({"pptest": "wrong"}) + + error_match = ( + "Mismatch in number of values provided for 'sn_test'." + "\nValues: \\[None\\]\nNumber of values: 1\nExpected number of values: 9" + ) + with pytest.raises(ValueError, match=error_match): + _check_namelist_entries({"sn_test": [None]}) + + error_match = ( + "Values do not match expected types for 'sn_test'." + "\nValues: \\[None, None, None, None, None, None, None, None, None\\]" + "\nTypes: \\['NoneType', 'NoneType', 'NoneType', 'NoneType', 'NoneType'," + " 'NoneType', 'NoneType', 'NoneType', 'NoneType'\\]" + "\nExpected types:" + " \\['str', 'int', 'str', 'bool', 'bool', 'str', 'str', 'str', 'str'\\]" + ) + with pytest.raises(TypeError, match=error_match): + _check_namelist_entries({"sn_test": [None] * 9}) diff --git a/pydomcfg/tests/test_zco.py b/pydomcfg/tests/test_zco.py index 3881de2..510310f 100644 --- a/pydomcfg/tests/test_zco.py +++ b/pydomcfg/tests/test_zco.py @@ -2,6 +2,8 @@ Tests for zco """ +from io import StringIO + import numpy as np import pytest import xarray as xr @@ -9,10 +11,11 @@ import pydomcfg # noqa: F401 from .bathymetry import Bathymetry -from .data import ORCA2_VGRID +from .data import NML_REF_PATH, ORCA2_NAMELIST, ORCA2_VGRID -def test_zco_orca2(): +@pytest.mark.parametrize("from_namelist", (True, False)) +def test_zco_orca2(from_namelist): """ The test consists in reproducing ORCA2 grid z3T/W and e3T/W as computed by NEMO v3.6 @@ -24,22 +27,31 @@ def test_zco_orca2(): # Bathymetry dataset ds_bathy = Bathymetry(1.0e3, 1.2e3, 1, 1).flat(5.0e3) - # Set number of vertical levels - ds_bathy.domcfg.jpk = 31 + if from_namelist: + pytest.importorskip("f90nml") - # zco mesh with analytical e3 using ORCA2 input parameters - # See pag 62 of v3.6 manual for the input parameters - dsz_an = ds_bathy.domcfg.zco( - ppdzmin=10.0, - pphmax=5000.0, - ppkth=21.43336197938, - ppacr=3, - ppsur=-4762.96143546300, - ppa0=255.58049070440, - ppa1=245.58132232490, - ldbletanh=False, - ln_e3_dep=False, - ) + # Set reference namelist + ds_bathy.domcfg.nml_ref_path = NML_REF_PATH + + # Infer parameters from namelist + dsz_an = ds_bathy.domcfg.from_namelist(StringIO(ORCA2_NAMELIST)) + + else: + # Set number of vertical levels + ds_bathy.domcfg.jpk = 31 + + # zco mesh with analytical e3 using ORCA2 input parameters + # See pag 62 of v3.6 manual for the input parameters + dsz_an = ds_bathy.domcfg.zco( + ppdzmin=10.0, + pphmax=5000.0, + ppkth=21.43336197938, + ppacr=3, + ppsur=-4762.96143546300, + ppa0=255.58049070440, + ppa1=245.58132232490, + ln_e3_dep=False, + ) # reference ocean.output values are # given with 4 digits precision @@ -103,16 +115,12 @@ def test_zco_errors(): ds_bathy = Bathymetry(1.0e3, 1.2e3, 1, 1).flat(5.0e3) ds_bathy.domcfg.jpk = 10 - # Without ldbletanh flag, only allow all pps set or none of them + # Only allow all pps set or none of them with pytest.raises( ValueError, match="ppa2, ppkth2 and ppacr2 MUST be all None or all float" ): ds_bathy.domcfg.zco(**kwargs, ppa2=1, ppkth2=1, ppacr2=None) - # When ldbletanh flag is True, all coefficients must be specified - with pytest.raises(ValueError, match="ppa2, ppkth2 and ppacr2 MUST be all float"): - ds_bathy.domcfg.zco(**kwargs, ldbletanh=True, ppa2=1, ppkth2=1, ppacr2=None) - def test_zco_warnings(): """Make sure we warn when arguments are ignored""" @@ -123,22 +131,13 @@ def test_zco_warnings(): # Uniform: Ignore stretching kwargs = dict(ppdzmin=10, pphmax=5.0e3, ppkth=0, ppacr=0) - expected = ds_bathy.domcfg.zco(**kwargs, ppsur=None, ppa0=999_999, ppa1=None) + expected = ds_bathy.domcfg.zco(**kwargs, ppsur=None, ppa0=None, ppa1=None) with pytest.warns( UserWarning, match="ppsur, ppa0 and ppa1 are ignored when ppacr == ppkth == 0" ): actual = ds_bathy.domcfg.zco(**kwargs, ppsur=2, ppa0=2, ppa1=2) xr.testing.assert_identical(expected, actual) - # ldbletanh OFF: Ignore double tanh - kwargs = dict(ppdzmin=10, pphmax=5.0e3, ldbletanh=False) - expected = ds_bathy.domcfg.zco(**kwargs, ppa2=None, ppkth2=None, ppacr2=None) - with pytest.warns( - UserWarning, match="ppa2, ppkth2 and ppacr2 are ignored when ldbletanh is False" - ): - actual = ds_bathy.domcfg.zco(**kwargs, ppa2=2, ppkth2=2, ppacr2=2) - xr.testing.assert_identical(expected, actual) - # Uniform case: Ignore double tanh kwargs = dict(ppdzmin=10, pphmax=5.0e3, ppkth=0, ppacr=0) expected = ds_bathy.domcfg.zco(**kwargs, ppa2=None, ppkth2=None, ppacr2=None) diff --git a/pydomcfg/utils.py b/pydomcfg/utils.py index 3b2dd53..caba33a 100644 --- a/pydomcfg/utils.py +++ b/pydomcfg/utils.py @@ -2,24 +2,16 @@ Utilities """ -from typing import Hashable, Iterable, Iterator, Optional +import inspect +from collections import ChainMap +from functools import wraps +from typing import Any, Callable, Mapping, Optional, Tuple, TypeVar, Union, cast import numpy as np import xarray as xr from xarray import DataArray, Dataset -NEMO_NONE = 999_999 - - -def _is_nemo_none(var: Hashable) -> bool: - """Assess if a NEMO parameter is None""" - return (var or NEMO_NONE) == NEMO_NONE - - -def _are_nemo_none(var: Iterable) -> Iterator[bool]: - """Iterate over namelist parameters and assess if they are None""" - for v in var: - yield _is_nemo_none(v) +F = TypeVar("F", bound=Callable[..., Any]) def generate_cartesian_grid( @@ -110,3 +102,111 @@ def generate_cartesian_grid( ds = ds.transpose(*("y", "x")) return ds.set_coords(ds.variables) + + +def _maybe_float_to_int(value: Any) -> Any: + """Convert floats that are integers""" + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def _check_namelist_entries(entries_mapper: Mapping[str, Any]): + """ + Check whether namelist entries follow NEMO convention for names and types + + Parameters + ---------- + entries_mapper: Mapping + Object mapping entry names to their values. + + Notes + ----- + This function does not accept nested objects. + I.e., namelist blocks must be chained first. + """ + + prefix_mapper = { + # Use tuple if multiple types are allowed. + # Use lists to specify types allowed for each list item. + "ln_": bool, + "nn_": int, + "rn_": (int, float), + "cn_": str, + "sn_": [str, int, str, bool, bool, str, str, str, str], + "jp": int, + "pp": (type(None), int, float), + "cp": str, + } + + def _type_names( + type_or_tuple: Union[type, Tuple[type, ...]] + ) -> Union[str, Tuple[str, ...]]: + """Return type name(s) for a pretty print""" + if isinstance(type_or_tuple, tuple): + return tuple(type_class.__name__ for type_class in type_or_tuple) + return type_or_tuple.__name__ + + for key, val in entries_mapper.items(): + + for prefix, val_type_or_list_of_types in prefix_mapper.items(): + if key.startswith(prefix): + val_type = ( + val_type_or_list_of_types + if isinstance(val_type_or_list_of_types, (type, tuple)) + else list + ) + list_of_types = ( + val_type_or_list_of_types + if isinstance(val_type_or_list_of_types, list) + else [] + ) + break + else: + # No match found + continue + + val_int = _maybe_float_to_int(val) + if not isinstance(val_int, val_type): + raise TypeError( + f"Value does not match expected types for {key!r}." + f"\nValue: {val!r}" + f"\nType: {_type_names(type(val_int))!r}" + f"\nExpected types: {_type_names(val_type)!r}" + ) + + if list_of_types: + + if len(val) != len(list_of_types): + raise ValueError( + f"Mismatch in number of values provided for {key!r}." + f"\nValues: {val!r}\nNumber of values: {len(val)!r}" + f"\nExpected number of values: {len(list_of_types)!r}" + ) + + val_int = list(map(_maybe_float_to_int, val)) + if not all(map(isinstance, val_int, list_of_types)): + raise TypeError( + f"Values do not match expected types for {key!r}." + f"\nValues: {val!r}" + f"\nTypes: {list(map(_type_names, map(type, val_int)))!r}" + f"\nExpected types: {list(map(_type_names, list_of_types))!r}" + ) + + +def _check_parameters(func: F) -> F: + """ + Decorator to check whether parameter names & types follow NEMO conventions. + To be used with class methods. + """ + + @wraps(func) + def wrapper(self, *args, **kwargs): + argnames = list(inspect.signature(func).parameters) + argnames.remove("self") + args_dict = {name: arg for name, arg in zip(argnames, args)} + args_and_kwargs = ChainMap(args_dict, kwargs) + _check_namelist_entries(args_and_kwargs) + return func(self, *args, **kwargs) + + return cast(F, wrapper) diff --git a/setup.cfg b/setup.cfg index 0e8337e..85fc6c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,10 @@ tests_require = pytest pooch +[options.extras_require] +complete = + f90nml + [tool:pytest] testpaths = pydomcfg/tests addopts = @@ -36,6 +40,8 @@ max-line-length = 88 ignore = E203 # whitespace before ':' - doesn't work well with black W503 # line break before binary operator +exclude = + pydomcfg/tests/data.py [doc8] max-line-length = 88 @@ -44,9 +50,11 @@ max-line-length = 88 # Ignore private and tests, retain __init__ and __call__ ignore_regex = ^(_|test_)(.*)(?