diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 081b673..893a1af 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -10,13 +10,20 @@ on: jobs: ci_tests: - name: visualCaseGen CI tests + name: visualCaseGen CI tests (Python ${{ matrix.python-version }}) runs-on: "ubuntu-latest" + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] defaults: run: shell: bash -el {0} steps: - + - name: Configure Git Identity + run: | + git config --global user.name "github-actions" + git config --global user.email "github-actions@github.com" - name: Install xmllint run: sudo apt-get install -y libxml2-utils - name: Checkout VisualCaseGen to temp folder @@ -29,7 +36,7 @@ jobs: uses: actions/checkout@v4 with: repository: alperaltuntas/CESM - ref: cesm3_0_beta03_gui + ref: cesm3_0_beta08_gui path: CESM #submodules: recursive @@ -53,6 +60,7 @@ jobs: - name: Create visualCaseGen conda env run: | cd CESM/visualCaseGen/ + sed -i "s/python>=3.11.10/python=${{ matrix.python-version }}.*/" environment.yml conda env create --file environment.yml conda activate visualCaseGen diff --git a/.gitmodules b/.gitmodules index 10d4aae..e73ad27 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ -[submodule "external/mom6_bathy"] - path = external/mom6_bathy - url = https://github.com/NCAR/mom6_bathy.git +[submodule "external/mom6_forge"] + path = external/mom6_forge + url = https://github.com/NCAR/mom6_forge.git [submodule "external/ipyfilechooser"] path = external/ipyfilechooser url = https://github.com/alperaltuntas/ipyfilechooser.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..649e1ea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to visualCaseGen + +Thank you for your interest in contributing to visualCaseGen! + +We welcome contributions that fix bugs, introduce new features, and enhance the usability, robustness, or performance of the tool. Here's how you can get involved: + +## Getting Started + +Fork the repository and clone your fork. + +Follow the instructions in the visualCaseGen documentation to set up your local visualCaseGen environment: https://esmci.github.io/visualCaseGen/ + +Create a new branch for your feature or bugfix. + +## What You Can Contribute + +- Bug fixes and issue resolutions +- Enhancements to the GUI or core functionality +- Improved documentation or examples +- Code cleanup and test coverage + +## Guidelines + +- Follow the existing code style and structure. +- Include clear commit messages. +- Test your changes before submitting. +- Add tests if you introduce new features. +- Open a Pull Request with a short summary of your changes. +- If addressing an open issue, mention it in your PR (e.g., Closes #42). + +## Reporting Issues + +Please use the GitHub Issues page to report bugs or suggest features. When reporting: + +- Include the version of visualCaseGen you used and details about your system environment. +- Provide clear steps to reproduce the issue. +- Include screenshots or logs if helpful. diff --git a/ProConPy/config_var.py b/ProConPy/config_var.py index 67653bf..01a12a2 100644 --- a/ProConPy/config_var.py +++ b/ProConPy/config_var.py @@ -334,6 +334,11 @@ def update_options_validities(self): return validities_changed + @property + def valid_options(self): + """Returns the list of valid options for this variable.""" + return [opt for opt in self._options if self._options_validities.get(opt, False)] + def _refresh_widget_options(self): """Refresh the widget options list based on information in the current self._options_validities.""" diff --git a/ProConPy/stage.py b/ProConPy/stage.py index 5f7f52b..6216525 100644 --- a/ProConPy/stage.py +++ b/ProConPy/stage.py @@ -352,7 +352,7 @@ def _proceed(self): return # Display the child stage and its siblings by appending them to the current stage's widget - if self.has_children(): + if self.has_children() and next_stage.is_descendant_of(self): self._widget.add_child_stages(first_child=next_stage) # Proceed the csp solver before enabling the next stage @@ -378,20 +378,24 @@ def get_next(self, full_dfs=False): The next stage to visit, if found. Otherwise, None. """ - if self.has_children(): - return self._get_child_to_enable(full_dfs) - elif self._right is not None: + # First try to get a child stage to enable + if (child_to_enable := self._get_child_to_enable(full_dfs)) is not None: + return child_to_enable + + # No child stage to enable. Try to get the right sibling. + if self._right is not None: return self._right - else: # Backtrack - ancestor = self._parent - while ancestor is not None: - if ancestor._right is not None and ( - full_dfs or not ancestor.has_condition() - ): - return ancestor._right - else: - ancestor = ancestor._parent - return None + + # No child or right sibling. Backtrack to find the next stage. + ancestor = self._parent + while ancestor is not None: + if ancestor._right is not None and ( + full_dfs or not ancestor.has_condition() + ): + return ancestor._right + else: + ancestor = ancestor._parent + return None def _get_child_to_enable(self, full_dfs): """Determine the child stage to activate. @@ -401,6 +405,9 @@ def _get_child_to_enable(self, full_dfs): full_dfs : bool If True, visit all the stages in the stage tree. Otherwise, skip stages whose guards are not satisfied.""" + + if self.has_children() is False: + return None child_to_activate = None @@ -412,6 +419,11 @@ def _get_child_to_enable(self, full_dfs): child_to_activate is None ), "Only one child stage can be activated at a time." child_to_activate = child + + if child_to_activate is None: + # No child guard's condition is satisfied. + # Let the caller handle this case (by backtracking). + return None else: # If children are not guards, the first child is activated. # Note the remaining children will be activated in sequence by their siblings. @@ -419,11 +431,7 @@ def _get_child_to_enable(self, full_dfs): # If the child to activate is a Guard, return it's first child if child_to_activate.has_condition(): - return child_to_activate._children[0] - - assert ( - child_to_activate is not None - ), "At least one child stage must be activated." + child_to_activate = child_to_activate._children[0] return child_to_activate diff --git a/README.md b/README.md index ecd847e..b710795 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![DOI](https://joss.theoj.org/papers/10.21105/joss.09130/status.svg)](https://doi.org/10.21105/joss.09130) + # visualCaseGen Welcome to **visualCaseGen**, an intuitive graphical user interface (GUI) designed to simplify the workflow of creating Community Earth System Model v.3 (CESM3) cases. With visualCaseGen, users can effortlessly explore and define component sets (compsets), select or customize grid resolutions, and prepare their CESM cases, all through an interactive and user-friendly platform that runs on Jupyter notebooks. diff --git a/docs/grid.rst b/docs/grid.rst index db434c7..9da180d 100644 --- a/docs/grid.rst +++ b/docs/grid.rst @@ -64,23 +64,23 @@ Ocean Grid For the ocean grid, if MOM6 is selected as the ocean model, you can either select a standard ocean grid or create a new MOM6 grid. When creating a new MOM6 grid, you'll specify parameters such as grid extent and resolution, after which you'll be directed to a separate notebook that -uses the `mom6_bathy` tool to generate the new grid and bathymetry. +uses the `mom6_forge` tool to generate the new grid and bathymetry. If using a standard ocean grid, select one from the list compatible with your chosen compset and atmosphere grid. If creating a new MOM6 grid, complete the required parameters, then proceed -to launch the `mom6_bathy` tool for final customization. +to launch the `mom6_forge` tool for final customization. .. image:: assets/Stage2_6.png -After specifying all ocean grid parameters, click `Launch mom6_bathy`. This will open an +After specifying all ocean grid parameters, click `Launch mom6_forge`. This will open an auto-generated Jupyter notebook where you can fine-tune the ocean grid, topography, and vertical grid. You can then generate the corresponding MOM6 input files, which will be saved under `ocnice` subdirectory within the directory you specified earlier for saving the new grid files. -For more details on mom6_bathy, refer to its documentation: https://ncar.github.io/mom6_bathy/ +For more details on mom6_forge, refer to its documentation: https://ncar.github.io/mom6_forge/ -.. note:: If the `mom6_bathy` notebook doesn't open automatically, make sure that your browser allows +.. note:: If the `mom6_forge` notebook doesn't open automatically, make sure that your browser allows pop-ups from visualCaseGen. If the notebook still doesn't open, you can manually launch it by - navigating to the `mom6_bathy_notebooks/` directory in your visualCaseGen installation and opening + navigating to the `mom6_forge_notebooks/` directory in your visualCaseGen installation and opening the notebook corresponding to your custom grid. Ocean Initial conditions diff --git a/docs/installation.rst b/docs/installation.rst index bd5e0d4..8f963bc 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,7 +1,7 @@ Installation ====================================== -visualCaseGen is presently bundled with a CESM distribution (fork) based on cesm3_0_beta06. The following +visualCaseGen is presently bundled with a CESM distribution (fork) based on cesm3_0_beta08. The following instructions guide you through obtaining and installing this specific CESM distribution with visualCaseGen. Prerequisite @@ -31,8 +31,8 @@ CESM may take some time. .. code-block:: bash - git clone https://github.com/alperaltuntas/cesm.git -b cesm3_0_beta06_gui cesm3_0_beta06_gui - cd cesm3_0_beta06_gui + git clone https://github.com/alperaltuntas/cesm.git -b cesm3_0_beta08_gui cesm3_0_beta08_gui + cd cesm3_0_beta08_gui ./bin/git-fleximod update This will download the required CESM version, including visualCaseGen. diff --git a/docs/open.rst b/docs/open.rst index 6b71b9e..794419f 100644 --- a/docs/open.rst +++ b/docs/open.rst @@ -64,7 +64,7 @@ To access the GUI on a remote machine, set up SSH tunneling with port forwarding .. code-block:: bash conda activate visualCaseGen - cd [PATH_TO_visualCaseGen] # e.g., ~/cesm3_0_beta03_gui/visualCaseGen + cd [PATH_TO_visualCaseGen] # e.g., ~/cesm3_0_beta08_gui/visualCaseGen jupyter-lab GUI.ipynb --no-browser If the above steps are successful, the command line will display a URL that you can copy and paste into your web browser diff --git a/docs/ridge.rst b/docs/ridge.rst index 0fd4fe6..07b23d3 100644 --- a/docs/ridge.rst +++ b/docs/ridge.rst @@ -126,18 +126,18 @@ assign a unique name to your custom ocean grid. .. image:: assets/ridge7.png -After specifying all ocean grid parameters, click the green **Launch mom6_bathy** button. This will open an auto-generated +After specifying all ocean grid parameters, click the green **Launch mom6_forge** button. This will open an auto-generated Jupyter notebook where you can further customize the ocean bathymetry and generate all necessary grid input files. -The mom6_bathy tool +The mom6_forge tool ~~~~~~~~~~~~~~~~~~~ -.. important:: If the `mom6_bathy` notebook doesn't open automatically, make sure that your browser allows +.. important:: If the `mom6_forge` notebook doesn't open automatically, make sure that your browser allows pop-ups from visualCaseGen. If the notebook still doesn't open, you can manually launch it by - navigating to the `mom6_bathy_notebooks/` directory in your visualCaseGen installation and opening + navigating to the `mom6_forge_notebooks/` directory in your visualCaseGen installation and opening the notebook corresponding to your custom grid. -Once the auto-generated `mom6_bathy` notebook is open, you will see a series of cells that guide you through +Once the auto-generated `mom6_forge` notebook is open, you will see a series of cells that guide you through the process of creating your custom ocean bathymetry. Before proceeding, make sure that the appropriate kernel is selected in the top right corner of the notebook. Then, execute the first two cells to import the necessary libraries and to instantiate the `Grid` object. Notice that the `Grid` object is initialized @@ -145,12 +145,12 @@ with the parameters you specified in visualCaseGen. .. image:: assets/ridge8.png -The third section of mom6_bathy is where a custom bathymetry may be defined. The default option is to produce +The third section of mom6_forge is where a custom bathymetry may be defined. The default option is to produce a flat ocean bottom of depth 2000m. We are going to instead generate an ocean that resembles the ridge world case of `Wu et al (2021) `_ , which has a depth 4000m with some sinusoidal fluctuations, a land ridge of width 1 degree longitude and the furthest poleward 10 degrees latitude at the poles set to land. We will specify this analytically with python -code within mom6_bathy. +code within mom6_forge. First, instantiate the topo object by running the cell below. Note that the `min_depth` parameter, which is set to 10.0m by default determines the minimum bathymetric depth for a cell to be considered ocean. @@ -199,7 +199,7 @@ You can plot the bathymetry to see the results by running the cell below: Vertical Grid ~~~~~~~~~~~~~ -Next, you will need to set up the vertical grid. In the `mom6_bathy` notebook, +Next, you will need to set up the vertical grid. In the `mom6_forge` notebook, a default vertical grid is provided, which consists of 20 layers, and a ratio of 10, which means that the thicknesses of layers gradually increase with depth, and the thickness of the bottom layer is 10 times thicker than the top layer. @@ -222,7 +222,7 @@ vertical grid files (needed by MOM6), CICE grid file (needed by the sea ice mode (needed by the CESM coupler). .. warning:: The paths specified in the cell below are for illustrative purposes only. Do not modify the paths - in your auto-generated `mom6_bathy` notebook. The paths are unique to the user's system and should not be changed, + in your auto-generated `mom6_forge` notebook. The paths are unique to the user's system and should not be changed, since doing so will prevent visualCaseGen from confirming that the grid files have been successfully generated and will prevent configuring CESM accurately. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 3ba2aac..bbb7cdf 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -23,13 +23,13 @@ Commonly Encountered Issues right corner of the welcome dialog to see if any error messages are displayed. If so, submit an issue on the visualCaseGen GitHub repository with the error messages. -- **mom6_bathy notebook doesn't open automatically:** If the `mom6_bathy` notebook doesn't open automatically, +- **mom6_forge notebook doesn't open automatically:** If the `mom6_forge` notebook doesn't open automatically, make sure that your browser allows pop-ups from visualCaseGen. If the notebook still doesn't open, you can manually launch it by - navigating to the `mom6_bathy_notebooks/` directory in your visualCaseGen installation and opening + navigating to the `mom6_forge_notebooks/` directory in your visualCaseGen installation and opening the notebook corresponding to your custom grid. -- **The mom6_bathy interactive point-and-click feature is not working on JupyterHub.** +- **The mom6_forge interactive point-and-click feature is not working on JupyterHub.** If you are experiencing issues with interactive features, such as point-and-click highlighting of individual cells, you may need to (re)install the ipympl extension. Steps to (re)install the ipympl extension: (1) Open JupyterHub. (2) Locate the *Extension Manager* tab on the left sidebar: Look for an icon that resembles a puzzle diff --git a/environment.yml b/environment.yml index a310f6a..dd45833 100644 --- a/environment.yml +++ b/environment.yml @@ -5,10 +5,11 @@ channels: - anaconda dependencies: - - python>=3.11.10,<3.12 - #- xesmf + - python>=3.11.10,<3.13.0 + - libxml2>=2.13,<2.16 + - xesmf>=0.8.10,<0.10.0 - pip - pip: - - -e ./external/mom6_bathy/ + - -e ./external/mom6_forge/ - -e ./external/ipyfilechooser/ - -e ./ # visualCaseGen diff --git a/external/ipyfilechooser b/external/ipyfilechooser index 4c4f94a..67edea7 160000 --- a/external/ipyfilechooser +++ b/external/ipyfilechooser @@ -1 +1 @@ -Subproject commit 4c4f94a8d99c4cd63cb039b75b128f6c550051ad +Subproject commit 67edea7fd41e4c8c31101a27fdcaa924dd2b8a21 diff --git a/external/mom6_bathy b/external/mom6_bathy deleted file mode 160000 index c8156d7..0000000 --- a/external/mom6_bathy +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c8156d7852a970e94769e3549ac97ec168cddd33 diff --git a/external/mom6_forge b/external/mom6_forge new file mode 160000 index 0000000..e4f6652 --- /dev/null +++ b/external/mom6_forge @@ -0,0 +1 @@ +Subproject commit e4f665253d1055a8bc3c4f4a397e89bcf4469511 diff --git a/internal/README.md b/internal/README.md index d987ef7..bbe0200 100644 --- a/internal/README.md +++ b/internal/README.md @@ -1 +1 @@ -This subdirectory is internally used by visualCaseGen to exchange information with mom6_bathy and other tools. Manually interfering with files in this directory may disrupt any active visualCaseGen session. \ No newline at end of file +This subdirectory is internally used by visualCaseGen to exchange information with mom6_forge and other tools. Manually interfering with files in this directory may disrupt any active visualCaseGen session. \ No newline at end of file diff --git a/mom6_bathy_notebooks/README.md b/mom6_forge_notebooks/README.md similarity index 63% rename from mom6_bathy_notebooks/README.md rename to mom6_forge_notebooks/README.md index 9dbcd0f..a02ba39 100644 --- a/mom6_bathy_notebooks/README.md +++ b/mom6_forge_notebooks/README.md @@ -1,2 +1,2 @@ -This directory is for storing mom6_bathy notebooks generated by visualCaseGen. +This directory is for storing mom6_forge notebooks generated by visualCaseGen. When you are done with a notebook, you may manually remove or store it for documentation purposes. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 48ff9d7..1a5d612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "visualCaseGen" -version = "0.1.1" +version = "0.1.3" authors = [ { name = "Alper Altuntas, NCAR", email = "altuntas@ucar.edu" } ] @@ -9,25 +9,25 @@ readme = "README.md" license = { file = "LICENSE.md" } classifiers = [ "Intended Audience :: Science/Research", - "License :: OSI Approved :: LGPL", + "License :: OSI Approved :: Apache 2.0 License", "Programming Language :: Python", "Framework :: Jupyter" ] -requires-python = ">=3.11.10,<3.12" +requires-python = ">=3.11.10,<3.13.0" dependencies = [ - "ipykernel>=6.29,<6.30", - "ipython>=8.2,<8.3", - "jupyterlab>=4.0,<4.1", - "jupyterlab_server>=2.25,<2.26", - "ipywidgets>=8.1.1,<8.2", - "PyYAML>=6.0,<6.1", - "z3-solver>=4.12.3,<4.13", - "networkx>=3.3,<3.4", - "netcdf4>=1.6,<1.7", - "xarray>=2023.12,<2024", - "black>=24.1,<24.2", - "pytest>=8.0,<8.1", - "hypothesis>=6.125.1,<6.126" + "ipykernel>=6.29,<7.3.0", + "ipython>=8.2,<9.13", + "jupyterlab>=4.0,<4.6.0", + "jupyterlab_server>=2.25,<2.29.0", + "ipywidgets>=8.1.1,<8.2.0", + "PyYAML>=6.0,<6.1.0", + "z3-solver>=4.12.3,<4.17.0", + "networkx>=3.3,<3.7.0", + "netcdf4>=1.6,<1.8.0", + "xarray>=2023.12,<2026.3.0", + "black>=24.1,<26.4.0", + "pytest>=8.0,<9.1.0", + "hypothesis>=6.125.1,<6.152.0" ] [build-system] @@ -35,4 +35,4 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = ["ProConPy", "visualCaseGen"] \ No newline at end of file +packages = ["ProConPy", "visualCaseGen"] diff --git a/tests/1_unit/test_cores_case_creator.py b/tests/1_unit/test_cores_case_creator.py index df3425e..8bbb71d 100644 --- a/tests/1_unit/test_cores_case_creator.py +++ b/tests/1_unit/test_cores_case_creator.py @@ -17,11 +17,11 @@ def test_calc_cores_based_on_grid_cases(): assert CaseCreator._calc_cores_based_on_grid(33) == 1 # Test ideal cores amount - assert CaseCreator._calc_cores_based_on_grid(800*128) == 128 + assert CaseCreator._calc_cores_based_on_grid(300*128) == 128 - assert CaseCreator._calc_cores_based_on_grid(800*32) == 128 + assert CaseCreator._calc_cores_based_on_grid(300*32) == 128 - assert CaseCreator._calc_cores_based_on_grid(740 * 780) == 768 + assert CaseCreator._calc_cores_based_on_grid(740 * 780) == 2048 \ No newline at end of file diff --git a/tests/2_integration/test_standard_compset.py b/tests/2_integration/test_standard_compset.py index 1604d41..1e7064a 100755 --- a/tests/2_integration/test_standard_compset.py +++ b/tests/2_integration/test_standard_compset.py @@ -61,7 +61,7 @@ def configure_standard_compset(cime): cvars[f"COMP_{comp_class}_FILTER"].value = "any" ## Pick a standard compset - cvars['COMPSET_ALIAS'].value = "BLT1850" + cvars['COMPSET_ALIAS'].value = "B1850C_LTso" # Generate standard grids list (but don't select any yet) cvars['GRID_MODE'].value = 'Standard' diff --git a/tests/3_system/test_custom_compset_std_grid.py b/tests/3_system/test_custom_compset_std_grid.py new file mode 100755 index 0000000..f91110e --- /dev/null +++ b/tests/3_system/test_custom_compset_std_grid.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +import pytest +import shutil +import os +from pathlib import Path +import tempfile +import time + +from ProConPy.config_var import ConfigVar, cvars +from ProConPy.stage import Stage +from ProConPy.csp_solver import csp +from visualCaseGen.cime_interface import CIME_interface +from visualCaseGen.initialize_configvars import initialize_configvars +from visualCaseGen.initialize_widgets import initialize_widgets +from visualCaseGen.initialize_stages import initialize_stages +from visualCaseGen.specs.options import set_options +from visualCaseGen.specs.relational_constraints import get_relational_constraints +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher +from visualCaseGen.custom_widget_types.case_creator_widget import CaseCreatorWidget +from tests.utils import safe_create_case + + +# do not show logger output +import logging + +logger = logging.getLogger() +logger.setLevel(logging.CRITICAL) + +base_temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "temp")) + + +def test_custom_compset_std_grid(): + """Configure a custom compset with a standard grid: 2000_DATM%JRA_SLND_CICE%PRES_MOM6_SROF_SGLC_WW3. Progress through the stages + until the launch stage is reached.""" + + ConfigVar.reboot() + Stage.reboot() + cime = CIME_interface() + initialize_configvars(cime) + initialize_widgets(cime) + initialize_stages(cime) + set_options(cime) + csp.initialize(cvars, get_relational_constraints(cvars), Stage.first()) + + # At initialization, the first stage should be enabled + assert Stage.first().enabled + cvars['COMPSET_MODE'].value = 'Custom' + + # CCOMPSET_MODE is the only variable in the first stage, so assigning a value to it should disable the first stage + assert not Stage.first().enabled + + # The next stge is Custom Component Set, whose first child is Model Time Period + assert Stage.active().title.startswith('Time Period') + cvars['INITTIME'].value = '1850' + + cvars['COMP_OCN'].value = "mom" + cvars['COMP_ICE'].value = "sice" + cvars['COMP_ATM'].value = "cam" + cvars['COMP_ROF'].value = "mosart" + cvars['COMP_LND'].value = "clm" + cvars['COMP_WAV'].value = "swav" + cvars['COMP_GLC'].value = "sglc" + + + assert Stage.active().title.startswith('Component Physics') + + cvars['COMP_ATM_PHYS'].value = "CAM60" + cvars['COMP_LND_PHYS'].value = "CLM60" + + assert Stage.active().title.startswith('Component Options') + + cvars['COMP_ATM_OPTION'].value = "1PCT" + cvars['COMP_LND_OPTION'].value = "BGC" + cvars['COMP_OCN_OPTION'].value = "(none)" + cvars['COMP_ICE_OPTION'].value = "(none)" + cvars['COMP_ROF_OPTION'].value = "(none)" + + assert Stage.active().title.startswith('2. Grid') + + cvars['GRID_MODE'].value = 'Standard' + cvars['GRID'].value = 'f09_t232' + + assert Stage.active().title.startswith('3. Launch') + launch_stage = Stage.active() + + with tempfile.TemporaryDirectory(dir=base_temp_dir) as temp_case_path: + pass # immediately remove the random temporary directory, + # which will become the caseroot directory below + + cvars["CASEROOT"].value = temp_case_path + + case_creator = launch_stage._widget._main_body.children[-1] + assert isinstance(case_creator, CaseCreatorWidget) + + cvars["PROJECT"].value = "12345" + + # *Click* the create_case button + safe_create_case(cime.srcroot, case_creator) + + # sleep for a bit to allow the case to be created + time.sleep(5) + + # remove the caseroot directory + shutil.rmtree(temp_case_path) + + diff --git a/tests/3_system/test_custom_mom6_grid.py b/tests/3_system/test_custom_mom6_grid.py index c8c5457..1adcbb9 100755 --- a/tests/3_system/test_custom_mom6_grid.py +++ b/tests/3_system/test_custom_mom6_grid.py @@ -16,7 +16,7 @@ from visualCaseGen.initialize_stages import initialize_stages from visualCaseGen.specs.options import set_options from visualCaseGen.specs.relational_constraints import get_relational_constraints -from visualCaseGen.custom_widget_types.mom6_bathy_launcher import MOM6BathyLauncher +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher from visualCaseGen.custom_widget_types.case_creator_widget import CaseCreatorWidget from tests.utils import safe_create_case @@ -90,24 +90,24 @@ def test_custom_mom6_grid(): cvars["OCN_LENY"].value = 160.0 cvars["CUSTOM_OCN_GRID_NAME"].value = "custom_ocn_grid" - # now launch the mom6_bathy notebook + # now launch the mom6_forge notebook - mom6_bathy_launcher_widget = Stage.active()._widget._main_body.children[-1] - assert isinstance(mom6_bathy_launcher_widget, MOM6BathyLauncher) + mom6_forge_launcher_widget = Stage.active()._widget._main_body.children[-1] + assert isinstance(mom6_forge_launcher_widget, MOM6ForgeLauncher) # After setting all the required parameters, the launch button should be enabled - assert mom6_bathy_launcher_widget._btn_launch_mom6_bathy.disabled is False + assert mom6_forge_launcher_widget._btn_launch_mom6_forge.disabled is False # *Click* the launch button - mom6_bathy_launcher_widget._on_btn_launch_clicked(b=None) + mom6_forge_launcher_widget._on_btn_launch_clicked(b=None) # The confirm button should be visible: assert ( - mom6_bathy_launcher_widget._btn_confirm_completion.layout.display != "none" + mom6_forge_launcher_widget._btn_confirm_completion.layout.display != "none" ) # *Click* the confirm button - mom6_bathy_launcher_widget._on_btn_confirm_completion_clicked(b=None) + mom6_forge_launcher_widget._on_btn_confirm_completion_clicked(b=None) # Since the notebook wasn't fully executed, we should remain in the same stage assert Stage.active().title.startswith("Custom Ocean") @@ -116,10 +116,10 @@ def test_custom_mom6_grid(): import nbformat from nbconvert.preprocessors import ExecutePreprocessor, CellExecutionError - # find the only mom6_bathy_*.ipynb file in the mom6_bathy_notebooks directory + # find the only mom6_forge_*.ipynb file in the mom6_forge_notebooks directory ocn_grid_name = cvars['CUSTOM_OCN_GRID_NAME'].value - nb_files = list(Path("mom6_bathy_notebooks").glob(f"mom6_bathy_{ocn_grid_name}*.ipynb")) - assert len(nb_files) == 1, "Expected only one mom6_bathy notebook file" + nb_files = list(Path("mom6_forge_notebooks").glob(f"mom6_forge_{ocn_grid_name}*.ipynb")) + assert len(nb_files) == 1, "Expected only one mom6_forge notebook file" nb_path = nb_files[0] with open(nb_path, "r") as f: @@ -141,7 +141,10 @@ def test_custom_mom6_grid(): assert Stage.active().title.startswith("Simple Initial Conditions") cvars["T_REF"].value = 10.0 - # Since land grid gets set automatically, we should be in the Launch stage: + # Since land grid and runoff grid get set automatically, we should be in the runoff to ocn mapping: + assert Stage.active().title.startswith("Runoff to Ocean Mapping") + cvars["ROF_OCN_MAPPING_STATUS"].value = "skip" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() @@ -170,7 +173,7 @@ def test_custom_mom6_grid(): # If the error is not related to machine porting, raise it raise e - # remove mom6_bathy notebook belonging to the test_grid: + # remove mom6_forge notebook belonging to the test_grid: if os.path.exists(nb_path): os.remove(nb_path) diff --git a/tests/3_system/test_f2000_custom_grid.py b/tests/3_system/test_f2000_custom_grid.py index 4163006..a972cc4 100755 --- a/tests/3_system/test_f2000_custom_grid.py +++ b/tests/3_system/test_f2000_custom_grid.py @@ -18,7 +18,7 @@ from visualCaseGen.specs.relational_constraints import get_relational_constraints from visualCaseGen.custom_widget_types.clm_modifier_launcher import MeshMaskModifierLauncher, FsurdatModifierLauncher from visualCaseGen.custom_widget_types.case_creator_widget import CaseCreatorWidget -from visualCaseGen.custom_widget_types.mom6_bathy_launcher import MOM6BathyLauncher +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher from tests.utils import safe_create_case @@ -106,8 +106,8 @@ def construct_custom_f2000_compset(): cvars["COMP_ROF_OPTION"].value = "(none)" cvars["COMP_GLC_OPTION"].value = "NOEVOLVE" -def construct_standard_blt1850(cime): - """Construct a standard BLT1850 case.""" +def construct_standard_b1850(cime): + """Construct a standard B1850C_LTso case.""" assert os.path.exists(base_temp_dir), "temp testing directory does not exist" # At initialization, the first stage should be enabled @@ -131,7 +131,7 @@ def construct_standard_blt1850(cime): cvars[f"COMP_{comp_class}_FILTER"].value = "any" ## Pick a standard compset - cvars["COMPSET_ALIAS"].value = "BLT1850" + cvars["COMPSET_ALIAS"].value = "B1850C_LTso" def construct_custom_res_from_std_grids(cime): @@ -159,6 +159,9 @@ def construct_custom_res_from_std_grids(cime): assert Stage.active().title.startswith("Land Grid") cvars["CUSTOM_LND_GRID"].value = "0.9x1.25" + assert Stage.active().title.startswith("Runoff Grid") + cvars["CUSTOM_ROF_GRID"].value = "r05" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() @@ -248,6 +251,9 @@ def construct_custom_res_from_modified_clm_grid(cime): # click the "Run Surface Data Modifier" button fsurdat_modifier_launcher._on_launch_clicked(b=None) + assert Stage.active().title.startswith("Runoff Grid") + cvars["CUSTOM_ROF_GRID"].value = "r05" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() @@ -302,30 +308,30 @@ def construct_custom_res_from_new_mom6_grid_modified_clm_grid(cime): cvars["OCN_LENY"].value = 160.0 cvars["CUSTOM_OCN_GRID_NAME"].value = "custom_ocn_grid" - # now launch the mom6_bathy notebook + # now launch the mom6_forge notebook - mom6_bathy_launcher_widget = Stage.active()._widget._main_body.children[-1] - assert isinstance(mom6_bathy_launcher_widget, MOM6BathyLauncher) + mom6_forge_launcher_widget = Stage.active()._widget._main_body.children[-1] + assert isinstance(mom6_forge_launcher_widget, MOM6ForgeLauncher) # After setting all the required parameters, the launch button should be enabled - assert mom6_bathy_launcher_widget._btn_launch_mom6_bathy.disabled is False + assert mom6_forge_launcher_widget._btn_launch_mom6_forge.disabled is False # *Click* the launch button - mom6_bathy_launcher_widget._on_btn_launch_clicked(b=None) + mom6_forge_launcher_widget._on_btn_launch_clicked(b=None) # The confirm button should be visible: assert ( - mom6_bathy_launcher_widget._btn_confirm_completion.layout.display != "none" + mom6_forge_launcher_widget._btn_confirm_completion.layout.display != "none" ) # now, programmatically run the notebook import nbformat from nbconvert.preprocessors import ExecutePreprocessor, CellExecutionError - # find the only mom6_bathy_*.ipynb file in the mom6_bathy_notebooks directory + # find the only mom6_forge_*.ipynb file in the mom6_forge_notebooks directory ocn_grid_name = cvars['CUSTOM_OCN_GRID_NAME'].value - nb_files = list(Path("mom6_bathy_notebooks").glob(f"mom6_bathy_{ocn_grid_name}*.ipynb")) - assert len(nb_files) == 1, "Expected only one mom6_bathy notebook file" + nb_files = list(Path("mom6_forge_notebooks").glob(f"mom6_forge_{ocn_grid_name}*.ipynb")) + assert len(nb_files) == 1, "Expected only one mom6_forge notebook file" nb_path = nb_files[0] with open(nb_path, "r") as f: @@ -364,6 +370,9 @@ def construct_custom_res_from_new_mom6_grid_modified_clm_grid(cime): # click the "Run Surface Data Modifier" button fsurdat_modifier_launcher._on_launch_clicked(b=None) + assert Stage.active().title.startswith("Runoff Grid") + cvars["CUSTOM_ROF_GRID"].value = "r05" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() @@ -384,7 +393,7 @@ def construct_custom_res_from_new_mom6_grid_modified_clm_grid(cime): # sleep for a bit to allow the case to be created time.sleep(5) - # remove mom6_bathy notebook belonging to the test_grid: + # remove mom6_forge notebook belonging to the test_grid: os.remove(nb_path) # remove the caseroot directory @@ -435,7 +444,7 @@ def test_custom_f2000_new_mom6_grid_modified_clm_grid(): if machine not in ["derecho", "casper"]: pytest.skip("This test is only for the derecho and casper machines") - construct_standard_blt1850(cime) + construct_standard_b1850(cime) construct_custom_res_from_new_mom6_grid_modified_clm_grid(cime) if __name__ == "__main__": diff --git a/tests/3_system/test_fhist_custom_grid.py b/tests/3_system/test_fhist_custom_grid.py index d11c4b3..f3a9a99 100755 --- a/tests/3_system/test_fhist_custom_grid.py +++ b/tests/3_system/test_fhist_custom_grid.py @@ -129,6 +129,9 @@ def construct_custom_res_from_std_grids(cime): assert Stage.active().title.startswith("Land Grid") cvars["CUSTOM_LND_GRID"].value = "0.9x1.25" + assert Stage.active().title.startswith("Runoff Grid") + cvars["CUSTOM_ROF_GRID"].value = "r05" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() @@ -218,6 +221,9 @@ def construct_custom_res_from_modified_clm_grid(cime): # click the "Run Surface Data Modifier" button fsurdat_modifier_launcher._on_launch_clicked(b=None) + assert Stage.active().title.startswith("Runoff Grid") + cvars["CUSTOM_ROF_GRID"].value = "r05" + assert Stage.active().title.startswith("3. Launch") launch_stage = Stage.active() diff --git a/tests/3_system/test_rof_ocn_map.py b/tests/3_system/test_rof_ocn_map.py new file mode 100755 index 0000000..099ce0e --- /dev/null +++ b/tests/3_system/test_rof_ocn_map.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +import pytest +import os +import tempfile + +from ProConPy.config_var import ConfigVar, cvars +from ProConPy.stage import Stage +from ProConPy.csp_solver import csp +from visualCaseGen.cime_interface import CIME_interface +from visualCaseGen.initialize_configvars import initialize_configvars +from visualCaseGen.initialize_widgets import initialize_widgets +from visualCaseGen.initialize_stages import initialize_stages +from visualCaseGen.specs.options import set_options +from visualCaseGen.specs.relational_constraints import get_relational_constraints + +# do not show logger output +import logging + +logger = logging.getLogger() +logger.setLevel(logging.CRITICAL) + +base_temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "temp")) + +def configure_for_rof_map(compset_alias): + """This function configures a standard compset with custom grids suitable for testing + the runoff to ocean mapping generator.""" + + ConfigVar.reboot() + Stage.reboot() + cime = CIME_interface() + initialize_configvars(cime) + initialize_widgets(cime) + initialize_stages(cime) + set_options(cime) + csp.initialize(cvars, get_relational_constraints(cvars), Stage.first()) + + assert os.path.exists(base_temp_dir), "temp testing directory does not exist" + + # At initialization, the first stage should be enabled + assert Stage.first().enabled + cvars["COMPSET_MODE"].value = "Standard" + + # COMPSET_MODE is the only variable in the first stage, so assigning a value to it should disable the first stage + assert not Stage.first().enabled + + # The next stage is Custom Component Set, whose first child is Model Time Period + assert Stage.active().title.startswith("Support Level") + cvars["SUPPORT_LEVEL"].value = "All" + + # Apply filters + for comp_class in cime.comp_classes: + cvars[f"COMP_{comp_class}_FILTER"].value = "any" + + ## Pick a standard compset + cvars["COMPSET_ALIAS"].value = compset_alias + + # Create a custom grid + assert Stage.active().title.startswith("2. Grid") + cvars["GRID_MODE"].value = "Custom" + + # Set the custom grid path + assert Stage.active().title.startswith("Custom Grid") + +def test_standard_rof_to_ocn_mapping(): + """This test configures a case with a standard runoff to ocean mapping for a custom resolution""" + + configure_for_rof_map("C_JRA") + + assert Stage.active().title.startswith("Custom Grid") + + with tempfile.TemporaryDirectory(dir=base_temp_dir) as temp_grid_path: + + cvars["CUSTOM_GRID_PATH"].value = temp_grid_path + # since this is a JRA run, the atmosphere grid must automatically be set to TL319 + assert cvars["CUSTOM_ATM_GRID"].value == "TL319" + + # Set the custom ocean grid mode + assert Stage.active().title.startswith("Ocean") + cvars["OCN_GRID_MODE"].value = "Standard" + + assert Stage.active().title.startswith("Ocean Grid") + cvars["CUSTOM_OCN_GRID"].value = "tx2_3v2" + + # Land Grid and Runoff grid should be set automatically + assert Stage.active().title.startswith("Runoff to Ocean Mapping") + + # Currently, smoothing parameters should be unset + assert cvars["ROF_OCN_MAPPING_RMAX"].value is None, "ROF_OCN_MAPPING_RMAX should be None" + assert cvars["ROF_OCN_MAPPING_FOLD"].value is None, "ROF_OCN_MAPPING_FOLD should be None" + + # *Click* "Use Standard Map" button + runoffMappingGenerator = Stage.active()._widget._supplementary_widgets[0] + runoffMappingGenerator._btn_use_standard.click() + + map_status = cvars["ROF_OCN_MAPPING_STATUS"].value + assert map_status == "Standard", f"ROF_OCN_MAPPING_STATUS should be 'Standard', but got: {map_status}" + + # revert stage + assert Stage.active().title.startswith("3. Launch") + Stage.active().revert() + assert Stage.active().title.startswith("Runoff to Ocean Mapping") + +# WARNING: for this test to run successfully, MPI must be available. As such, this test +# cannot be run through the derecho login nodes. +@pytest.mark.slow +def test_custom_rof_to_ocn_mapping(): + """This test configures a case with a custom runoff to ocean mapping for a custom resolution""" + + configure_for_rof_map('C_IAF') + + assert Stage.active().title.startswith("Custom Grid") + + with tempfile.TemporaryDirectory(dir=base_temp_dir) as temp_grid_path: + + cvars["CUSTOM_GRID_PATH"].value = temp_grid_path + # since this is a JRA run, the atmosphere grid must automatically be set to TL319 + assert cvars["CUSTOM_ATM_GRID"].value == "T62" + + # Set the custom ocean grid mode + assert Stage.active().title.startswith("Ocean") + cvars["OCN_GRID_MODE"].value = "Standard" + + assert Stage.active().title.startswith("Ocean Grid") + cvars["CUSTOM_OCN_GRID"].value = "tx2_3v2" + + # Land Grid and Runoff grid should be set automatically + assert Stage.active().title.startswith("Runoff to Ocean Mapping") + + # Currently, smoothing parameters should be unset + assert cvars["ROF_OCN_MAPPING_RMAX"].value is None, "ROF_OCN_MAPPING_RMAX should be None" + assert cvars["ROF_OCN_MAPPING_FOLD"].value is None, "ROF_OCN_MAPPING_FOLD should be None" + + # *Click* the Generate New Map button + runoffMappingGenerator = Stage.active()._widget._supplementary_widgets[0] + runoffMappingGenerator._btn_generate_new.click() + + # After clicking the button, the smoothing parameters should be set to suggested values + assert cvars["ROF_OCN_MAPPING_RMAX"].value is not None, "ROF_OCN_MAPPING_RMAX should have been set to a suggested value" + assert cvars["ROF_OCN_MAPPING_FOLD"].value is not None, "ROF_OCN_MAPPING_FOLD should have been set to a suggested value" + + # *Click* the Run mapping generator button + runoffMappingGenerator._btn_run_generate.click() + + map_status = cvars["ROF_OCN_MAPPING_STATUS"].value + assert map_status.startswith("CUSTOM:"), f"ROF_OCN_MAPPING_STATUS should indicate a custom mapping, but got: {map_status}" + map_paths = map_status.split("CUSTOM:")[1] + nn_map_path, nnsm_map_path = map_paths.split(",") + + # check if the mapping file was actually created + assert os.path.isfile(nn_map_path), f"Nearest neighbor map file was not created at {nn_map_path}" + assert os.path.isfile(nnsm_map_path), f"Smoothed map file was not created at {nnsm_map_path}" + + assert Stage.active().title.startswith("3. Launch") + +if __name__ == "__main__": + test_standard_rof_to_ocn_mapping() + test_custom_rof_to_ocn_mapping() + print("All tests passed!") diff --git a/visualCaseGen/cime_interface.py b/visualCaseGen/cime_interface.py index 21848d4..43c326e 100644 --- a/visualCaseGen/cime_interface.py +++ b/visualCaseGen/cime_interface.py @@ -5,7 +5,7 @@ import socket import getpass import subprocess -from collections import namedtuple +from collections import namedtuple, defaultdict from pathlib import Path from ProConPy.dialog import alert_warning @@ -14,7 +14,7 @@ Compset = namedtuple("Compset", ["alias", "lname", "model"]) Resolution = namedtuple("Resolution", ["alias", "compset", "not_compset", "desc"]) -ComponentGrid = namedtuple("ComponentGrid", ["name", "nx", "ny", "mesh", "desc", "compset_constr", "not_compset_constr"]) +ComponentGrid = namedtuple("ComponentGrid", ["name", "nx", "ny", "mesh", "desc", "compset_constr", "not_compset_constr", "is_default"]) class CIME_interface: @@ -73,15 +73,16 @@ def __init__(self, cesmroot=None): for model in self.models[comp_class]: self._retrieve_model_phys_opt(comp_class, model) self._retrieve_domains_and_resolutions() + self._retrieve_maps() self._retrieve_compsets() self._retrieve_machines() self._retrieve_clm_data() - + def _set_cimeroot(self, cesmroot=None): """Sets the cimeroot attribute, This method is called by the __init__ method. The cimeroot attribute is set based on the cesmroot argument, which is either passed to the __init__ method or determined from the CESMROOT environment variable. - + Parameters ---------- cesmroot : str | Path | None @@ -101,7 +102,7 @@ def _set_cimeroot(self, cesmroot=None): cesmroot = Path(Path(filepath).parent.parent) assert cesmroot.is_dir(), "Cannot find CESM root directory!" - # Set cimeroot attribute + # Set cimeroot attribute self.cimeroot = cesmroot / "cime" assert self.cimeroot.is_dir(), f"Cannot find CIME directory at {self.cimeroot}" @@ -110,7 +111,7 @@ def _check_cime_compatibility(self): """Checks the compatibility of the CIME version. This method is called by the __init__ method. The CIME version is determined based on the git tag of the CIME repository. The CIME version is checked for compatibility with visualCaseGen.""" - + # cime git tag: cime_git_tag = subprocess.check_output( ["git", "-C", self.cimeroot, "describe", "--tags"] @@ -352,10 +353,10 @@ def _retrieve_models(self, comp_class): self.models[comp_class].append(model) def _get_domains(self): - """Reads and returns component grids, i.e., domains, from the CIME XML file. The + """Reads and returns component grids, i.e., domains, from the CIME XML file. The compset_constr and not_compset_constr attributes of the ComponentGrid object are not filled in here, but are filled in later in the _retrieve_component_grid_constraints method - + Returns ------- dict @@ -398,12 +399,13 @@ def _get_domains(self): desc=desc+support, compset_constr=set(), # to be filled in later not_compset_constr=set(), # to be filled in later + is_default=False # to be updated later ) return domains def _retrieve_domains_and_resolutions(self): - """Retrieves and stores component grids and model resolutions from the corresponding CIME + """Retrieves and stores component grids and model resolutions from the corresponding CIME XML files. Retrieved resolutions are stored in self.resolutions list and component grids are stored in self.domains dict.""" @@ -413,14 +415,53 @@ def _retrieve_domains_and_resolutions(self): # Get the initial (temporary) dict of domains from the component_grids file domains = self._get_domains() + domain_found = {domain_name: False for domain_name in domains.keys()} grids = self._grids_obj.get_child("grids") - # Component grids dict and resolutions list to be populated in the loop below + # Domains, i.e., component grids, are stored in self.domains dict. The keys of + # self.domains are component names, e.g., "ocnice". The values are dicts where keys are domain names, + # e.g., "tx2_3v2", and values are ComponentGrid named tuples with attributes name, nx, ny, mesh, desc, + # compset_constr, and not_compset_constr. Since these constraints are resolution-specific, and + # not domain-specific, they are initially inserted into sets and then processed appropriately + # in the _process_domain_constraints method, where they are maintained or dropped depending on + # whether they are common across all resolutions that include this domain. self.domains = {comp: {} for comp in self._grids_obj._comp_gridnames} + + # Resolutions, i.e., combinations of component grids, .e.,g "TL319_t232" are stored in self.resolutions list. + # Each resolution is a Resolution named tuple with attributes alias, compset, not_compset, and desc. + # The compset and not_compset attributes are strings that represent the compset constraints for this resolution. self.resolutions = [] - # loop through model grids (resolutions) + # Loop through model grid defaults. i.e., default grids for each model, to populate self.domains + # We read them in case any domain is only listed in the model grid defaults and not in the model grids. + # In that case, we want to make sure that the domain is still included in the self.domains dict. + model_grid_defaults = self._grids_obj.get_child("model_grid_defaults", root=grids) + default_grids = self._grids_obj.get_children("grid", root=model_grid_defaults) + for default_grid in default_grids: + comp_name = self._grids_obj.get(default_grid, "name") # e.g., atm, lnd, ocnice, etc. + compset = self._grids_obj.get(default_grid, "compset") + comp_grid = self._grids_obj.text(default_grid) + if comp_grid == "null": + continue + if comp_grid in domains: + if comp_grid not in self.domains[comp_name]: + self.domains[comp_name][comp_grid] = ComponentGrid( + name=comp_grid, + nx=domains[comp_grid].nx, + ny=domains[comp_grid].ny, + mesh=domains[comp_grid].mesh, + desc=domains[comp_grid].desc, + compset_constr=set(), + not_compset_constr=set(), + is_default=True + ) + domain_found[comp_grid] = True + + # Loop through model grids, i.e., resolutions, to populate self.resolutions. + # Also, for each resolution, loop through the component grids that are part of this resolution + # and add them to the self.domains dict depending on which component name they are associated with. This is + # how we determine which domains (model grids) are part of which component name (e.g., atm, lnd, ocnice, etc.). model_grid_nodes = self._grids_obj.get_children("model_grid", root=grids) for model_grid_node in model_grid_nodes: alias = self._grids_obj.get(model_grid_node, "alias") @@ -428,56 +469,171 @@ def _retrieve_domains_and_resolutions(self): not_compset = self._grids_obj.get(model_grid_node, "not_compset") desc = "" - # loop through component grids that are part of this resolution + # Loop through all component grids that are part of this resolution all_component_grids_found = True grid_nodes = self._grids_obj.get_children("grid", root=model_grid_node) for grid_node in grid_nodes: comp_name = self._grids_obj.get(grid_node, "name") # e.g., atm, lnd, ocnice, etc. comp_grid = self._grids_obj.text(grid_node) - if comp_grid in domains: - if comp_grid not in self.domains[comp_name]: - self.domains[comp_name][comp_grid] = ComponentGrid( - name=comp_grid, - nx=domains[comp_grid].nx, - ny=domains[comp_grid].ny, - mesh=domains[comp_grid].mesh, - desc=domains[comp_grid].desc, - compset_constr=set(), - not_compset_constr=set(), - ) - domain = self.domains[comp_name][comp_grid] - desc += ' | ' + ' ' + comp_name.upper() + ': ' + domain.desc - domain.compset_constr.add(compset) - domain.not_compset_constr.add(not_compset) - else: + + # Skip if the component grid is null. This means that this component is not part of this resolution. + if comp_grid == "null": + continue + + # If the component grid is not found in the domains dict, then this resolution is invalid and we skip it. + if comp_grid not in domains: #logger.warning(f"Domain {comp_grid} not found in component_grids file.") all_component_grids_found = False break - - if not all_component_grids_found: - continue - self.resolutions.append(Resolution(alias, compset, not_compset, desc)) - + # If the component grid is not already in the self.domains dict for this component, add it. + if comp_grid not in self.domains[comp_name]: + self.domains[comp_name][comp_grid] = ComponentGrid( + name=comp_grid, + nx=domains[comp_grid].nx, + ny=domains[comp_grid].ny, + mesh=domains[comp_grid].mesh, + desc=domains[comp_grid].desc, + compset_constr=set(), + not_compset_constr=set(), + is_default=False + ) + domain_found[comp_grid] = True + + # Retrieve the domain object for this component grid and add the compset and not_compset constraints to it. + domain = self.domains[comp_name][comp_grid] + desc += ' | ' + ' ' + comp_name.upper() + ': ' + domain.desc + domain.compset_constr.add(compset) + domain.not_compset_constr.add(not_compset) + + # Add this resolution to the self.resolutions list + if all_component_grids_found: + self.resolutions.append(Resolution(alias, compset, not_compset, desc)) + + # If there are remaining domains that are not found to be belonging to any component, attempt to find out + # which component they belong to by looking at the description of the domain. + descr_tips ={ + 'rof': ('rof', 'runoff'), + 'atm': ('atm', 'atmosphere'), + 'lnd': ('lnd', 'land'), + 'ocnice': ('ocn', 'ocean', 'ice'), + 'glc': ('glc', 'glacier'), + 'wav': ('wav'), + } + for domain in domains: + if not domain_found[domain]: + for comp_name, tips in descr_tips.items(): + if any(tip in domains[domain].desc.lower() for tip in tips): + self.domains[comp_name][domain] = ComponentGrid( + name=domain, + nx=domains[domain].nx, + ny=domains[domain].ny, + mesh=domains[domain].mesh, + desc=domains[domain].desc, + compset_constr=set(), + not_compset_constr=set(), + is_default=False + ) + break + + + # Finally, process the compset and not_compset constraints for each domain (component grid). self._process_domain_constraints() + def get_mesh_path(self, comp_name, domain_name): + """Returns the mesh file path for a given component name and domain name. + + Parameters + ---------- + comp_name : str + component name, e.g., "atm", "lnd", "ocnice", etc. + domain_name : str + domain name, e.g., "tx2_3v2", "gx1v7", etc. + + Returns + ------- + str + mesh file path for the given component name and domain name. + If not found, returns an empty string. + """ + + if comp_name not in self.domains: + logger.error(f"Component {comp_name} not found in domains.") + return '' + if domain_name not in self.domains[comp_name]: + logger.error(f"Domain {domain_name} not found for component {comp_name}.") + return '' + + domain = self.domains[comp_name][domain_name] + mesh_path = domain.mesh + + if 'DIN_LOC_ROOT' in mesh_path: + assert self.din_loc_root is not None, "DIN_LOC_ROOT not set." + mesh_path = mesh_path.replace('$DIN_LOC_ROOT', self.din_loc_root) + mesh_path = mesh_path.replace('${DIN_LOC_ROOT}', self.din_loc_root) + + return mesh_path + + def _retrieve_maps(self): + """Retrieves the grid mapping files from the CIME XML file. The retrieved mapping files are stored + in the self.maps attribute, which is a nested dict where keys are source grids and values + are dicts where keys are destination grids and values are lists of (name, filepath) tuples. + This is currently used only to determine whether a runoff to ocean mapping file needs to be + generated or not. + """ + + assert hasattr(self, '_grids_obj'), "_grids_obj attribute not found. Call _retrieve_domains_and_resolutions() first." + + self.maps = defaultdict(dict) # maps[src_grid][dst_grid] = [(name, filepath), ...] + + gridmaps = self._grids_obj.get_child("gridmaps") + gridmap_nodes = self._grids_obj.get_children("gridmap", root=gridmaps) + for gridmap_node in gridmap_nodes: + comps = list(gridmap_node.attrib.keys()) # not being utilized currently + grids = list(gridmap_node.attrib.values()) + src_grid, dst_grid = grids[0], grids[1] + self.maps[src_grid][dst_grid] = [] + map_nodes = self._grids_obj.get_children("map", root=gridmap_node) + for map_node in map_nodes: + name = self._grids_obj.get(map_node, "name") + path = self._grids_obj.text(map_node) + self.maps[src_grid][dst_grid].append( (name, path) ) + def _process_domain_constraints(self): """Update the compset_constr and not_compset_constr attributes of the ComponentGrid objects in - the self.domains dict. This method is called after the component grids and resolutions have + the self.domains dict. This method is called after the component grids and resolutions have been retrieved. It updates the compset and not_compset constraints for each domain: If a domain is part of one or more resolutions that have no compset/not_compset constraints, then the domain - is deemed unconstrained. Otherwise, the domain is constrained by the disjunction of the - compset/not_compset constraints of all the resolutions it is part of. + is deemed unconstrained. Otherwise, the domain is constrained by the disjunction of the compset + constraints and the conjunction of the not_compset constraints of all the resolutions it is part of. """ for comp_name, domains in self.domains.items(): for domain_name, domain in domains.items(): - final_compset_constr = '' - if None not in domain.compset_constr and len(domain.compset_constr) >= 0: + # compset constraint + if None in domain.compset_constr or len(domain.compset_constr) == 0: + final_compset_constr = '' + else: final_compset_constr = '|'.join(domain.compset_constr) - final_not_compset_constr = '' - if None not in domain.not_compset_constr and len(domain.not_compset_constr) >= 0: - final_not_compset_constr = '|'.join(domain.not_compset_constr) + + # not_compset constraint: collect expressions (i.e., models with or without options) that are + # common across all not_compset_constr sets for this domain. If there are no common expressions + # across all not_compset_constr sets, then the final not_compset_constr is empty. + if None in domain.not_compset_constr or len(domain.not_compset_constr) == 0: + final_not_compset_constr = '' + else: + expr_count = defaultdict(int) + for not_compset_constr in domain.not_compset_constr: + exprs = set(not_compset_constr.split('|')) + for expr in exprs: + expr_count[expr] += 1 + common_exprs = [expr for expr, count in expr_count.items() if count == len(domain.not_compset_constr)] + if common_exprs: + final_not_compset_constr = '|'.join(common_exprs) + else: + final_not_compset_constr = '' + + # Update the domain object with the final compset and not_compset constraints self.domains[comp_name][domain_name] = domain._replace( compset_constr=final_compset_constr, not_compset_constr=final_not_compset_constr @@ -517,7 +673,7 @@ def get_components_from_compset_lname(self, compset_lname): for i, comp_class in enumerate(self.comp_classes): components[comp_class] = compset_lname_split[i+1] - + return components @@ -680,7 +836,7 @@ def _handle_machine_not_ported(self): ) logger.warning( "Please set the CIME machine in the visualCaseGen configuration file." - ) + ) return diff --git a/visualCaseGen/config_vars/grid_vars.py b/visualCaseGen/config_vars/grid_vars.py index 3313279..901eda7 100644 --- a/visualCaseGen/config_vars/grid_vars.py +++ b/visualCaseGen/config_vars/grid_vars.py @@ -53,7 +53,7 @@ def initialize_custom_grid_variables(cime): ConfigVarInt("OCN_NY") # number of cells in y-direction ConfigVarReal("OCN_LENX") # grid length in x-direction ConfigVarReal("OCN_LENY") # grid length in y-direction - ConfigVarStr('MB_ATTEMPT_ID') # latest mom6_bathy attempt id (auxiliary variable) + ConfigVarStr('MB_ATTEMPT_ID') # latest mom6_forge attempt id (auxiliary variable) ConfigVarStr( "MOM6_BATHY_STATUS", widget_none_val="" ) # a status variable to prevent the completion of the stage @@ -151,3 +151,10 @@ def default_fsurdat_area_spec(): # Note: this var isn't a part of any of the stages. ConfigVarStr("FSURDAT_MOD_STATUS", widget_none_val="") # a status variable to prevent the completion of the stage prematurely + # A preexisting ROF grid to be picked for custom grid + ConfigVarStrMS("CUSTOM_ROF_GRID") + + # Runoff to ocean mapping status + ConfigVarStr("ROF_OCN_MAPPING_STATUS", widget_none_val="") + ConfigVarReal("ROF_OCN_MAPPING_RMAX") + ConfigVarReal("ROF_OCN_MAPPING_FOLD") diff --git a/visualCaseGen/custom_widget_types/case_creator.py b/visualCaseGen/custom_widget_types/case_creator.py index 8205857..fbe8e0b 100644 --- a/visualCaseGen/custom_widget_types/case_creator.py +++ b/visualCaseGen/custom_widget_types/case_creator.py @@ -9,9 +9,9 @@ import math from ProConPy.config_var import cvars -from visualCaseGen.custom_widget_types.mom6_bathy_launcher import MOM6BathyLauncher +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher from visualCaseGen.custom_widget_types.dummy_output import DummyOutput -from visualCaseGen.custom_widget_types.case_tools import xmlchange, run_case_setup, append_user_nl +from visualCaseGen.custom_widget_types.case_tools import xmlchange, run_case_setup, append_user_nl, is_ccs_config_writeable COMMENT = "\033[01;96m" # bold, cyan SUCCESS = "\033[1;32m" # bold, green @@ -23,7 +23,7 @@ class CaseCreator: """The base class for CaseCreatorWidget. Here, backend functionalities are implemented.""" - def __init__(self, cime, output=None, allow_xml_override=False): + def __init__(self, cime, output=None, allow_xml_override=False, add_grids_to_ccs_config = True): """Initialize CaseCreator object. Parameters @@ -40,23 +40,25 @@ def __init__(self, cime, output=None, allow_xml_override=False): self._cime = cime self._out = DummyOutput() if output is None else output self._allow_xml_override = allow_xml_override + self._add_grids_to_ccs_config = add_grids_to_ccs_config # By default, visualCaseGen assigns grids through ccs_config, if not possible (which can happen if the user does not own the sandbox), it is possible to apply grid changes through xmlchanges instead. + assert is_ccs_config_writeable or not add_grids_to_ccs_config, "Cannot write to ccs_config xml files. Please set add_grids_to_ccs_config to False to apply grid changes through xmlchanges." def revert_launch(self, do_exec=True): """This function is called when the case creation fails. It reverts the changes made to the ccs_config xml files.""" - - mg = "ccs_config/modelgrid_aliases_nuopc.xml" - if (Path(self._cime.srcroot) / f"{mg}.orig").exists(): - shutil.move( - Path(self._cime.srcroot) / f"{mg}.orig", - Path(self._cime.srcroot) / f"{mg}" - ) - cg = "ccs_config/component_grids_nuopc.xml" - if (Path(self._cime.srcroot) / f"{cg}.orig").exists(): - shutil.move( - Path(self._cime.srcroot) / f"{cg}.orig", - Path(self._cime.srcroot) / f"{cg}" - ) + if self._add_grids_to_ccs_config: + mg = "ccs_config/modelgrid_aliases_nuopc.xml" + if (Path(self._cime.srcroot) / f"{mg}.orig").exists(): + shutil.move( + Path(self._cime.srcroot) / f"{mg}.orig", + Path(self._cime.srcroot) / f"{mg}" + ) + cg = "ccs_config/component_grids_nuopc.xml" + if (Path(self._cime.srcroot) / f"{cg}.orig").exists(): + shutil.move( + Path(self._cime.srcroot) / f"{cg}.orig", + Path(self._cime.srcroot) / f"{cg}" + ) def _remove_orig_xml_files(self): """This function is called when the case creation and modification process is successful. @@ -130,7 +132,10 @@ def create_case(self, do_exec): if cvars["GRID_MODE"].value == "Standard": resolution = cvars["GRID"].value elif cvars["GRID_MODE"].value == "Custom": - resolution = Path(cvars["CUSTOM_GRID_PATH"].value).name + if self._add_grids_to_ccs_config: + resolution = Path(cvars["CUSTOM_GRID_PATH"].value).name + else: + resolution = "USER_RES" # Set to a default visualCaseGen Resolution since grids are changed through xml changes else: raise RuntimeError(f"Unknown grid mode: {cvars['GRID_MODE'].value}") @@ -139,11 +144,16 @@ def create_case(self, do_exec): print(f"{COMMENT}Creating case...{RESET}\n") # First, update ccs_config xml files to add custom grid information if needed: - self._update_ccs_config(do_exec) + if self._add_grids_to_ccs_config: + self._update_ccs_config(do_exec) # Run create_newcase self._run_create_newcase(caseroot, compset, resolution, do_exec) + # If we don't pick the grids through ccs_config, use xml changes + if not self._add_grids_to_ccs_config: + self._update_grids_via_xmlchange(do_exec) + # Navigate to the case directory: with self._out: print(f"{COMMENT}Navigating to the case directory:{RESET}\n") @@ -160,7 +170,8 @@ def create_case(self, do_exec): # Clean up: if do_exec: - self._remove_orig_xml_files() + if self._add_grids_to_ccs_config: + self._remove_orig_xml_files() cvars["CASE_CREATOR_STATUS"].value = "OK" with self._out: caseroot = cvars["CASEROOT"].value @@ -223,6 +234,8 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): # Component grid names: atm_grid = cvars["CUSTOM_ATM_GRID"].value lnd_grid = cvars["CUSTOM_LND_GRID"].value + rof_grid = cvars["CUSTOM_ROF_GRID"].value + # modelgrid_aliases xml file that stores resolutions: srcroot = self._cime.srcroot ccs_config_root = Path(srcroot) / "ccs_config" @@ -236,15 +249,22 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): modelgrid_aliases_xml = modelgrid_aliases_xml.as_posix() # confirm that modelgrid_aliases xml file is writeable: - if not os.access(modelgrid_aliases_xml, os.W_OK): + if not is_ccs_config_writeable(self._cime): raise RuntimeError(f"Cannot write to {modelgrid_aliases_xml}.") + # Construct the component grids string to be logged: + component_grids_str = f' atm grid: "{atm_grid}" \n' + component_grids_str += f' lnd grid: "{lnd_grid}" \n' + component_grids_str += f' ocn grid: "{ocn_grid}".\n' + if rof_grid is not None and rof_grid != "": + component_grids_str += f' rof grid: "{rof_grid}".\n' + # log the modification of modelgrid_aliases.xml: with self._out: print( f'{BPOINT} Updating ccs_config/modelgrid_aliases_nuopc.xml file to include the new ' f'resolution "{resolution_name}" consisting of the following component grids.\n' - f' atm grid: "{atm_grid}", lnd grid: "{lnd_grid}", ocn grid: "{ocn_grid}".\n' + f'{component_grids_str}' ) # Read in xml file and generate grids object file: @@ -278,6 +298,7 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): ) new_atm_grid.text = atm_grid + # Add lnd grid to resolution entry: new_lnd_grid = SubElement( new_resolution, "grid", @@ -285,6 +306,7 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): ) new_lnd_grid.text = lnd_grid + # Add ocn grid to resolution entry: new_ocnice_grid = SubElement( new_resolution, "grid", @@ -292,6 +314,16 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): ) new_ocnice_grid.text = ocn_grid + # Add rof grid to resolution entry if it exists: + if rof_grid is not None and rof_grid != "": + new_rof_grid = SubElement( + new_resolution, + "grid", + attrib={"name": "rof"}, + ) + new_rof_grid.text = rof_grid + + if not do_exec: return @@ -403,7 +435,7 @@ def _update_component_grids( new_domain, "desc", ) - desc.text = f"New ocean grid {ocn_grid} generated by mom6_bathy" + desc.text = f"New ocean grid {ocn_grid} generated by mom6_forge" if not do_exec: return @@ -485,9 +517,31 @@ def _run_create_newcase(self, caseroot, compset, resolution, do_exec): raise RuntimeError("Error creating case.") def _apply_all_xmlchanges(self, do_exec): + """Apply all the necessary xmlchanges to the case. + + Parameters + ---------- + do_exec : bool + If True, execute the commands. If False, only print them. + """ + + # If standard grid is selected, no modifications are needed: + grid_mode = cvars["GRID_MODE"].value + if grid_mode == "Standard": + return # no modifications needed for standard grid + else: + assert grid_mode == "Custom", f"Unknown grid mode: {grid_mode}" + + self._apply_lnd_grid_xmlchanges(do_exec) + self._apply_ocn_grid_xmlchanges(do_exec) + self._apply_runoff_ocn_mapping_xmlchanges(do_exec) + + + def _apply_lnd_grid_xmlchanges(self, do_exec): + """Apply xmlchanges related to custom land grid if needed.""" lnd_grid_mode = cvars["LND_GRID_MODE"].value - if lnd_grid_mode == "Modified": + if self._add_grids_to_ccs_config and lnd_grid_mode == "Modified": if cvars["COMP_OCN"].value != "mom": with self._out: print(f"{COMMENT}Apply custom land grid xml changes:{RESET}\n") @@ -507,29 +561,47 @@ def _apply_all_xmlchanges(self, do_exec): xmlchange("MASK_MESH", modified_mask_mesh, do_exec, self._is_non_local(), self._out) else: assert lnd_grid_mode in [None, "", "Standard"], f"Unknown land grid mode: {lnd_grid_mode}" + + def _apply_ocn_grid_xmlchanges(self, do_exec): + """Apply xmlchanges related to custom ocean grid if needed.""" - # Set NTASKS based on grid size. e.g. NX * NY < max_pts_per_core - if cvars["COMP_OCN"].value == "mom": + # Set NTASKS based on grid size if custom ocn grid. e.g. NX * NY < max_pts_per_core + if cvars["COMP_OCN"].value == "mom" and cvars["OCN_GRID_MODE"].value == "Custom": num_points = int(cvars["OCN_NX"].value) * int(cvars["OCN_NY"].value) cores = CaseCreator._calc_cores_based_on_grid(num_points) with self._out: print(f"{COMMENT}Apply NTASK grid xml changes:{RESET}\n") xmlchange("NTASKS_OCN",cores, do_exec, self._is_non_local(), self._out) + + def _apply_runoff_ocn_mapping_xmlchanges(self, do_exec): + """Apply xmlchanges related to runoff to ocean mapping files if custom mapping is selected.""" + + if (rof_ocn_mapping_status := cvars["ROF_OCN_MAPPING_STATUS"].value) is not None: + if rof_ocn_mapping_status.startswith("CUSTOM:"): + mapping_files = rof_ocn_mapping_status[7:] + nn_map_file, nnsm_map_file = mapping_files.split(",") + with self._out: + print(f"{COMMENT}Apply runoff to ocean mapping xml changes:{RESET}\n") + xmlchange("ROF2OCN_ICE_RMAPNAME", nnsm_map_file, do_exec, self._is_non_local(), self._out) + xmlchange("ROF2OCN_LIQ_RMAPNAME", nnsm_map_file, do_exec, self._is_non_local(), self._out) + @staticmethod - def _calc_cores_based_on_grid( num_points, min_points_per_core = 32, max_points_per_core = 800, ideal_multiple_of_cores_used = 128): + def _calc_cores_based_on_grid( num_points, min_points_per_core = 32, max_points_per_core = 300, ideal_multiple_of_cores_used = 128): """Calculate the number of cores based on the grid size.""" min_cores = math.ceil(num_points/max_points_per_core) - max_cores = math.ceil(num_points/min_points_per_core) + max_cores = math.ceil(num_points/min_points_per_core) + + # If min_cores is less than the first multiple of ideal cores, just return the min_cores + if max_cores < ideal_multiple_of_cores_used: + return min_cores # Request a multiple of the entire core (ideal_multiple_of_cores_used) starting from the min ideal_cores = ((min_cores + ideal_multiple_of_cores_used - 1) // ideal_multiple_of_cores_used) * ideal_multiple_of_cores_used - if ideal_cores <= max_cores: - return ideal_cores - else: - return (max_cores+min_cores)//2 + return ideal_cores + def _apply_user_nl_changes(self, model, var_val_pairs, do_exec, comment=None, log_title=True): """Apply changes to a given user_nl file.""" @@ -570,10 +642,10 @@ def _apply_mom_namelist_changes(self, do_exec): ocn_grid_mode == "Create New" ), f"Unknown ocean grid mode: {ocn_grid_mode}" - supergrid_file_path = MOM6BathyLauncher.supergrid_file_path() - topo_file_path = MOM6BathyLauncher.topo_file_path() - vgrid_file_path = MOM6BathyLauncher.vgrid_file_path() - ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() + supergrid_file_path = MOM6ForgeLauncher.supergrid_file_path() + topo_file_path = MOM6ForgeLauncher.topo_file_path() + vgrid_file_path = MOM6ForgeLauncher.vgrid_file_path() + ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() # read in min and max depth from the MOM6 topo file: ds_topo = xr.open_dataset(topo_file_path) @@ -586,7 +658,7 @@ def _apply_mom_namelist_changes(self, do_exec): # Determine timesteps based on the grid resolution (assuming coupling frequency of 1800.0 sec): res_x = float(cvars['OCN_LENX'].value) / int(cvars["OCN_NX"].value) res_y = float(cvars['OCN_LENY'].value) / int(cvars["OCN_NY"].value) - dt = 600.0 * min(res_x,res_y) # A 1-deg grid should have ~600 sec tstep (a safe value) + dt = 7200.0 * min(res_x,res_y) # A 1-deg grid should have ~600 sec tstep (a safe value) # Make sure 1800.0 is a multiple of dt and dt is a power of 2 and/or 3: dt = min((1800.0 / n for n in [2**i * 3**j for i in range(10) for j in range(6)] if 1800.0 % n == 0), key=lambda x: abs(dt - x)) # Try setting dt_therm to dt*4, or dt*3, or dt*3, depending on whether 1800.0 becomes a multiple of dt: @@ -680,7 +752,7 @@ def _apply_cice_namelist_changes(self, do_exec): if not comp_ice.startswith("cice"): return - cice_grid_file_path = MOM6BathyLauncher.cice_grid_file_path() + cice_grid_file_path = MOM6ForgeLauncher.cice_grid_file_path() self._apply_user_nl_changes( "cice", [ @@ -732,3 +804,117 @@ def _apply_clm_namelist_changes(self, do_exec): ]) self._apply_user_nl_changes("clm", user_nl_clm_changes, do_exec) + + def _update_grids_via_xmlchange(self, do_exec): + """Update the case with custom grid information if needed. + This function is called after running create_newcase.""" + + if cvars["GRID_MODE"].value == "Standard": + return + else: + assert ( + cvars["GRID_MODE"].value == "Custom" + ), f"Unknown grid mode: {cvars['GRID_MODE'].value}" + + # check if custom grid path exists: + ocn_grid_mode = cvars["OCN_GRID_MODE"].value + lnd_grid_mode = cvars["LND_GRID_MODE"].value + custom_grid_path = Path(cvars["CUSTOM_GRID_PATH"].value) + if not custom_grid_path.exists(): + if ocn_grid_mode != "Standard" or lnd_grid_mode != "Standard": + raise RuntimeError(f"Custom grid path {custom_grid_path} does not exist.") + + ocn_grid = None + if ocn_grid_mode == "Standard": + ocn_grid = cvars["CUSTOM_OCN_GRID"].value + elif ocn_grid_mode in ["Modify Existing", "Create New"]: + ocn_grid = cvars["CUSTOM_OCN_GRID_NAME"].value + else: + raise RuntimeError(f"Unknown ocean grid mode: {ocn_grid_mode}") + if ocn_grid is None: + raise RuntimeError("No ocean grid specified.") + + self._update_component_grids_xml(custom_grid_path, ocn_grid, ocn_grid_mode, do_exec) + + + def _update_component_grids_xml( + self, custom_grid_path, ocn_grid, ocn_grid_mode, do_exec + ): + """Update the component_grids xml file with custom ocnice grid information. + This function is called before running create_newcase. + + Parameters + ---------- + custom_grid_path : Path + The path to the custom grid directory. + ocn_grid : str + The name of the custom ocean grid. + ocn_grid_mode : str + The ocean grid mode. It can be "Standard", "Modify Existing", or "Create New". + do_exec : bool + If True, execute the commands. If False, only print them. + """ + + if ocn_grid_mode == "Create New": + ocn_dir = custom_grid_path / "ocnice" + assert ocn_dir.exists(), f"Ocean grid directory {ocn_dir} does not exist." + + ocn_mesh = ( + ocn_dir / f"ESMF_mesh_{ocn_grid}_{cvars['MB_ATTEMPT_ID'].value}.nc" + ) + assert ocn_mesh.exists(), f"Ocean mesh file {ocn_mesh} does not exist." + + + # log the modification of component_grids.xml: + with self._out: + print( + f'{BPOINT} Updating case xml variables to include ' + f'newly generated ocean grid "{ocn_grid}" with the following properties:\n' + f' nx: {cvars["OCN_NX"].value}, ny: {cvars["OCN_NY"].value}.' + f' ocean mesh: {ocn_mesh}.{RESET}\n' + ) + + xmlchange("OCN_NX", cvars["OCN_NX"].value, do_exec, self._is_non_local(), self._out) + + xmlchange("OCN_NY", cvars["OCN_NY"].value, do_exec, self._is_non_local(), self._out) + + xmlchange("OCN_DOMAIN_MESH", ocn_mesh.as_posix(), do_exec, self._is_non_local(), self._out) + + xmlchange("ICE_DOMAIN_MESH", ocn_mesh.as_posix(), do_exec, self._is_non_local(), self._out) + + xmlchange("MASK_MESH", ocn_mesh.as_posix(), do_exec, self._is_non_local(), self._out) + + xmlchange("ATM_GRID", cvars["CUSTOM_ATM_GRID"].value, do_exec, self._is_non_local(), self._out) + + xmlchange("LND_GRID", cvars["CUSTOM_LND_GRID"].value, do_exec, self._is_non_local(), self._out) + + xmlchange("ATM_DOMAIN_MESH", self._cime.get_mesh_path("atm",cvars["CUSTOM_ATM_GRID"].value), do_exec, self._is_non_local(), self._out) + + xmlchange("LND_DOMAIN_MESH", self._cime.get_mesh_path("lnd",cvars["CUSTOM_LND_GRID"].value), do_exec, self._is_non_local(), self._out) + + if cvars["CUSTOM_ROF_GRID"].value is not None and cvars["CUSTOM_ROF_GRID"].value != "" and cvars["CUSTOM_ROF_GRID"].value != "null": + xmlchange("ROF_GRID", cvars["CUSTOM_ROF_GRID"].value, do_exec, self._is_non_local(), self._out) + xmlchange("ROF_DOMAIN_MESH", self._cime.get_mesh_path("rof",cvars["CUSTOM_ROF_GRID"].value), do_exec, self._is_non_local(), self._out) + + + lnd_grid_mode = cvars["LND_GRID_MODE"].value + if lnd_grid_mode == "Modified": + if cvars["COMP_OCN"].value != "mom": + with self._out: + print(f"{COMMENT}Apply custom land grid xml changes:{RESET}\n") + + # TODO: NO LONGER RELEVANT - OCEAN GRIDS ARE DONE THROUGH XML CHANGES AS WELL: instead of xmlchanges, these changes should be made via adding the new lnd domain mesh to + # component_grids_nuopc.xml and modelgrid_aliases_nuopc.xml (just like how we handle new ocean grids) + + # lnd domain mesh + xmlchange("LND_DOMAIN_MESH", cvars["INPUT_MASK_MESH"].value, do_exec, self._is_non_local(), self._out) + + # mask mesh (if modified) + base_lnd_grid = cvars["CUSTOM_LND_GRID"].value + custom_grid_path = Path(cvars["CUSTOM_GRID_PATH"].value) + lnd_dir = custom_grid_path / "lnd" + modified_mask_mesh = lnd_dir / f"{base_lnd_grid}_mesh_mask_modifier.nc" # TODO: the way we get this filename is fragile + assert modified_mask_mesh.exists(), f"Modified mask mesh file {modified_mask_mesh} does not exist." + xmlchange("MASK_MESH", modified_mask_mesh, do_exec, self._is_non_local(), self._out) + else: + assert lnd_grid_mode in [None, "", "Standard"], f"Unknown land grid mode: {lnd_grid_mode}" diff --git a/visualCaseGen/custom_widget_types/case_tools.py b/visualCaseGen/custom_widget_types/case_tools.py index 433b88f..cb5bfdb 100644 --- a/visualCaseGen/custom_widget_types/case_tools.py +++ b/visualCaseGen/custom_widget_types/case_tools.py @@ -1,5 +1,6 @@ from pathlib import Path import subprocess +import os from ProConPy.config_var import cvars from visualCaseGen.custom_widget_types.dummy_output import DummyOutput @@ -8,6 +9,15 @@ RESET = "\033[0m" +def is_ccs_config_writeable(cime): + srcroot = cime.srcroot + ccs_config_root = Path(srcroot) / "ccs_config" + assert ( + ccs_config_root.exists() + ), f"ccs_config_root {ccs_config_root} does not exist." + modelgrid_aliases_xml = ccs_config_root / "modelgrid_aliases_nuopc.xml" + return os.access(modelgrid_aliases_xml, os.W_OK) + def run_case_setup(do_exec, is_non_local=False, out=None): """Run the case.setup script to set up the case instance. diff --git a/visualCaseGen/custom_widget_types/clm_modifier_launcher.py b/visualCaseGen/custom_widget_types/clm_modifier_launcher.py index 920be86..d11d9de 100644 --- a/visualCaseGen/custom_widget_types/clm_modifier_launcher.py +++ b/visualCaseGen/custom_widget_types/clm_modifier_launcher.py @@ -225,6 +225,7 @@ def _write_config(self, config_file_path, modified_file_path): f'lon_dimname = {cvars["LON_DIM_NAME"].value}\n' f'lat_varname = {cvars["LAT_VAR_NAME"].value}\n' f'lon_varname = {cvars["LON_VAR_NAME"].value}\n' + f'lon_type = 360\n' ) @@ -281,6 +282,7 @@ def _write_config(self, config_file_path, modified_file_path): f"lnd_lon_1 = {coords[2]}\n" f"lnd_lon_2 = {coords[3]}\n" f'landmask_file = UNSET\n' + f'lon_type = 360\n' ) elif cvars["FSURDAT_AREA_SPEC"].value.startswith("mask_file:"): f.write( @@ -289,6 +291,7 @@ def _write_config(self, config_file_path, modified_file_path): f"lnd_lon_1 = 0.0\n" f"lnd_lon_2 = 360.0\n" f'landmask_file = {cvars["FSURDAT_AREA_SPEC"].value[10:]}\n' + f'lon_type = 360\n' ) else: raise ValueError("Invalid FSURDAT_AREA_SPEC value") diff --git a/visualCaseGen/custom_widget_types/fsurdat_area_specifier.py b/visualCaseGen/custom_widget_types/fsurdat_area_specifier.py index 28304b0..f98d498 100644 --- a/visualCaseGen/custom_widget_types/fsurdat_area_specifier.py +++ b/visualCaseGen/custom_widget_types/fsurdat_area_specifier.py @@ -40,13 +40,13 @@ def __init__(self, value=None, disabled=False, **kwargs): style={"description_width": "260px"}, ) self._west = BoundedFloatText( - value=0.0, min=-360.0, max=360.0, + value=0.0, min=0.0, max=360.0, description="Westernmost longitude for rectangle:", layout={"display": "none", "width": "350px", "margin": "5px"}, style={"description_width": "260px"}, ) self._east = BoundedFloatText( - value=360.0, min=-360.0, max=360.0, + value=360.0, min=-0.0, max=360.0, description="Easternmost longitude for rectangle:", layout={"display": "none", "width": "350px", "margin": "5px"}, style={"description_width": "260px"}, diff --git a/visualCaseGen/custom_widget_types/mom6_bathy_launcher.py b/visualCaseGen/custom_widget_types/mom6_forge_launcher.py similarity index 76% rename from visualCaseGen/custom_widget_types/mom6_bathy_launcher.py rename to visualCaseGen/custom_widget_types/mom6_forge_launcher.py index 62a9fc4..fa73ca0 100644 --- a/visualCaseGen/custom_widget_types/mom6_bathy_launcher.py +++ b/visualCaseGen/custom_widget_types/mom6_forge_launcher.py @@ -14,16 +14,16 @@ logger = logging.getLogger("\t" + __name__.split(".")[-1]) -class MOM6BathyLauncher(VBox): - """A widget to create and launch a new mom6_bathy notebook. The widget is enabled when all the - required parameters for mom6_bathy are set.""" +class MOM6ForgeLauncher(VBox): + """A widget to create and launch a new mom6_forge notebook. The widget is enabled when all the + required parameters for mom6_forge are set.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) cvars['MB_ATTEMPT_ID'].value = None - self.required_mom6_bathy_vars = [ + self.required_mom6_forge_vars = [ cvars["CUSTOM_GRID_PATH"], cvars["OCN_GRID_MODE"], cvars["OCN_NX"], @@ -35,17 +35,17 @@ def __init__(self, *args, **kwargs): ] # observe changes in the required variables - for var in self.required_mom6_bathy_vars: + for var in self.required_mom6_forge_vars: var._widget.observe(self._on_required_var_change, names="_property_lock", type="change") # Create the main child widgets: Launcg button, Output, and Confirm button - self._btn_launch_mom6_bathy = Button( - description="Launch mom6_bathy", + self._btn_launch_mom6_forge = Button( + description="Launch mom6_forge", button_style="success", layout={"width": "160px", "margin": "10px", "align_self": "center"}, ) - self._btn_launch_mom6_bathy.on_click(self._on_btn_launch_clicked) + self._btn_launch_mom6_forge.on_click(self._on_btn_launch_clicked) self._out = Output() @@ -61,7 +61,7 @@ def __init__(self, *args, **kwargs): self._btn_confirm_completion.on_click(self._on_btn_confirm_completion_clicked) self.children = [ - self._btn_launch_mom6_bathy, + self._btn_launch_mom6_forge, self._out, self._btn_confirm_completion, ] @@ -73,7 +73,7 @@ def disabled(self): @disabled.setter def disabled(self, value): self._out.clear_output() - self._btn_launch_mom6_bathy.disabled = value + self._btn_launch_mom6_forge.disabled = value self._btn_confirm_completion.disabled = value @staticmethod @@ -83,7 +83,7 @@ def get_custom_ocn_grid_path(): return Path(custom_grid_path) / "ocnice" def _on_required_var_change(self, change): - """If any of the required variables are changed, reset the attempt id and mom6_bathy_status.""" + """If any of the required variables are changed, reset the attempt id and mom6_forge_status.""" self._btn_confirm_completion.layout.display = "none" cvars['MB_ATTEMPT_ID'].value = None cvars["MOM6_BATHY_STATUS"].value = None @@ -91,22 +91,22 @@ def _on_required_var_change(self, change): @owh.out.capture() def _on_btn_launch_clicked(self, b): - """Function to launch the mom6_bathy notebook. The function first checks if all the required + """Function to launch the mom6_forge notebook. The function first checks if all the required parameters are set. If not, it displays a warning message. If all required parameters are set, - it generates a new mom6_bathy notebook and opens it in a new tab. The user is then prompted to + it generates a new mom6_forge notebook and opens it in a new tab. The user is then prompted to execute the notebook and confirm completion. Once the notebook is executed, the user can click the "Confirm completion" button to proceed to the next stage.""" - if any(var.value is None for var in self.required_mom6_bathy_vars): + if any(var.value is None for var in self.required_mom6_forge_vars): remaining_params = [ - var.name for var in self.required_mom6_bathy_vars if var.value is None + var.name for var in self.required_mom6_forge_vars if var.value is None ] alert_warning( - f"Please specify all the required parameters before launching mom6_bathy: {remaining_params}" + f"Please specify all the required parameters before launching mom6_forge: {remaining_params}" ) return - # Reset the attempt_id and mom6_bathy_status + # Reset the attempt_id and mom6_forge_status new_attempt_id = str(uuid.uuid1())[:6] cvars['MB_ATTEMPT_ID'].value = new_attempt_id cvars["MOM6_BATHY_STATUS"].value = None @@ -114,17 +114,17 @@ def _on_btn_launch_clicked(self, b): # Determine the path to the new notebook custom_ocn_grid_name = cvars["CUSTOM_OCN_GRID_NAME"].value nb_path = ( - Path("mom6_bathy_notebooks") / f"mom6_bathy_{custom_ocn_grid_name}_{new_attempt_id}.ipynb" + Path("mom6_forge_notebooks") / f"mom6_forge_{custom_ocn_grid_name}_{new_attempt_id}.ipynb" ) - # Launch the mom6_bathy notebook - self._launch_mom6_bathy(nb_path) + # Launch the mom6_forge notebook + self._launch_mom6_forge(nb_path) # Display a message to the user in the output widget with self._out: self._out.clear_output() print( - 'Note: Clicking the "Launch mom6_bathy" button generates a new notebook that ' + 'Note: Clicking the "Launch mom6_forge" button generates a new notebook that ' "should open in a new tab automatically. If not, try manually opening the notebook " f"at the following location: {nb_path}. Follow the instructions and run all cells " 'in the notebook. Once done, click the "Confirm completion" button to proceed.' @@ -135,21 +135,21 @@ def _on_btn_launch_clicked(self, b): @owh.out.capture() def _on_btn_confirm_completion_clicked(self, b): - """Function to confirm completion of mom6_bathy. The function checks if all the required - files are created. If so, it confirms completion of mom6_bathy. If not, it displays a + """Function to confirm completion of mom6_forge. The function checks if all the required + files are created. If so, it confirms completion of mom6_forge. If not, it displays a warning message.""" - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() if custom_ocn_grid_path is None: alert_warning( - "No custom_ocn_grid_path found. Cannot confirm completion of mom6_bathy" + "No custom_ocn_grid_path found. Cannot confirm completion of mom6_forge" ) return attempt_id = cvars['MB_ATTEMPT_ID'].value if attempt_id is None: alert_warning( - "No attempt_id found. Cannot confirm completion of mom6_bathy" + "No attempt_id found. Cannot confirm completion of mom6_forge" ) return @@ -157,15 +157,15 @@ def _on_btn_confirm_completion_clicked(self, b): custom_ocn_grid_name = cvars["CUSTOM_OCN_GRID_NAME"].value warning_msg = ( - "Cannot confirm completion of mom6_bathy. Make sure you've executed all " - + "of the cells in the mom6_bathy notebook. The following file is missing: " + "Cannot confirm completion of mom6_forge. Make sure you've executed all " + + "of the cells in the mom6_forge notebook. The following file is missing: " ) # See if all required files are created: - mom6_supergrid_file = MOM6BathyLauncher.supergrid_file_path() - mom6_topog_file = MOM6BathyLauncher.topo_file_path() - esmf_mesh_file = MOM6BathyLauncher.esmf_mesh_file_path() - cice_grid_file = MOM6BathyLauncher.cice_grid_file_path() + mom6_supergrid_file = MOM6ForgeLauncher.supergrid_file_path() + mom6_topog_file = MOM6ForgeLauncher.topo_file_path() + esmf_mesh_file = MOM6ForgeLauncher.esmf_mesh_file_path() + cice_grid_file = MOM6ForgeLauncher.cice_grid_file_path() required_files = [mom6_supergrid_file, mom6_topog_file, esmf_mesh_file] if "CICE" in cvars["COMP_ICE_PHYS"].value: required_files.append(cice_grid_file) @@ -176,23 +176,23 @@ def _on_btn_confirm_completion_clicked(self, b): return # If all files are found, confirm completion - logger.info(f"Confirmed completion of mom6_bathy for {custom_ocn_grid_name}") + logger.info(f"Confirmed completion of mom6_forge for {custom_ocn_grid_name}") cvars["MOM6_BATHY_STATUS"].value = None cvars["MOM6_BATHY_STATUS"].value = "Complete" # Proceed to the next stage Stage.proceed() - def _launch_mom6_bathy(self, nb_filepath): - """Generate a new mom6_bathy notebook and open it in a new tab. This method gets called when - the user clicks the "Launch mom6_bathy" button.""" - nb = MOM6BathyLauncher._create_notebook_object() - MOM6BathyLauncher._write_notebook(nb, nb_filepath) - MOM6BathyLauncher._open_notebook_in_browser(nb_filepath) + def _launch_mom6_forge(self, nb_filepath): + """Generate a new mom6_forge notebook and open it in a new tab. This method gets called when + the user clicks the "Launch mom6_forge" button.""" + nb = MOM6ForgeLauncher._create_notebook_object() + MOM6ForgeLauncher._write_notebook(nb, nb_filepath) + MOM6ForgeLauncher._open_notebook_in_browser(nb_filepath) @staticmethod def _create_notebook_object(): - """Create a mom6_bathy notebook object based on the current values of the required variables. + """Create a mom6_forge notebook object based on the current values of the required variables. Returns ------- @@ -210,7 +210,7 @@ def _create_notebook_object(): attempt_id = cvars['MB_ATTEMPT_ID'].value # if custom_grid_path doesn't exist, create it: - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() os.makedirs(custom_ocn_grid_path, exist_ok=True) # Create a new notebook: @@ -218,18 +218,18 @@ def _create_notebook_object(): nb["cells"] = [ nbf.v4.new_markdown_cell( - "# mom6_bathy\n" + "# mom6_forge\n" "This notebook is auto-generated by visualCaseGen. " "Please review and execute the cells below to create the new MOM6 grid and bathymetry. " "You can modify the cells as needed, unless otherwise noted, to customize the grid " "and bathymetry." ), - nbf.v4.new_markdown_cell("## 1. Import mom6_bathy"), + nbf.v4.new_markdown_cell("## 1. Import mom6_forge"), nbf.v4.new_code_cell( "%%capture\n" - "from mom6_bathy.grid import Grid\n" - "from mom6_bathy.topo import Topo\n" - "from mom6_bathy.vgrid import VGrid" + "from mom6_forge.grid import Grid\n" + "from mom6_forge.topo import Topo\n" + "from mom6_forge.vgrid import VGrid" ), nbf.v4.new_markdown_cell("## 2. Create horizontal grid\n"), ] @@ -260,9 +260,9 @@ def _create_notebook_object(): nb["cells"].extend( [ nbf.v4.new_markdown_cell( - "***mom6_bathy*** provides several idealized bathymetry options and customization " + "***mom6_forge*** provides several idealized bathymetry options and customization " "methods. Below, we show how to specify the simplest bathymetry configuration, a " - "flat bottom. Customize it as you see fit. See mom6_bathy documentation and " + "flat bottom. Customize it as you see fit. See mom6_forge documentation and " "example notebooks on how to create custom bathymetries. " ), nbf.v4.new_code_cell( @@ -288,7 +288,7 @@ def _create_notebook_object(): nbf.v4.new_code_cell( "# Manually modify the bathymetry\n" "%matplotlib ipympl\n" - "from mom6_bathy.topo_editor import TopoEditor\n" + "from mom6_forge.topo_editor import TopoEditor\n" "TopoEditor(topo)" ), ] @@ -314,22 +314,22 @@ def _create_notebook_object(): save_files_cmd = ( "# Do NOT modify this cell!\n\n" "# MOM6 supergrid file.\n" - f'grid.write_supergrid(f"{MOM6BathyLauncher.supergrid_file_path()}")\n\n' + f'grid.write_supergrid(f"{MOM6ForgeLauncher.supergrid_file_path()}")\n\n' "# Save MOM6 topography file:\n" - f'topo.write_topo(f"{MOM6BathyLauncher.topo_file_path()}")\n\n' + f'topo.write_topo(f"{MOM6ForgeLauncher.topo_file_path()}")\n\n' "# Save MOM6 vertical grid file:\n" - f'vgrid.write(f"{MOM6BathyLauncher.vgrid_file_path()}")\n\n' + f'vgrid.write(f"{MOM6ForgeLauncher.vgrid_file_path()}")\n\n' ) if "CICE" in cvars["COMP_ICE_PHYS"].value: save_files_cmd += ( "# CICE grid file:\n" - f'topo.write_cice_grid(f"{MOM6BathyLauncher.cice_grid_file_path()}")\n\n' + f'topo.write_cice_grid(f"{MOM6ForgeLauncher.cice_grid_file_path()}")\n\n' ) save_files_cmd += ( "# Save ESMF mesh file:\n" - f'topo.write_esmf_mesh(f"{MOM6BathyLauncher.esmf_mesh_file_path()}")' + f'topo.write_esmf_mesh(f"{MOM6ForgeLauncher.esmf_mesh_file_path()}")' ) nb["cells"].extend( @@ -349,19 +349,19 @@ def _create_notebook_object(): return nb def _write_notebook(nb, nb_filepath): - """Write the new mom6_bathy notebook to the specified file path. If the directory doesn't + """Write the new mom6_forge notebook to the specified file path. If the directory doesn't exist, it is created. The notebook is then written to the file path.""" os.makedirs(nb_filepath.parent, exist_ok=True) with open(nb_filepath, "w") as f: nbf.write(nb, f) - logger.info(f"Generated a new mom6_bathy notebook at {nb_filepath}") + logger.info(f"Generated a new mom6_forge notebook at {nb_filepath}") return nb_filepath def _open_notebook_in_browser(nb_filepath): - """Open the new mom6_bathy notebook in the browser. This function is called after the notebook + """Open the new mom6_forge notebook in the browser. This function is called after the notebook is generated. It opens the notebook in a new tab in the browser.""" # Open the new notebook in the browser @@ -396,25 +396,30 @@ def nc_file_suffix(): @staticmethod def supergrid_file_path(): - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() - return custom_ocn_grid_path / f"ocean_hgrid_{MOM6BathyLauncher.nc_file_suffix()}" + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"ocean_hgrid_{MOM6ForgeLauncher.nc_file_suffix()}" @staticmethod def topo_file_path(): - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() - return custom_ocn_grid_path / f"ocean_topog_{MOM6BathyLauncher.nc_file_suffix()}" + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"ocean_topog_{MOM6ForgeLauncher.nc_file_suffix()}" @staticmethod def vgrid_file_path(): - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() - return custom_ocn_grid_path / f"ocean_vgrid_{MOM6BathyLauncher.nc_file_suffix()}" + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"ocean_vgrid_{MOM6ForgeLauncher.nc_file_suffix()}" + + @staticmethod + def scrip_grid_file_path(): + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"scrip_{MOM6ForgeLauncher.nc_file_suffix()}" @staticmethod def esmf_mesh_file_path(): - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() - return custom_ocn_grid_path / f"ESMF_mesh_{MOM6BathyLauncher.nc_file_suffix()}" + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"ESMF_mesh_{MOM6ForgeLauncher.nc_file_suffix()}" @staticmethod def cice_grid_file_path(): - custom_ocn_grid_path = MOM6BathyLauncher.get_custom_ocn_grid_path() - return custom_ocn_grid_path / f"cice_grid_{MOM6BathyLauncher.nc_file_suffix()}" + custom_ocn_grid_path = MOM6ForgeLauncher.get_custom_ocn_grid_path() + return custom_ocn_grid_path / f"cice_grid_{MOM6ForgeLauncher.nc_file_suffix()}" diff --git a/visualCaseGen/custom_widget_types/runoff_mapping_generator.py b/visualCaseGen/custom_widget_types/runoff_mapping_generator.py new file mode 100644 index 0000000..c775715 --- /dev/null +++ b/visualCaseGen/custom_widget_types/runoff_mapping_generator.py @@ -0,0 +1,236 @@ +import os +from ipywidgets import HBox, VBox, Button, Output, Label +from pathlib import Path + +from ProConPy.out_handler import handler as owh +from ProConPy.config_var import cvars +from ProConPy.dialog import alert_warning +from mom6_forge import mapping +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher + +class RunoffMappingGenerator(VBox): + """Widget to generate runoff to ocean mapping for custom grids. + The widget first checks if there exists a standard mapping between the selected + runoff grid and the custom ocean grid. If not, it allows the user to generate + a new mapping using the mom6_forge mapping module. + """ + + def __init__(self, cime, **kwargs): + super().__init__(**kwargs) + self.cime = cime + + self._btn_use_standard = Button( + description="Use Standard Map", + disabled=True, + tooltip="Use the standard mapping files already available for the selected grids.", + ) + self._btn_use_standard.on_click(self.on_btn_use_standard_clicked) + + self._btn_generate_new = Button( + description="Generate New Map", + disabled=False, + tooltip="Generate a new mapping file using the mom6_forge mapping module.", + ) + self._btn_generate_new.on_click(self.on_btn_generate_new_clicked) + + self._out = Output() + + self._btn_run_generate = Button( + description="Run mapping generator", + disabled=False, + button_style="success", + tooltip="Run the mapping generator with the specified parameters.", + layout={"width": "260px", "align_self": "center"}, + ) + self._btn_run_generate.on_click(self.on_btn_run_generate_clicked) + + self._generate_new_dialog = VBox([ + cvars["ROF_OCN_MAPPING_RMAX"].widget, + cvars["ROF_OCN_MAPPING_FOLD"].widget, + self._btn_run_generate, + self._out + ], + layout={"display": "none"} + ) + + self.children = [ + HBox([ + Label("Select mapping option:"), + self._btn_use_standard, + self._btn_generate_new + ], + layout={"justify_content": "center", "margin": "10px"} + ), + self._generate_new_dialog + ] + + # Reset the widget when the runoff grid changes + cvars["CUSTOM_ROF_GRID"].observe(self.reset, names='value', type='change') + + @property + def disabled(self): + return super().disabled + + @disabled.setter + def disabled(self, value): + self._btn_use_standard.disabled = value or not self.standard_map_exists() + self._btn_generate_new.disabled = value + for child in self._generate_new_dialog.children: + child.disabled = value + + def reset(self, change): + """Reset all widget children and auxiliary config variables. To be called + when the runoff grid changes.""" + self._out.clear_output() + self._generate_new_dialog.layout.display = "none" + cvars["ROF_OCN_MAPPING_RMAX"].value = None + cvars["ROF_OCN_MAPPING_FOLD"].value = None + + def standard_map_exists(self): + """Check if there exists a standard mapping between the selected + runoff grid and the custom ocean grid. + """ + if cvars["OCN_GRID_MODE"].value != "Standard": + return False + + rof_grid = cvars["CUSTOM_ROF_GRID"].value + ocn_grid = cvars["CUSTOM_OCN_GRID"].value + + if ocn_grid in self.cime.maps[rof_grid]: + return True + + return False + + @owh.out.capture() + def on_btn_use_standard_clicked(self, b): + """Handler for the 'Use Standard Map' button click event. + Sets the ROF_OCN_MAPPING_STATUS variable to indicate that + the standard mapping will be used. + """ + + if not self.standard_map_exists(): + alert_warning( + "No standard mapping exists between the selected runoff grid " + "and the custom ocean grid. Please generate a new mapping." + ) + return + + self._out.clear_output() + self._generate_new_dialog.layout.display = "none" + cvars["ROF_OCN_MAPPING_STATUS"].value = "Standard" + + + def get_rof_grid_and_mesh(self): + """Return the runoff grid name and mesh path.""" + + rof_grid = cvars["CUSTOM_ROF_GRID"].value + rof_mesh_path = self.cime.get_mesh_path("rof", rof_grid) + return rof_grid, rof_mesh_path + + def get_ocn_grid_and_mesh(self): + """Return the ocean grid name and mesh path.""" + + ocn_grid_mode = cvars["OCN_GRID_MODE"].value + + match ocn_grid_mode: + case "Standard": + ocn_grid = cvars["CUSTOM_OCN_GRID"].value + ocn_mesh_path = self.cime.get_mesh_path("ocnice", ocn_grid) + case "Create New": + ocn_grid = cvars["CUSTOM_OCN_GRID_NAME"].value + ocn_mesh_path = MOM6ForgeLauncher.esmf_mesh_file_path() + case _: + assert False, f"Unsupported OCN_GRID_MODE: {ocn_grid_mode}" + + return ocn_grid, ocn_mesh_path + + def on_btn_generate_new_clicked(self, b): + """Handler for the 'Generate New Map' button click event. + Sets the ROF_OCN_MAPPING_STATUS variable to indicate that + a new mapping will be generated. + """ + + cvars["ROF_OCN_MAPPING_STATUS"].value = None + self._out.clear_output() + self._generate_new_dialog.layout.display = "" + + rmax = cvars["ROF_OCN_MAPPING_RMAX"].value + fold = cvars["ROF_OCN_MAPPING_FOLD"].value + + # Suggest default values for RMAX and FOLD if not set + if rmax is None and fold is None: + _, ocn_mesh_path = self.get_ocn_grid_and_mesh() + suggested_rmax, suggested_fold = mapping.get_suggested_smoothing_params(ocn_mesh_path) + + cvars["ROF_OCN_MAPPING_RMAX"].value = suggested_rmax + cvars["ROF_OCN_MAPPING_FOLD"].value = suggested_fold + + + @owh.out.capture() + def on_btn_run_generate_clicked(self, b): + """Handler for the 'Run mapping generator' button click event. + Runs the mapping generator with the specified parameters. + """ + + cvars["ROF_OCN_MAPPING_STATUS"].value = None + self._out.clear_output() + + rmax = cvars["ROF_OCN_MAPPING_RMAX"].value + if rmax is None: + alert_warning("Please specify a valid RMAX value.") + return + + fold = cvars["ROF_OCN_MAPPING_FOLD"].value + if fold is None: + alert_warning("Please specify a valid FOLD value.") + return + + rof_grid, rof_mesh_path = self.get_rof_grid_and_mesh() + ocn_grid, ocn_mesh_path = self.get_ocn_grid_and_mesh() + + try: + # disable the widget ahead of running the mapping generator + self.disabled = True + + mapping_file_prefix = f"{rof_grid}_to_{ocn_grid}_map" + output_dir = RunoffMappingGenerator.mapping_dir() + + # Run the mapping generator + with self._out: + mapping.gen_rof_maps( + rof_mesh_path=rof_mesh_path, + ocn_mesh_path=ocn_mesh_path, + output_dir=output_dir, + mapping_file_prefix=mapping_file_prefix, + rmax=rmax, + fold=fold + ) + + nn_map_filepath = mapping.get_nn_map_filepath( + mapping_file_prefix=mapping_file_prefix, + output_dir=output_dir, + ) + + nnsm_map_filepath = mapping.get_smoothed_map_filepath( + mapping_file_prefix=mapping_file_prefix, + output_dir=output_dir, + rmax=rmax, + fold=fold + ) + + # Set the mapping status to CUSTOM after successful generation + cvars["ROF_OCN_MAPPING_STATUS"].value = f"CUSTOM:{nn_map_filepath},{nnsm_map_filepath}" + + except Exception as e: + alert_warning( + f"An error occurred while generating the mapping: {e}" + ) + self.disabled = False + + + @staticmethod + def mapping_dir(): + custom_grid_path = cvars["CUSTOM_GRID_PATH"].value + mapping_dir = Path(custom_grid_path) / "mapping" + os.makedirs(mapping_dir, exist_ok=True) + return mapping_dir \ No newline at end of file diff --git a/visualCaseGen/initialize.py b/visualCaseGen/initialize.py new file mode 100644 index 0000000..e13433c --- /dev/null +++ b/visualCaseGen/initialize.py @@ -0,0 +1,41 @@ +import logging + +from ProConPy.config_var import ConfigVar, cvars +from ProConPy.stage import Stage +from ProConPy.csp_solver import csp +from visualCaseGen.cime_interface import CIME_interface +from visualCaseGen.initialize_configvars import initialize_configvars +from visualCaseGen.initialize_widgets import initialize_widgets +from visualCaseGen.initialize_stages import initialize_stages +from visualCaseGen.specs.options import set_options +from visualCaseGen.specs.relational_constraints import get_relational_constraints + +logger = logging.getLogger('\t'+__name__.split('.')[-1]) + + +def initialize(cesmroot=None): + """Initialize the visualCaseGen system by setting up configuration variables, stages, and widgets. + + Parameters: + ----------- + cesmroot : str, optional + The path to the CESM root directory. If not provided, it will be determined automatically. + + Returns: + -------- + cime : CIME_interface + An instance of the CIME_interface class, initialized with the provided CESM root directory. + """ + + logger.info("Initializing the visualCaseGen system...") + + ConfigVar.reboot() + Stage.reboot() + cime = CIME_interface(cesmroot=cesmroot) + initialize_configvars(cime) + initialize_widgets(cime) + initialize_stages(cime) + set_options(cime) + csp.initialize(cvars, get_relational_constraints(cvars), Stage.first()) + + return cime \ No newline at end of file diff --git a/visualCaseGen/specs/grid_options.py b/visualCaseGen/specs/grid_options.py index 8057dac..2f00983 100644 --- a/visualCaseGen/specs/grid_options.py +++ b/visualCaseGen/specs/grid_options.py @@ -17,6 +17,7 @@ def set_grid_options(cime): set_custom_atm_grid_options(cime) set_custom_ocn_grid_options(cime) set_custom_lnd_grid_options(cime) + set_custom_rof_grid_options(cime) def set_standard_grid_options(cime): @@ -95,7 +96,7 @@ def check_comp_grid(comp_class, proposed_grid, compset_lname): proposed_grid.compset_constr, compset_lname ): return False - if proposed_grid.not_compset_constr and re.search( + if (not proposed_grid.is_default) and proposed_grid.not_compset_constr and re.search( proposed_grid.not_compset_constr, compset_lname ): return False @@ -108,8 +109,12 @@ def set_custom_atm_grid_options(cime): This function is called at initialization.""" # CUSTOM_ATM_GRID options - def custom_atm_grid_options_func(comp_atm, grid_mode): + def custom_atm_grid_options_func(grid_mode): """Return the options and descriptions for the custom ATM grid variable.""" + + if grid_mode != "Custom": + return None, None + compset_lname = cvars["COMPSET_LNAME"].value compatible_atm_grids = [] descriptions = [] @@ -122,7 +127,7 @@ def custom_atm_grid_options_func(comp_atm, grid_mode): cv_custom_atm_grid = cvars["CUSTOM_ATM_GRID"] cv_custom_atm_grid.options_spec = OptionsSpec( - func=custom_atm_grid_options_func, args=(cvars["COMP_ATM"], cvars["GRID_MODE"]) + func=custom_atm_grid_options_func, args=(cvars["GRID_MODE"],) ) @@ -228,3 +233,34 @@ def custom_lnd_grid_options_func(comp_lnd, custom_atm_grid, lnd_grid_mode): cv_lnd_include_nonveg = cvars["LND_INCLUDE_NONVEG"] cv_lnd_include_nonveg.options = ["True", "False"] + + +def set_custom_rof_grid_options(cime): + """Set the options and options specs for the custom ROF grid variable. + This function is called at initialization.""" + + # CUSTOM_ROF_GRID options + def custom_rof_grid_options_func(grid_mode): + """Return the options and descriptions for the custom ROF grid variable.""" + + if grid_mode != "Custom": + return None, None + + if cvars["COMP_ROF"].value == "srof": + return ["null"], ["(When stub ROF is selected, custom ROF grid is set to null.)"] + + # Loop through all ROF grids and check if they are compatible with the compset constraints + compset_lname = cvars["COMPSET_LNAME"].value + compatible_rof_grids = [] + descriptions = [] + for rof_grid in cime.domains["rof"].values(): + if check_comp_grid("ROF", rof_grid, compset_lname) is False: + continue + compatible_rof_grids.append(rof_grid.name) + descriptions.append(rof_grid.desc) + return compatible_rof_grids, descriptions + + cv_custom_rof_grid = cvars["CUSTOM_ROF_GRID"] + cv_custom_rof_grid.options_spec = OptionsSpec( + func=custom_rof_grid_options_func, args=(cvars["GRID_MODE"],) + ) \ No newline at end of file diff --git a/visualCaseGen/specs/relational_constraints.py b/visualCaseGen/specs/relational_constraints.py index 6da1eb6..b3b1d78 100644 --- a/visualCaseGen/specs/relational_constraints.py +++ b/visualCaseGen/specs/relational_constraints.py @@ -24,6 +24,8 @@ def get_relational_constraints(cvars): OCN_NX = cvars['OCN_NX']; OCN_NY = cvars['OCN_NY']; OCN_LENX = cvars['OCN_LENX']; OCN_LENY = cvars['OCN_LENY'] LND_GRID_MODE = cvars['LND_GRID_MODE']; LND_SOIL_COLOR = cvars['LND_SOIL_COLOR']; LND_DOM_PFT = cvars['LND_DOM_PFT'] LND_MAX_SAT_AREA = cvars['LND_MAX_SAT_AREA']; LND_STD_ELEV = cvars['LND_STD_ELEV'] + CUSTOM_ROF_GRID = cvars['CUSTOM_ROF_GRID'] + ROF_OCN_MAPPING_RMAX = cvars['ROF_OCN_MAPPING_RMAX']; ROF_OCN_MAPPING_FOLD = cvars['ROF_OCN_MAPPING_FOLD'] # Return a dictionary of constraints where keys are the z3 boolean expressions corresponding to the constraints # and values are error messages to be displayed when the constraint is violated. @@ -111,7 +113,7 @@ def get_relational_constraints(cvars): Implies(In(COMP_ATM_OPTION, ["IAF", "NYF"]), ATM_GRID == "T62"): "Core2 forcing can only be used with T62 grid.", - # mom6_bathy-related constraints ------------------ + # mom6_forge-related constraints ------------------ Implies(And(COMP_OCN=="mom", COMP_LND=="slnd", COMP_ICE=="sice"), OCN_LENY<180.0): "If LND and ICE are stub, custom MOM6 grid must exclude poles (singularity).", @@ -165,7 +167,35 @@ def get_relational_constraints(cvars): "Max fraction of saturated area must be set to a value between 0 and 1.", LND_STD_ELEV >= 0.0: - "Standard deviation of elevation must be a nonnegative number." + "Standard deviation of elevation must be a nonnegative number.", + + # Custom rof grid constraints ------------------ + Implies(In(CUSTOM_ROF_GRID, ["JRA025", "rx1"]), COMP_ROF=="drof"): + "JRA025 and rx1 runoff grids can only be selected if DROF is the runoff component.", + + CUSTOM_ROF_GRID != "r05mz": # mizuroute is no longer available + "r05mz runoff grid can only be selected if MIZUROUTE is the runoff component.", + + In(COMP_ROF_OPTION, ["NYF", "IAF"]) == (CUSTOM_ROF_GRID=="rx1"): + "When Core2 forcing is selected for the ocean component, the runoff grid must be set to rx1.", + + (Contains(COMP_ROF_OPTION, "JRA")) == (CUSTOM_ROF_GRID == "JRA025"): + "When JRA forcing is selected for the ocean component, the runoff grid must be set to JRA025.", + + (COMP_ROF_OPTION == "GLOFAS") == (CUSTOM_ROF_GRID=="GLOFAS"): + "When GLOFAS forcing is selected for the ocean component, the runoff grid must be set to GLOFAS.", + + ROF_OCN_MAPPING_RMAX > 0: + "ROF_OCN_MAPPING_RMAX must be a positive number.", + + ROF_OCN_MAPPING_FOLD > 0: + "ROF_OCN_MAPPING_FOLD must be a positive number.", + + ROF_OCN_MAPPING_RMAX <= 4000: + "ROF_OCN_MAPPING_RMAX must be less than or equal to 4000 km.", + + ROF_OCN_MAPPING_FOLD <= 8000: + "ROF_OCN_MAPPING_FOLD must be less than or equal to 8000 km.", #### Assertions to stress-test the CSP solver diff --git a/visualCaseGen/stages/grid_stages.py b/visualCaseGen/stages/grid_stages.py index a1aad75..1ce32a7 100644 --- a/visualCaseGen/stages/grid_stages.py +++ b/visualCaseGen/stages/grid_stages.py @@ -3,13 +3,15 @@ from pathlib import Path import time import os +from z3 import And from ProConPy.config_var import cvars from ProConPy.stage import Stage, Guard from ProConPy.out_handler import handler as owh from visualCaseGen.custom_widget_types.stage_widget import StageWidget -from visualCaseGen.custom_widget_types.mom6_bathy_launcher import MOM6BathyLauncher +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher from visualCaseGen.custom_widget_types.clm_modifier_launcher import MeshMaskModifierLauncher, FsurdatModifierLauncher +from visualCaseGen.custom_widget_types.runoff_mapping_generator import RunoffMappingGenerator logger = logging.getLogger("\t" + __name__.split(".")[-1]) @@ -79,7 +81,7 @@ def initialize_grid_stages(cime): description="You have the option to either use a standard ocean grid or, if you picked " "MOM6 as the ocean model, create a new ocean grid. If you choose to create a new ocean grid, " "you will be prompted to specify the grid extent, resolution, and other parameters. You " - "will then be directed to a new notebook to create the new grid using the mom6_bathy tool.", + "will then be directed to a new notebook to create the new grid using the mom6_forge tool.", widget=StageWidget(VBox), parent=guard_custom_grid, varlist=[cvars["OCN_GRID_MODE"]], @@ -101,13 +103,13 @@ def initialize_grid_stages(cime): stg_new_ocn_grid = Stage( title="Custom Ocean Grid", description="Specify the grid extent, resolution, and other parameters for the new ocean grid. " - "Once all the parameters are specified, the Launch mom6_bathy button will be enabled. Clicking " + "Once all the parameters are specified, the Launch mom6_forge button will be enabled. Clicking " "the button will launch a new notebook. Execute all the cells in the notebook to create the new " "grid. Once all the cells are executed, return to this tab and click the Confirm Completion " "button to proceed to the next stage.", widget=StageWidget( VBox, - supplementary_widgets=[MOM6BathyLauncher()] + supplementary_widgets=[MOM6ForgeLauncher()] ), parent=Guard( title="Custom Ocn Grid", @@ -296,3 +298,31 @@ def initialize_grid_stages(cime): cvars["FSURDAT_MOD_STATUS"] ], ) + + + stg_custom_rof_grid = Stage( + title="Runoff Grid", + description="From the below list of standard runoff grids, select one to be used as the " + "runoff grid within the new, custom CESM grid.", + widget=StageWidget(VBox), + parent=guard_custom_grid, + varlist=[cvars["CUSTOM_ROF_GRID"]], + auto_set_default_value=False, + ) + + stg_custom_rof_ocn_mapping = Stage( + title="Runoff to Ocean Mapping", + description="If the ocean model is MOM6, and unless there exists a standard mapping between" + "the selected runoff grid and the custom ocean grid, a new mapping must be created using " + "the mom6_forge mapping module.", + widget=StageWidget( + VBox, + supplementary_widgets=[RunoffMappingGenerator(cime)] + ), + parent=Guard( + title="ROF to OCN Mapping", + parent=stg_custom_rof_grid, + condition=And(cvars["COMP_OCN"] == "mom", cvars["COMP_ROF"] != "srof") + ), + varlist=[cvars["ROF_OCN_MAPPING_STATUS"]], + ) diff --git a/visualCaseGen/widgets/grid_widgets.py b/visualCaseGen/widgets/grid_widgets.py index aeeeb05..f471206 100644 --- a/visualCaseGen/widgets/grid_widgets.py +++ b/visualCaseGen/widgets/grid_widgets.py @@ -25,13 +25,15 @@ def initialize_grid_widgets(cime): disabled=False, ) - initialize_standard_grid_widgets(cime) - initialize_custom_atm_grid_widgets(cime) - initialize_custom_ocn_grid_widgets(cime) - initialize_custom_lnd_grid_widgets(cime) - -def initialize_standard_grid_widgets(cime): - # Standard grid options + initialize_standard_grid_widgets() + initialize_custom_grid_path_widget(cime) + initialize_custom_atm_grid_widgets() + initialize_custom_ocn_grid_widgets() + initialize_custom_lnd_grid_widgets() + initialize_custom_rof_grid_widgets() + +def initialize_standard_grid_widgets(): + """Initialize the widgets for the standard grid options.""" cv_grid = cvars["GRID"] cv_grid.widget = MultiCheckbox( description="Grid:", @@ -39,8 +41,8 @@ def initialize_standard_grid_widgets(cime): ) cv_grid.valid_opt_char = chr(int("27A4", base=16)) -def initialize_custom_atm_grid_widgets(cime): - +def initialize_custom_grid_path_widget(cime): + """Initialize the widget for the custom grid path variable.""" default_path = Path.home() if cime.cime_output_root is not None: if (p := Path(cime.cime_output_root)).exists(): @@ -56,14 +58,16 @@ def initialize_custom_atm_grid_widgets(cime): layout={'width': '90%', 'margin': '10px'}, ) +def initialize_custom_atm_grid_widgets(): + """Initialize the widgets for the custom ATM grid options.""" cv_custom_atm_grid = cvars["CUSTOM_ATM_GRID"] cv_custom_atm_grid.widget = MultiCheckbox( description="Custom ATM Grid:", allow_multi_select=False, ) -def initialize_custom_ocn_grid_widgets(cime): - +def initialize_custom_ocn_grid_widgets(): + """Initialize the widgets for the custom OCN grid options.""" cv_custom_ocn_grid_mode = cvars["OCN_GRID_MODE"] cv_custom_ocn_grid_mode.widget = ToggleButtons( description="Ocean Grid Mode:", @@ -126,11 +130,11 @@ def initialize_custom_ocn_grid_widgets(cime): style={"description_width": "250px"}, ) - cv_mom6_bathy_stat = cvars["MOM6_BATHY_STATUS"] - cv_mom6_bathy_stat.widget = DisabledText( + cv_mom6_forge_stat = cvars["MOM6_BATHY_STATUS"] + cv_mom6_forge_stat.widget = DisabledText( value = '', disabled = True, - description="mom6_bathy status:", + description="mom6_forge status:", placeholder = "Incomplete", layout={"width": "300px", "padding": "5px", "align_self": "flex-end"}, style={"description_width": "150px", "background":"lightgray", "text_color":"white"}, @@ -177,11 +181,11 @@ def initialize_custom_ocn_grid_widgets(cime): continuous_update=False, ) -def initialize_custom_lnd_grid_widgets(cime): +def initialize_custom_lnd_grid_widgets(): + """Initialize the widgets for the custom LND grid options.""" description_width = "250px" - cv_lnd_grid_mode = cvars["LND_GRID_MODE"] cv_lnd_grid_mode.widget = ToggleButtons( description="LND grid mode:", @@ -321,3 +325,29 @@ def initialize_custom_lnd_grid_widgets(cime): cv_fsurdat_mod_status = cvars["FSURDAT_MOD_STATUS"] cv_fsurdat_mod_status.widget = DisabledText(value='') + + +def initialize_custom_rof_grid_widgets(): + """Initialize the widgets for the custom ROF grid options.""" + cv_custom_rof_grid = cvars["CUSTOM_ROF_GRID"] + cv_custom_rof_grid.widget = MultiCheckbox( + description="Custom ROF Grid:", + allow_multi_select=False, + ) + + cv_rof_ocn_mapping_status = cvars["ROF_OCN_MAPPING_STATUS"] + cv_rof_ocn_mapping_status.widget = DisabledText(value='') + + cv_rof_ocn_mapping_rmax = cvars["ROF_OCN_MAPPING_RMAX"] + cv_rof_ocn_mapping_rmax.widget = Text( + description="Smoothing Rmax (km):", + layout={"width": "370px", "padding": "5px"}, + style={"description_width": "250px"}, + ) + + cv_rof_ocn_mapping_fold = cvars["ROF_OCN_MAPPING_FOLD"] + cv_rof_ocn_mapping_fold.widget = Text( + description="Smoothing Fold (km):", + layout={"width": "370px", "padding": "5px"}, + style={"description_width": "250px"}, + ) \ No newline at end of file