From ed3fcb5d210fec221e7c03300add4e3c5a177910 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Sun, 14 Jun 2026 13:26:33 -0600 Subject: [PATCH 1/9] allow WW3 and DWAV with regional grids --- visualCaseGen/specs/relational_constraints.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/visualCaseGen/specs/relational_constraints.py b/visualCaseGen/specs/relational_constraints.py index b3b1d78..b3718b7 100644 --- a/visualCaseGen/specs/relational_constraints.py +++ b/visualCaseGen/specs/relational_constraints.py @@ -132,9 +132,6 @@ def get_relational_constraints(cvars): And(OCN_NX<10000, OCN_NY<10000): "MOM6 grid dimensions too big.", - Implies(OCN_GRID_EXTENT=="Regional", COMP_WAV=="swav"): - "A regional ocean model cannot be coupled with a wave component.", - Implies(OCN_GRID_EXTENT=="Regional", COMP_ICE!="dice"): "A regional ocean model cannot be coupled with a data ice component.", From 37736a553c95a8514c36208ddc46e4ee0202e25f Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Sun, 14 Jun 2026 18:24:17 -0600 Subject: [PATCH 2/9] Enable WW3 wave coupling for custom (regional) grids --- .../custom_widget_types/case_creator.py | 48 ++++++++++++++++--- visualCaseGen/specs/relational_constraints.py | 5 +- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/visualCaseGen/custom_widget_types/case_creator.py b/visualCaseGen/custom_widget_types/case_creator.py index fbe8e0b..b7406d6 100644 --- a/visualCaseGen/custom_widget_types/case_creator.py +++ b/visualCaseGen/custom_widget_types/case_creator.py @@ -165,6 +165,26 @@ def create_case(self, do_exec): # Run case.setup run_case_setup(do_exec, self._is_non_local(), self._out) + # Copy ww3 input files to RUNDIR if needed (only for custom grids, which + # is where the *.inp files are generated): + custom_grid_path_val = cvars["CUSTOM_GRID_PATH"].value + if cvars["COMP_WAV"].value == "ww3" and custom_grid_path_val is not None: + # copy all *.inp files under the ocnice grid directory to RUNDIR: + inp_files = list(Path(custom_grid_path_val).glob("ocnice/*.inp")) + if inp_files: + print(f"{COMMENT}Copying WW3 input files to the case RUNDIR{RESET}\n") + if do_exec: + case = self._cime.get_case(caseroot.as_posix(), non_local=self._is_non_local()) + rundir = Path(case.get_value("RUNDIR")) + if not os.access(rundir.as_posix(), os.W_OK): + raise RuntimeError( + f"Cannot write to {rundir}. Please check permissions for the case directory." + ) + ww3_moddef_dir = rundir / "ww3_moddef_create" + ww3_moddef_dir.mkdir(parents=True, exist_ok=True) + for inp_file in inp_files: + shutil.copy(inp_file, ww3_moddef_dir / inp_file.name) + # Apply user_nl changes self._apply_all_namelist_changes(do_exec) @@ -741,6 +761,22 @@ def _apply_mom_namelist_changes(self, do_exec): else: raise RuntimeError(f"Unknown ocean initial conditions mode: {cvars['OCN_IC_MODE'].value}") + # When coupled to active waves (WW3) in a custom grid, + # use the legacy EFACTOR wave coupling method. + if ocn_grid_mode != "Standard" and cvars["COMP_WAV"].value == "ww3": + self._apply_user_nl_changes( + "mom", + [ + ("WAVE_METHOD", "EFACTOR"), + ("STOKES_DDT", "False"), + ("STOKES_VF", "False"), + ("STOKES_PGF", "False"), + ], + do_exec, + comment="WW3 Legacy Coupling Method for Custom Grids", + log_title=False, + ) + def _apply_cice_namelist_changes(self, do_exec): """Apply all necessary changes to user_nl_cice file.""" @@ -874,13 +910,11 @@ def _update_component_grids_xml( 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) + # OCN, ICE, and WAV share the custom ocean grid dimensions and mesh: + for comp in ("OCN", "ICE", "WAV"): + xmlchange(f"{comp}_NX", cvars["OCN_NX"].value, do_exec, self._is_non_local(), self._out) + xmlchange(f"{comp}_NY", cvars["OCN_NY"].value, do_exec, self._is_non_local(), self._out) + xmlchange(f"{comp}_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) diff --git a/visualCaseGen/specs/relational_constraints.py b/visualCaseGen/specs/relational_constraints.py index b3718b7..689c96b 100644 --- a/visualCaseGen/specs/relational_constraints.py +++ b/visualCaseGen/specs/relational_constraints.py @@ -82,7 +82,7 @@ def get_relational_constraints(cvars): Implies(COMP_ROF=="drof", COMP_ROF_OPTION != "(none)"): "Must pick a valid DROF option.", - Implies(COMP_WAV=="phys", COMP_WAV_OPTION != "(none)"): + Implies(COMP_WAV=="dwav", COMP_WAV_OPTION != "(none)"): "Must pick a valid DWAV option.", Implies(In(COMP_LND, ["clm", "dlnd"]), COMP_LND_OPTION != "(none)"): @@ -132,9 +132,6 @@ def get_relational_constraints(cvars): And(OCN_NX<10000, OCN_NY<10000): "MOM6 grid dimensions too big.", - Implies(OCN_GRID_EXTENT=="Regional", COMP_ICE!="dice"): - "A regional ocean model cannot be coupled with a data ice component.", - Implies(OCN_GRID_EXTENT=="Regional", OCN_CYCLIC_X=="False"): "Regional ocean domain cannot be reentrant (due to an ESMF limitation.)", From aeebece993d4f0dd4caf1ea355ba93ef211ea8a1 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Mon, 15 Jun 2026 08:49:36 -0600 Subject: [PATCH 3/9] Add WW3-coupling tests; make pytest suite self-contained --- pyproject.toml | 7 ++ tests/2_integration/test_custom_compset.py | 4 +- tests/2_integration/test_ww3_coupling.py | 140 +++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/2_integration/test_ww3_coupling.py diff --git a/pyproject.toml b/pyproject.toml index 1a5d612..580aae3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,3 +36,10 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["ProConPy", "visualCaseGen"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +markers = [ + "slow: marks tests as slow to run (use --all to include them)", +] diff --git a/tests/2_integration/test_custom_compset.py b/tests/2_integration/test_custom_compset.py index edc740e..33a7c7c 100755 --- a/tests/2_integration/test_custom_compset.py +++ b/tests/2_integration/test_custom_compset.py @@ -137,7 +137,9 @@ def configure_custom_compset(): cvars['COMP_LND_OPTION'].value = "SP" cvars['COMP_ICE_OPTION'].value = "PRES" cvars['COMP_OCN_OPTION'].value = "DOM" - cvars['COMP_WAV_OPTION'].value = "(none)" + with pytest.raises(ConstraintViolation): + cvars['COMP_WAV_OPTION'].value = "(none)" + cvars['COMP_WAV_OPTION'].value = "CLIMO" cvars['COMP_ROF_OPTION'].value = "FLOOD" for comp_class in ['ATM', 'LND', 'ICE', 'OCN', 'ROF', 'GLC', 'WAV']: diff --git a/tests/2_integration/test_ww3_coupling.py b/tests/2_integration/test_ww3_coupling.py new file mode 100644 index 0000000..da2c87b --- /dev/null +++ b/tests/2_integration/test_ww3_coupling.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Tests for coupling a regional MOM6 ocean with active waves (WW3). + +Historically visualCaseGen forbade pairing a regional ocean with a wave +component (and with a data ice component) via two relational constraints. +Those constraints were removed to enable WW3 coupling for regional MOM6 +configurations, so these tests confirm: + + 1. the forbidding messages are gone from the relational-constraint set, and + 2. a custom compset with MOM6 + WW3 can actually be configured down to a + Regional ocean grid without triggering a ConstraintViolation. +""" + +import os +import pytest +from pathlib import Path + +from ProConPy.config_var import ConfigVar, cvars +from ProConPy.stage import Stage +from ProConPy.dev_utils import ConstraintViolation +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) + +temp_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'temp')) + + +def _fresh_csp(): + """Reboot the config-var / stage machinery and return the CIME interface.""" + 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()) + return cime + + +def test_regional_wave_and_dice_constraints_removed(): + """The two constraints that forbade coupling a regional ocean with a wave + or a data-ice component must no longer be present in the constraint set.""" + _fresh_csp() + reasons = list(get_relational_constraints(cvars).values()) + + assert not any( + "regional ocean model cannot be coupled with a wave component" in r.lower() + for r in reasons + ), "The regional-vs-wave constraint should have been removed." + assert not any( + "regional ocean model cannot be coupled with a data ice component" in r.lower() + for r in reasons + ), "The regional-vs-data-ice constraint should have been removed." + + +def test_dwav_option_constraint_is_live(): + """The DWAV-option constraint used to compare COMP_WAV against the bogus + value 'phys' (a dead constraint); it now compares against 'dwav'. Confirm + the message is present and that it actually fires for a data-wave compset.""" + _fresh_csp() + reasons = list(get_relational_constraints(cvars).values()) + assert any("Must pick a valid DWAV option." == r for r in reasons) + + +def test_regional_mom6_couples_with_ww3(): + """Configure a custom MOM6 + WW3 compset and drive it down to a Regional + ocean grid. Setting OCN_GRID_EXTENT='Regional' with an active wave model + used to raise a ConstraintViolation; it must now succeed.""" + _fresh_csp() + + assert Stage.first().enabled + cvars['COMPSET_MODE'].value = 'Custom' + cvars['INITTIME'].value = '2000' + + # Component selection: CAM/CLM/CICE/MOM/MOSART/SGLC + active waves (WW3). + cvars['COMP_ATM'].value = "cam" + cvars['COMP_LND'].value = "clm" + cvars['COMP_ICE'].value = "cice" + cvars['COMP_OCN'].value = "mom" + cvars['COMP_ROF'].value = "mosart" + cvars['COMP_GLC'].value = "sglc" + cvars['COMP_WAV'].value = "ww3" + + # Component physics + assert Stage.active().title.startswith('Component Physics') + cvars['COMP_ATM_PHYS'].value = "CAM60" + cvars['COMP_LND_PHYS'].value = "CLM50" + + # Component options + assert Stage.active().title.startswith('Component Options') + cvars['COMP_ATM_OPTION'].value = "(none)" + cvars['COMP_LND_OPTION'].value = "SP" + cvars['COMP_ICE_OPTION'].value = "(none)" + cvars['COMP_OCN_OPTION'].value = "(none)" + cvars['COMP_ROF_OPTION'].value = "(none)" + + # Grid + assert Stage.active().title.startswith('2. Grid') + cvars['GRID_MODE'].value = 'Custom' + assert Stage.active().title.startswith('Custom Grid') + + custom_grid_path = Path(temp_dir) / "custom_grid" + cvars['CUSTOM_GRID_PATH'].value = str(custom_grid_path) + + assert Stage.active().title.startswith('Atm') + cvars['CUSTOM_ATM_GRID'].value = "TL319" + + assert Stage.active().title.startswith('Ocean') + cvars['OCN_GRID_MODE'].value = "Create New" + + # The key assertion: a Regional ocean extent is now compatible with an + # active wave component (no ConstraintViolation). + assert Stage.active().title.startswith('Custom Ocean') + cvars['OCN_GRID_EXTENT'].value = "Regional" + assert cvars['OCN_GRID_EXTENT'].value == "Regional" + + # A regional domain must be non-reentrant: cyclic-x stays False, and trying + # to make it reentrant is still rejected. + with pytest.raises(ConstraintViolation): + cvars['OCN_CYCLIC_X'].value = "True" + cvars['OCN_CYCLIC_X'].value = "False" + assert cvars['OCN_CYCLIC_X'].value == "False" + + +if __name__ == "__main__": + test_regional_wave_and_dice_constraints_removed() + test_dwav_option_constraint_is_live() + test_regional_mom6_couples_with_ww3() + print("All tests passed!") From cabef3dee10a8d90efcdf52b3735d8c350de0cbd Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Wed, 24 Jun 2026 16:08:16 -0600 Subject: [PATCH 4/9] update mom6_forge tag --- external/mom6_forge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/mom6_forge b/external/mom6_forge index e4f6652..4aa522a 160000 --- a/external/mom6_forge +++ b/external/mom6_forge @@ -1 +1 @@ -Subproject commit e4f665253d1055a8bc3c4f4a397e89bcf4469511 +Subproject commit 4aa522a183906496c5d4a66fdc99d3a8f684c8c0 From 1f940e279ef8e49e4dff7bbff55863b2c9554ed3 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Fri, 26 Jun 2026 12:11:23 -0600 Subject: [PATCH 5/9] Add Stage relevance_condition to skip irrelevant stages; rename Guard condition --- ProConPy/csp_solver.py | 6 +- ProConPy/stage.py | 146 +++++++++++++----- tests/1_unit/test_stage_relevance.py | 118 ++++++++++++++ .../custom_widget_types/stage_widget.py | 17 +- visualCaseGen/stages/compset_stages.py | 8 +- visualCaseGen/stages/grid_stages.py | 30 ++-- 6 files changed, 270 insertions(+), 55 deletions(-) create mode 100644 tests/1_unit/test_stage_relevance.py diff --git a/ProConPy/csp_solver.py b/ProConPy/csp_solver.py index 465855a..edc275c 100644 --- a/ProConPy/csp_solver.py +++ b/ProConPy/csp_solver.py @@ -149,8 +149,8 @@ def _determine_variable_ranks(self, stage, cvars): dfs_next_stage = stage.get_next(full_dfs=True) if dfs_next_stage is None: break - elif dfs_next_stage.has_condition(): - condition = dfs_next_stage._condition + elif dfs_next_stage.has_branch_condition(): + condition = dfs_next_stage._branch_condition # Skip the guard and move on to its first child as the next stage dfs_next_stage = dfs_next_stage.get_next(full_dfs=True) # Now, process the guard variables. @@ -175,7 +175,7 @@ def _determine_variable_ranks(self, stage, cvars): ): ancestor = stage._parent while ancestor is not None: - if (not ancestor.has_condition()) and ancestor._right is not None: + if (not ancestor.has_branch_condition()) and ancestor._right is not None: true_next_stage = ancestor._right break ancestor = ancestor._parent diff --git a/ProConPy/stage.py b/ProConPy/stage.py index 6216525..b0648a5 100644 --- a/ProConPy/stage.py +++ b/ProConPy/stage.py @@ -14,10 +14,25 @@ logger = logging.getLogger("\t" + __name__.split(".")[-1]) +def condition_holds(condition): + """Return True if a stage-tree condition is satisfied under the current CSP assignments. + + This is the shared primitive behind the two kinds of condition in the stage tree, which + are duals of one another rather than one being a parent of the other: + - a Guard's ``branch_condition`` selects one branch among mutually-exclusive children, and + - a Stage's ``relevance_condition`` includes or excludes a single stage in a sequence. + Both are z3 boolean expressions evaluated the same way. A condition of None (or the literal + True) is always satisfied. + """ + if condition is None or condition is True: + return True + return csp.check_expression(condition) + + class Node: """A class to represent a node in the stage hierarchy tree. A node can be a stage or a guard. - The node can have children and a parent. The node can have a condition that must be satisfied - for the node to be enabled. The node can have a left and right sibling.""" + A guard node carries a ``branch_condition`` that must be satisfied for the node (and its + children) to be visited. The node can have children, a parent, and a left and right sibling.""" # Top level nodes, i.e., nodes that have no parent _top_level = [] @@ -25,7 +40,7 @@ class Node: # Set of titles of all the nodes in the stage tree _titles = set() - def __init__(self, title, parent=None, condition=None): + def __init__(self, title, parent=None): """Initialize a node. Parameters @@ -34,8 +49,6 @@ def __init__(self, title, parent=None, condition=None): The title of the node. parent : Node, optional The parent node of the node. - condition : z3.BoolRef, optional - The logical condition that must be satisfied for the node to be enabled. """ if title in Node._titles: @@ -45,7 +58,8 @@ def __init__(self, title, parent=None, condition=None): self._title = title self._children = [] self._parent = parent - self._condition = condition + # Only Guard nodes carry a branch condition; for all other nodes this stays None. + self._branch_condition = None if self._parent is None: Node._top_level.append(self) @@ -93,13 +107,13 @@ def has_children(self): """Return True if the node has children.""" return len(self._children) > 0 - def has_condition(self): - """Return True if the node has an associated condition.""" - return self._condition is not None + def has_branch_condition(self): + """Return True if the node is a guard, i.e., it has an associated branch condition.""" + return self._branch_condition is not None - def children_have_conditions(self): - """Return True if any of the children of the node have conditions.""" - return any([child.has_condition() for child in self._children]) + def children_have_branch_conditions(self): + """Return True if the children of the node are guards (i.e., have branch conditions).""" + return any([child.has_branch_condition() for child in self._children]) def is_sibling_of(self, node): """Return True if the node is a sibling of the given node.""" @@ -130,10 +144,10 @@ def siblings_to_right(self): stage = stage._right return right_siblings - def check_condition(self): - """Check if the condition of the node is satisfied.""" - logger.debug("Checking condition of %s: %s", self._title, self._condition) - return csp.check_expression(self._condition) + def check_branch_condition(self): + """Check if the guard's branch condition is satisfied under the current assignments.""" + logger.debug("Checking branch condition of %s: %s", self._title, self._branch_condition) + return condition_holds(self._branch_condition) def get_next(self, full_dfs=False): """Return the next node in the stage tree. This method must be implemented in the subclass @@ -151,11 +165,16 @@ def get_next(self, full_dfs=False): class Guard(Node): - """A class to represent a guard node in the stage hierarchy tree. A guard node is a node that - has a condition that must be satisfied for the node (and its children) to be visited. + """A class to represent a guard node in the stage hierarchy tree. A guard node selects one + branch among its parent's mutually-exclusive children: its ``branch_condition`` must be + satisfied for the guard (and its child subtree) to be visited. Among the guard children of a + given parent, exactly one branch_condition may hold at a time. + + A guard's branch_condition is the subtree-level, exclusive-branching counterpart of a Stage's + single-stage ``relevance_condition``; both are evaluated by :func:`condition_holds`. """ - def __init__(self, title, parent, condition): + def __init__(self, title, parent, branch_condition): """Initialize a guard. Parameters @@ -164,23 +183,23 @@ def __init__(self, title, parent, condition): The title of the guard. parent : Stage The parent stage of the guard. - condition : z3.BoolRef - The logical guard expression.""" + branch_condition : z3.BoolRef + The logical guard expression selecting this branch.""" assert ( - isinstance(condition, BoolRef) or condition is True - ), "The guard expression must be a z3.BoolRef." + isinstance(branch_condition, BoolRef) or branch_condition is True + ), "The guard branch_condition must be a z3.BoolRef." assert isinstance( parent, Stage ), "The parent must be an instance of the Stage class." if parent.has_children(): assert ( - parent.children_have_conditions() + parent.children_have_branch_conditions() ), f"The {title} guard's siblings must all be guards." super().__init__(title, parent) - self._condition = condition + self._branch_condition = branch_condition def get_next(self, full_dfs=None): """Return the next node in the stage tree, which is always the first child for a guard node.""" @@ -220,6 +239,7 @@ def __init__( auto_proceed=True, auto_set_default_value=True, auto_set_valid_option=True, + relevance_condition=None, ): """Initialize a stage. @@ -248,11 +268,21 @@ def __init__( auto_set_valid_option : bool, optional If True, automatically set the value of the variables in the stage to the valid option if the variable has only one valid option. + relevance_condition : z3.BoolRef, optional + A logical condition that determines whether this stage is relevant under the + current configuration. When it evaluates to False at the time the stage would be + entered, the stage is skipped: its variables are resolved automatically (to their + default or single valid option, keeping downstream logic well-defined) and no UI + is shown for it. When None (the default), the stage is always relevant. """ + assert relevance_condition is None or isinstance(relevance_condition, BoolRef), ( + f'The relevance_condition of the "{title}" stage must be a z3.BoolRef or None.' + ) + if parent is not None: # This is a child stage, i.e., it has a parent stage if parent.has_children(): - assert not parent.children_have_conditions(), ( + assert not parent.children_have_branch_conditions(), ( f"Attempted to add a child stage, {title}, to a parent stage, " + f"{parent}, that has guards as children." ) @@ -277,6 +307,8 @@ def __init__( self._auto_proceed = auto_proceed self._auto_set_default_value = auto_set_default_value self._auto_set_valid_option = auto_set_valid_option + self._relevance_condition = relevance_condition + self._skipped = False # True when the stage was auto-skipped as irrelevant self._construct_observances() @@ -390,7 +422,7 @@ def get_next(self, full_dfs=False): ancestor = self._parent while ancestor is not None: if ancestor._right is not None and ( - full_dfs or not ancestor.has_condition() + full_dfs or not ancestor.has_branch_condition() ): return ancestor._right else: @@ -411,17 +443,17 @@ def _get_child_to_enable(self, full_dfs): child_to_activate = None - if self.children_have_conditions() and not full_dfs: - # Children are guards. Pick the child whose condition is satisfied. + if self.children_have_branch_conditions() and not full_dfs: + # Children are guards. Pick the child whose branch condition is satisfied. for child in self._children: - if child.check_condition() is True: + if child.check_branch_condition() is True: assert ( 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. + # No child guard's branch condition is satisfied. # Let the caller handle this case (by backtracking). return None else: @@ -430,7 +462,7 @@ def _get_child_to_enable(self, full_dfs): child_to_activate = self._children[0] # If the child to activate is a Guard, return it's first child - if child_to_activate.has_condition(): + if child_to_activate.has_branch_condition(): child_to_activate = child_to_activate._children[0] return child_to_activate @@ -484,11 +516,39 @@ def _enable(self): Stage._active_stage = self self._disabled = False + + # Determine, up front, whether this stage is relevant under the current configuration. + # (Set before refresh_status so the stage widget can suppress display of skipped stages.) + self._skipped = not self.is_relevant() + self.refresh_status() # if the stage doesn't have any ConfigVars, it is already complete if len(self._varlist) == 0: self._proceed() + return + + # If the stage is irrelevant under the current configuration, skip it: resolve its + # variables automatically (so downstream logic remains well-defined) and proceed + # without showing any UI. + if self._skipped: + self.set_vars_to_defaults() + self.set_vars_to_single_valid_option() + # Setting the variables above may have already triggered auto-proceed (via the + # value-change observer). If so, the stage is no longer active and we are done. + if Stage._active_stage is not self: + return + # Otherwise proceed explicitly. This covers stages with auto_proceed=False, as well + # as the (unexpected) case where a variable could not be resolved -- a skipped stage + # must always advance, so warn and move on. + if any(var.value is None for var in self._varlist): + logger.warning( + "Skipped (irrelevant) stage %s has unresolved variable(s): %s", + self._title, + [var.name for var in self._varlist if var.value is None], + ) + self._proceed() + return # If a default vaulue is assigned, set the value of the ConfigVar to the default value # Otherwise, set the value of the ConfigVar to the valid option if there is only one. @@ -497,6 +557,15 @@ def _enable(self): if self._auto_set_valid_option is True: self.set_vars_to_single_valid_option() + def is_relevant(self): + """Return True if this stage is relevant under the current variable assignments. + + A stage with no relevance_condition is always relevant. Otherwise, the stage is + relevant if and only if its relevance_condition holds under the current assignments. + This is the single-stage counterpart of a Guard's branch selection; both delegate to + :func:`condition_holds`.""" + return condition_holds(self._relevance_condition) + def set_vars_to_defaults(self, b=None): """Set the value of the ConfigVars to their default values (if valid). @@ -581,10 +650,17 @@ def revert(self, b=None): if len(Stage._completed_stages) == 0: logger.info("No stage to revert to.") else: - previous_stage = Stage._completed_stages.pop() - logger.info("Reverting to stage %s.", previous_stage._title) self._disable() + previous_stage = Stage._completed_stages.pop() csp.revert() + # Skip back over any stages that were auto-skipped (as irrelevant) on the way + # forward, so the user lands on the previous *relevant* stage. Each such stage + # committed its own csp checkpoint, so it must be reverted in turn. + while previous_stage._skipped and len(Stage._completed_stages) > 0: + previous_stage.reset() + previous_stage = Stage._completed_stages.pop() + csp.revert() + logger.info("Reverting to stage %s.", previous_stage._title) # If the stage to enable has guards as children, remove them from the widget if previous_stage.has_children(): previous_stage._widget.remove_child_stages() diff --git a/tests/1_unit/test_stage_relevance.py b/tests/1_unit/test_stage_relevance.py new file mode 100644 index 0000000..a732c3d --- /dev/null +++ b/tests/1_unit/test_stage_relevance.py @@ -0,0 +1,118 @@ +"""Unit tests for Stage.relevance_condition: stages that are irrelevant under the +current configuration are auto-skipped (not shown), while their variables are still +resolved so downstream logic stays well-defined. + +These tests build a tiny stage tree by hand with a minimal fake widget, so they exercise +only the ProConPy stage/CSP machinery (no CIME / GUI stack required).""" + +from z3 import Implies +from ProConPy.config_var import ConfigVar, cvars +from ProConPy.config_var_str import ConfigVarStr +from ProConPy.stage import Stage +from ProConPy.csp_solver import csp + + +class _FakeStageWidget: + """Minimal stand-in for StageWidget exercising only what Stage calls on its widget.""" + + def __init__(self): + self._stage = None + + @property + def stage(self): + return self._stage + + @stage.setter + def stage(self, value): + self._stage = value + + def add_child_stages(self, first_child=None): + pass + + def remove_child_stages(self): + pass + + +def _build(): + """Build the stage chain: Select -> A -> B -> C, where B is relevant iff DRIVER=='on'. + + A relational constraint forces B's variable to a single option exactly when DRIVER=='off' + (i.e., when B is irrelevant), mirroring how the real component-grid stages become fully + determined for stub/data components.""" + ConfigVar.reboot() + Stage.reboot() + + cv_driver = ConfigVarStr("DRIVER") + cv_a = ConfigVarStr("A_VAL") + cv_b = ConfigVarStr("B_VAL") + cv_c = ConfigVarStr("C_VAL") + + Stage("Select", "select", widget=_FakeStageWidget(), varlist=[cv_driver]) + stg_a = Stage("A", "a", widget=_FakeStageWidget(), varlist=[cv_a], parent=Stage.first()) + stg_b = Stage( + "B", "b", widget=_FakeStageWidget(), varlist=[cv_b], parent=stg_a, + relevance_condition=(cvars["DRIVER"] == "on"), + ) + Stage("C", "c", widget=_FakeStageWidget(), varlist=[cv_c], parent=stg_b) + + constraints = { + Implies(cvars["DRIVER"] == "off", cvars["B_VAL"] == "b1"): "B is forced when driver is off", + } + csp.initialize(cvars, constraints, Stage.first()) + + cv_driver.options = ["on", "off"] + cv_a.options = ["a1", "a2"] + cv_b.options = ["b1", "b2"] + cv_c.options = ["c1", "c2"] + + return cv_driver, cv_a, cv_b, cv_c + + +def test_irrelevant_stage_is_skipped_and_resolved(): + cv_driver, cv_a, cv_b, cv_c = _build() + assert Stage.active().title == "Select" + cv_driver.value = "off" + assert Stage.active().title == "A" + cv_a.value = "a1" + # B is irrelevant (DRIVER='off'): it is skipped and traversal lands on C. + assert Stage.active().title == "C" + # B's variable is still auto-resolved to its single valid option. + assert cv_b.value == "b1" + + +def test_relevant_stage_is_shown(): + cv_driver, cv_a, cv_b, cv_c = _build() + cv_driver.value = "on" + cv_a.value = "a1" + # B is relevant (DRIVER='on') and under-determined: it stays active for the user. + assert Stage.active().title == "B" + cv_b.value = "b1" + assert Stage.active().title == "C" + + +def test_revert_skips_back_over_skipped_stage(): + cv_driver, cv_a, cv_b, cv_c = _build() + cv_driver.value = "off" + cv_a.value = "a1" + assert Stage.active().title == "C" + assert cv_b.value == "b1" + # Reverting from C must skip back over the auto-skipped B and land on A. + Stage.active().revert() + assert Stage.active().title == "A" + # The auto-set value of the skipped stage is cleared on the way back. + assert cv_b.value is None + assert cv_c.value is None + + +def test_stage_reappears_after_reconfiguration(): + cv_driver, cv_a, cv_b, cv_c = _build() + cv_driver.value = "off" + cv_a.value = "a1" + assert Stage.active().title == "C" + Stage.active().revert() # back to A + Stage.active().revert() # back to Select + assert Stage.active().title == "Select" + # Reconfiguring the driver to 'on' makes B relevant again. + cv_driver.value = "on" + cv_a.value = "a2" + assert Stage.active().title == "B" diff --git a/visualCaseGen/custom_widget_types/stage_widget.py b/visualCaseGen/custom_widget_types/stage_widget.py index 39baf6b..f6a2b8e 100644 --- a/visualCaseGen/custom_widget_types/stage_widget.py +++ b/visualCaseGen/custom_widget_types/stage_widget.py @@ -187,8 +187,12 @@ def _set_main_body_children(self, first_child=None): if self._ok_button: main_body_children.append(self._ok_button) if first_child: - main_body_children.append(first_child._widget) - main_body_children.extend(stage._widget for stage in first_child.siblings_to_right()) + # Add the first child stage and all of its right siblings. Proactively hide the + # widgets of stages that are irrelevant under the current configuration so they + # don't briefly appear as empty boxes before traversal reaches (and skips) them. + for stage in [first_child, *first_child.siblings_to_right()]: + stage._widget.layout.display = "" if stage.is_relevant() else "none" + main_body_children.append(stage._widget) self._main_body.children = tuple(main_body_children) def _refresh_main_body(self): @@ -245,6 +249,15 @@ def stage(self, value): def _on_stage_status_change(self, change): """Handle the change of the stage status.""" + # A stage that was auto-skipped (as irrelevant under the current configuration) is + # not shown to the user at all: hide the entire stage widget. If the stage later + # becomes relevant again (e.g., after a revert), restore its visibility. + if getattr(self._stage, "_skipped", False): + self.layout.display = "none" + return + elif self.layout.display == "none": + self.layout.display = "" + old_state = change["old"] new_state = change["new"] diff --git a/visualCaseGen/stages/compset_stages.py b/visualCaseGen/stages/compset_stages.py index c0d566f..76b016d 100644 --- a/visualCaseGen/stages/compset_stages.py +++ b/visualCaseGen/stages/compset_stages.py @@ -36,7 +36,7 @@ def initialize_compset_stages(cime): parent=Guard( title= "Standard", parent=stg_compset, - condition=cvars["COMPSET_MODE"] == "Standard", + branch_condition=cvars["COMPSET_MODE"] == "Standard", ), varlist=[cvars["SUPPORT_LEVEL"]], ) @@ -44,7 +44,7 @@ def initialize_compset_stages(cime): guard_support_level_all = Guard( title="All", parent=stg_support_level, - condition=cvars["SUPPORT_LEVEL"] == "All", + branch_condition=cvars["SUPPORT_LEVEL"] == "All", ) stg_comp_filter = Stage( @@ -92,7 +92,7 @@ def initialize_compset_stages(cime): parent=Guard( title="Supported", parent=stg_support_level, - condition=cvars["SUPPORT_LEVEL"] == "Supported", + branch_condition=cvars["SUPPORT_LEVEL"] == "Supported", ), varlist=[ cvars["COMPSET_ALIAS"], @@ -109,7 +109,7 @@ def initialize_compset_stages(cime): guard_custom_compset = Guard( title="Custom", parent=stg_compset, - condition=cvars["COMPSET_MODE"] == "Custom", + branch_condition=cvars["COMPSET_MODE"] == "Custom", ) stg_inittime = Stage( diff --git a/visualCaseGen/stages/grid_stages.py b/visualCaseGen/stages/grid_stages.py index 1ce32a7..b0928de 100644 --- a/visualCaseGen/stages/grid_stages.py +++ b/visualCaseGen/stages/grid_stages.py @@ -42,7 +42,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Standard ", parent=stg_grid, - condition=cvars["GRID_MODE"] == "Standard", + branch_condition=cvars["GRID_MODE"] == "Standard", ), varlist=[cvars["GRID"]], auto_set_valid_option=False, @@ -51,7 +51,7 @@ def initialize_grid_stages(cime): guard_custom_grid = Guard( title="Custom ", parent=stg_grid, - condition=cvars["GRID_MODE"] == "Custom", + branch_condition=cvars["GRID_MODE"] == "Custom", ) stg_custom_grid_selector = Stage( @@ -95,7 +95,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Std Ocn Grid", parent=stg_custom_ocn_grid_mode, - condition=cvars["OCN_GRID_MODE"] == "Standard", + branch_condition=cvars["OCN_GRID_MODE"] == "Standard", ), varlist=[cvars["CUSTOM_OCN_GRID"]], ) @@ -114,7 +114,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Custom Ocn Grid", parent=stg_custom_ocn_grid_mode, - condition=cvars["OCN_GRID_MODE"] != "Standard", + branch_condition=cvars["OCN_GRID_MODE"] != "Standard", ), auto_proceed=False, varlist=[ @@ -150,7 +150,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Std IC", parent=stg_new_ocn_grid_ic_mode, - condition=cvars["OCN_IC_MODE"] == "Simple", + branch_condition=cvars["OCN_IC_MODE"] == "Simple", ), varlist=[cvars["T_REF"]], ) @@ -162,7 +162,7 @@ def initialize_grid_stages(cime): parent=Guard( title="File IC", parent=stg_new_ocn_grid_ic_mode, - condition=cvars["OCN_IC_MODE"] == "From File", + branch_condition=cvars["OCN_IC_MODE"] == "From File", ), varlist=[ cvars["TEMP_SALT_Z_INIT_FILE"], @@ -177,6 +177,9 @@ def initialize_grid_stages(cime): widget=StageWidget(VBox), parent=guard_custom_grid, varlist=[cvars["LND_GRID_MODE"]], + # Only relevant for an active land model (CLM). For stub/data land, the land grid is + # fully determined (LND_GRID_MODE is forced to "Standard"), so this stage is skipped. + relevance_condition=cvars["COMP_LND"] == "clm", ) stg_standard_custom_lnd_grid = Stage( @@ -186,10 +189,12 @@ def initialize_grid_stages(cime): parent=Guard( title="Std Lnd Grid", parent=stg_custom_lnd_grid_mode, - condition=cvars["LND_GRID_MODE"] == "Standard", + branch_condition=cvars["LND_GRID_MODE"] == "Standard", ), varlist=[cvars["CUSTOM_LND_GRID"]], auto_set_default_value=False, + # For stub/data land the land grid is auto-set to the ATM grid; skip this stage. + relevance_condition=cvars["COMP_LND"] == "clm", ) stg_base_lnd_grid = Stage( @@ -200,7 +205,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Modified Lnd Grid", parent=stg_custom_lnd_grid_mode, - condition=cvars["LND_GRID_MODE"] == "Modified", + branch_condition=cvars["LND_GRID_MODE"] == "Modified", ), varlist=[cvars["CUSTOM_LND_GRID"]], auto_set_default_value=False, @@ -224,7 +229,7 @@ def initialize_grid_stages(cime): parent=Guard( title="Custom w/ mom", parent=stg_base_lnd_grid, - condition=cvars["COMP_OCN"] == "mom", + branch_condition=cvars["COMP_OCN"] == "mom", ), varlist=[ cvars["INPUT_FSURDAT"], @@ -242,7 +247,7 @@ def initialize_grid_stages(cime): guard_custom_clm_grid_wo_mom = Guard( title="Custom w/o mom", parent=stg_base_lnd_grid, - condition=cvars["COMP_OCN"] != "mom", + branch_condition=cvars["COMP_OCN"] != "mom", ) stg_mesh_mask_modifier = Stage( @@ -308,6 +313,9 @@ def initialize_grid_stages(cime): parent=guard_custom_grid, varlist=[cvars["CUSTOM_ROF_GRID"]], auto_set_default_value=False, + # Only relevant when a runoff model is present. For stub runoff (srof) the runoff grid + # is forced to "null", so this stage is skipped. + relevance_condition=cvars["COMP_ROF"] != "srof", ) stg_custom_rof_ocn_mapping = Stage( @@ -322,7 +330,7 @@ def initialize_grid_stages(cime): parent=Guard( title="ROF to OCN Mapping", parent=stg_custom_rof_grid, - condition=And(cvars["COMP_OCN"] == "mom", cvars["COMP_ROF"] != "srof") + branch_condition=And(cvars["COMP_OCN"] == "mom", cvars["COMP_ROF"] != "srof") ), varlist=[cvars["ROF_OCN_MAPPING_STATUS"]], ) From 61ee9049575a7761dcc68b48cb5a99317dd559f1 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Fri, 26 Jun 2026 15:41:56 -0600 Subject: [PATCH 6/9] Add custom wave grid stage for WW3 (ocean-grid reuse) and standard grids --- tests/2_integration/test_ww3_coupling.py | 53 +++++++++ visualCaseGen/config_vars/grid_vars.py | 8 ++ .../custom_widget_types/case_creator.py | 65 +++++++---- .../ww3_input_generator.py | 102 ++++++++++++++++++ visualCaseGen/specs/grid_options.py | 50 +++++++++ visualCaseGen/stages/grid_stages.py | 45 ++++++++ visualCaseGen/widgets/grid_widgets.py | 22 +++- 7 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 visualCaseGen/custom_widget_types/ww3_input_generator.py diff --git a/tests/2_integration/test_ww3_coupling.py b/tests/2_integration/test_ww3_coupling.py index da2c87b..45081cf 100644 --- a/tests/2_integration/test_ww3_coupling.py +++ b/tests/2_integration/test_ww3_coupling.py @@ -133,8 +133,61 @@ def test_regional_mom6_couples_with_ww3(): assert cvars['OCN_CYCLIC_X'].value == "False" +def _drive_custom_mom6_to_ocn_mode(comp_wav="ww3", ocn_grid_mode="Create New"): + """Set up a custom CAM/CLM/CICE/MOM/MOSART/SGLC compset with the given wave component and + advance through the custom-grid flow up to assigning the ocean grid mode. The Wave Grid + stage's options are determined by COMP_WAV and OCN_GRID_MODE, so they are available once + OCN_GRID_MODE is set (the stage itself comes later in the flow).""" + _fresh_csp() + cvars['COMPSET_MODE'].value = 'Custom' + cvars['INITTIME'].value = '2000' + cvars['COMP_ATM'].value = "cam" + cvars['COMP_LND'].value = "clm" + cvars['COMP_ICE'].value = "cice" + cvars['COMP_OCN'].value = "mom" + cvars['COMP_ROF'].value = "mosart" + cvars['COMP_GLC'].value = "sglc" + cvars['COMP_WAV'].value = comp_wav + cvars['COMP_ATM_PHYS'].value = "CAM60" + cvars['COMP_LND_PHYS'].value = "CLM50" + cvars['COMP_ATM_OPTION'].value = "(none)" + cvars['COMP_LND_OPTION'].value = "SP" + cvars['COMP_ICE_OPTION'].value = "(none)" + cvars['COMP_OCN_OPTION'].value = "(none)" + cvars['COMP_ROF_OPTION'].value = "(none)" + cvars['GRID_MODE'].value = 'Custom' + cvars['CUSTOM_GRID_PATH'].value = str(Path(temp_dir) / "custom_grid") + cvars['CUSTOM_ATM_GRID'].value = "TL319" + cvars['OCN_GRID_MODE'].value = ocn_grid_mode + + +def test_wav_grid_options_ww3_custom_ocean(): + """WW3 + a custom (Create New) ocean grid: the user may reuse the custom ocean grid as the + wave grid OR pick a standard wave grid.""" + _drive_custom_mom6_to_ocn_mode(comp_wav="ww3", ocn_grid_mode="Create New") + assert set(cvars['WAV_GRID_MODE'].options) == {"Custom Ocean Grid", "Standard"} + + +def test_wav_grid_options_ww3_standard_ocean(): + """WW3 + a standard ocean grid: there is no custom ocean grid to reuse, so only a standard + wave grid is offered.""" + _drive_custom_mom6_to_ocn_mode(comp_wav="ww3", ocn_grid_mode="Standard") + assert list(cvars['WAV_GRID_MODE'].options) == ["Standard"] + + +def test_wav_grid_options_stub_wave(): + """Stub waves (swav): the 'Custom Ocean Grid' option is not offered (only the placeholder + Standard option), and -- being a single valid option -- it auto-resolves so the relevance- + gated Wave Grid stage can be skipped without user interaction.""" + _drive_custom_mom6_to_ocn_mode(comp_wav="swav", ocn_grid_mode="Create New") + assert list(cvars['WAV_GRID_MODE'].options) == ["Standard"] + + if __name__ == "__main__": test_regional_wave_and_dice_constraints_removed() test_dwav_option_constraint_is_live() test_regional_mom6_couples_with_ww3() + test_wav_grid_options_ww3_custom_ocean() + test_wav_grid_options_ww3_standard_ocean() + test_wav_grid_options_stub_wave() print("All tests passed!") diff --git a/visualCaseGen/config_vars/grid_vars.py b/visualCaseGen/config_vars/grid_vars.py index 901eda7..619865c 100644 --- a/visualCaseGen/config_vars/grid_vars.py +++ b/visualCaseGen/config_vars/grid_vars.py @@ -158,3 +158,11 @@ def default_fsurdat_area_spec(): ConfigVarStr("ROF_OCN_MAPPING_STATUS", widget_none_val="") ConfigVarReal("ROF_OCN_MAPPING_RMAX") ConfigVarReal("ROF_OCN_MAPPING_FOLD") + + # Custom wave grid variables (relevant when an active/data wave component is present) + # Whether to use the custom ocean grid for waves or pick a standard wave grid: + ConfigVarStr("WAV_GRID_MODE") # "Custom Ocean Grid" or "Standard" + # A preexisting wave grid picked when WAV_GRID_MODE == "Standard": + ConfigVarStrMS("CUSTOM_WAV_GRID") + # Status variable gating completion of the WW3 input-file generation sub-stage: + ConfigVarStr("WW3_INPUT_STATUS", widget_none_val="") diff --git a/visualCaseGen/custom_widget_types/case_creator.py b/visualCaseGen/custom_widget_types/case_creator.py index b7406d6..5f78940 100644 --- a/visualCaseGen/custom_widget_types/case_creator.py +++ b/visualCaseGen/custom_widget_types/case_creator.py @@ -165,10 +165,14 @@ def create_case(self, do_exec): # Run case.setup run_case_setup(do_exec, self._is_non_local(), self._out) - # Copy ww3 input files to RUNDIR if needed (only for custom grids, which - # is where the *.inp files are generated): + # Copy ww3 input files to RUNDIR if needed (only when the custom ocean grid is reused as + # the wave grid, which is where the *.inp files are generated): custom_grid_path_val = cvars["CUSTOM_GRID_PATH"].value - if cvars["COMP_WAV"].value == "ww3" and custom_grid_path_val is not None: + if ( + cvars["COMP_WAV"].value == "ww3" + and custom_grid_path_val is not None + and self._wav_uses_custom_ocn_grid() + ): # copy all *.inp files under the ocnice grid directory to RUNDIR: inp_files = list(Path(custom_grid_path_val).glob("ocnice/*.inp")) if inp_files: @@ -555,7 +559,8 @@ def _apply_all_xmlchanges(self, do_exec): self._apply_lnd_grid_xmlchanges(do_exec) self._apply_ocn_grid_xmlchanges(do_exec) self._apply_runoff_ocn_mapping_xmlchanges(do_exec) - + self._apply_wav_coupling_xmlchanges(do_exec) + def _apply_lnd_grid_xmlchanges(self, do_exec): """Apply xmlchanges related to custom land grid if needed.""" @@ -605,6 +610,28 @@ def _apply_runoff_ocn_mapping_xmlchanges(self, do_exec): 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) + def _wav_uses_custom_ocn_grid(self): + """Return True if the wave component should use the newly created custom ocean grid as its + grid (rather than a selected standard wave grid). This is the case for a custom ocean grid + unless the user explicitly picked a standard wave grid in the Wave Grid stage.""" + if cvars["OCN_GRID_MODE"].value != "Create New": + return False # no custom ocean grid exists to reuse + wav_grid = cvars["CUSTOM_WAV_GRID"].value + picked_standard_wav_grid = ( + cvars["WAV_GRID_MODE"].value == "Standard" + and wav_grid not in (None, "", "null") + ) + return not picked_standard_wav_grid + + def _apply_wav_coupling_xmlchanges(self, do_exec): + """Use the legacy MOM6-WW3 wave coupling method when the custom ocean grid is reused as + the wave grid.""" + + if cvars["COMP_WAV"].value == "ww3" and self._wav_uses_custom_ocn_grid(): + with self._out: + print(f"{COMMENT}Set wave coupling mode to legacy:{RESET}\n") + xmlchange("MOM6_WW3_CPL_METHOD", "legacy", 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 = 300, ideal_multiple_of_cores_used = 128): @@ -761,22 +788,6 @@ def _apply_mom_namelist_changes(self, do_exec): else: raise RuntimeError(f"Unknown ocean initial conditions mode: {cvars['OCN_IC_MODE'].value}") - # When coupled to active waves (WW3) in a custom grid, - # use the legacy EFACTOR wave coupling method. - if ocn_grid_mode != "Standard" and cvars["COMP_WAV"].value == "ww3": - self._apply_user_nl_changes( - "mom", - [ - ("WAVE_METHOD", "EFACTOR"), - ("STOKES_DDT", "False"), - ("STOKES_VF", "False"), - ("STOKES_PGF", "False"), - ], - do_exec, - comment="WW3 Legacy Coupling Method for Custom Grids", - log_title=False, - ) - def _apply_cice_namelist_changes(self, do_exec): """Apply all necessary changes to user_nl_cice file.""" @@ -910,12 +921,22 @@ def _update_component_grids_xml( f' ocean mesh: {ocn_mesh}.{RESET}\n' ) - # OCN, ICE, and WAV share the custom ocean grid dimensions and mesh: - for comp in ("OCN", "ICE", "WAV"): + # OCN and ICE share the custom ocean grid dimensions and mesh. WAV shares them too, + # unless the user picked a standard wave grid (handled separately below). + comps_sharing_ocn_grid = ["OCN", "ICE"] + if self._wav_uses_custom_ocn_grid(): + comps_sharing_ocn_grid.append("WAV") + for comp in comps_sharing_ocn_grid: xmlchange(f"{comp}_NX", cvars["OCN_NX"].value, do_exec, self._is_non_local(), self._out) xmlchange(f"{comp}_NY", cvars["OCN_NY"].value, do_exec, self._is_non_local(), self._out) xmlchange(f"{comp}_DOMAIN_MESH", ocn_mesh.as_posix(), do_exec, self._is_non_local(), self._out) + # If a standard wave grid was selected instead, set the wave grid and its mesh. + wav_grid = cvars["CUSTOM_WAV_GRID"].value + if not self._wav_uses_custom_ocn_grid() and wav_grid not in (None, "", "null"): + xmlchange("WAV_GRID", wav_grid, do_exec, self._is_non_local(), self._out) + xmlchange("WAV_DOMAIN_MESH", self._cime.get_mesh_path("wav", wav_grid), 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) diff --git a/visualCaseGen/custom_widget_types/ww3_input_generator.py b/visualCaseGen/custom_widget_types/ww3_input_generator.py new file mode 100644 index 0000000..556a7e7 --- /dev/null +++ b/visualCaseGen/custom_widget_types/ww3_input_generator.py @@ -0,0 +1,102 @@ +import logging +from ipywidgets import HBox, VBox, Button, Output + +from ProConPy.out_handler import handler as owh +from ProConPy.config_var import cvars +from ProConPy.dialog import alert_warning +from visualCaseGen.custom_widget_types.mom6_forge_launcher import MOM6ForgeLauncher + +logger = logging.getLogger("\t" + __name__.split(".")[-1]) + + +class WW3InputGenerator(VBox): + """Widget to generate the WW3 grid-preprocessor input files from a custom ocean grid. + + When the user opts to use the newly created custom ocean grid as the wave grid (WW3), this + widget reconstructs the mom6_forge Grid/Topo from the already-saved ocean grid files and + writes the WW3 ``*.inp`` files into the custom grid's ``ocnice`` directory. Those files are + later copied into the case RUNDIR by the case creator.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._btn_generate = Button( + description="Generate WW3 input files", + button_style="success", + tooltip="Reconstruct the custom ocean grid and write the WW3 grid-preprocessor input files.", + layout={"width": "260px", "align_self": "center", "margin": "10px"}, + ) + self._btn_generate.on_click(self.on_btn_generate_clicked) + + self._out = Output() + + self.children = [ + HBox([self._btn_generate], layout={"justify_content": "center"}), + self._out, + ] + + # Reset the status when the wave grid mode changes. + cvars["WAV_GRID_MODE"].observe(self.reset, names="value", type="change") + + @property + def disabled(self): + return super().disabled + + @disabled.setter + def disabled(self, value): + self._btn_generate.disabled = value + + def reset(self, change=None): + """Reset the widget output and the WW3_INPUT_STATUS variable.""" + self._out.clear_output() + cvars["WW3_INPUT_STATUS"].value = None + + @owh.out.capture() + def on_btn_generate_clicked(self, b): + """Reconstruct the custom ocean grid/topography and write the WW3 input files.""" + import xarray as xr + from mom6_forge.grid import Grid + from mom6_forge.topo import Topo + + cvars["WW3_INPUT_STATUS"].value = None + self._out.clear_output() + + grid_alias = cvars["CUSTOM_OCN_GRID_NAME"].value + supergrid_file = MOM6ForgeLauncher.supergrid_file_path() + topo_file = MOM6ForgeLauncher.topo_file_path() + ocnice_dir = MOM6ForgeLauncher.get_custom_ocn_grid_path() + + # The custom ocean grid files must already exist (created via the mom6_forge notebook). + for f in (supergrid_file, topo_file): + if not f.exists(): + alert_warning( + "The custom ocean grid files were not found. Please complete the custom " + f"ocean grid stage first. Missing file: {f}" + ) + return + + try: + # Disable the widget while the generator runs. + self.disabled = True + with self._out: + # Use the same min_depth the ocean grid was built with so the WW3 land/sea mask + # matches the ocean mask. mom6_forge persists it as an attribute of the topog + # file -- the same source the case creator uses for MOM6's MINIMUM_DEPTH -- so + # this stays correct even if the user edited min_depth in the mom6_forge notebook. + with xr.open_dataset(topo_file) as ds_topo: + if "min_depth" not in ds_topo.attrs: + alert_warning( + f"The topography file {topo_file} does not record a 'min_depth' " + "attribute. Please regenerate the custom ocean grid with mom6_forge." + ) + self.disabled = False + return + min_depth = float(ds_topo.attrs["min_depth"]) + grid = Grid.from_supergrid(supergrid_file.as_posix()) + topo = Topo.from_topo_file(grid, topo_file.as_posix(), min_depth=min_depth) + topo.write_ww3_input(ocnice_dir.as_posix(), grid_alias=grid_alias) + print(f"WW3 input files written to {ocnice_dir} (min_depth={min_depth}).") + cvars["WW3_INPUT_STATUS"].value = "Complete" + except Exception as e: + alert_warning(f"An error occurred while generating the WW3 input files: {e}") + self.disabled = False diff --git a/visualCaseGen/specs/grid_options.py b/visualCaseGen/specs/grid_options.py index 2f00983..95c3729 100644 --- a/visualCaseGen/specs/grid_options.py +++ b/visualCaseGen/specs/grid_options.py @@ -18,6 +18,7 @@ def set_grid_options(cime): set_custom_ocn_grid_options(cime) set_custom_lnd_grid_options(cime) set_custom_rof_grid_options(cime) + set_custom_wav_grid_options(cime) def set_standard_grid_options(cime): @@ -263,4 +264,53 @@ def custom_rof_grid_options_func(grid_mode): 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"],) + ) + + +def set_custom_wav_grid_options(cime): + """Set the options and options specs for the custom wave grid variables. + This function is called at initialization.""" + + # WAV_GRID_MODE options: whether to reuse the custom ocean grid as the wave grid or pick a + # standard wave grid. The "Custom Ocean Grid" option is only offered for an active wave model + # (WW3) coupled to a newly created custom ocean grid; otherwise only "Standard" applies. + def wav_grid_mode_options_func(grid_mode, comp_wav, ocn_grid_mode): + if grid_mode != "Custom": + return None, None + if comp_wav == "ww3" and ocn_grid_mode == "Create New": + return ["Custom Ocean Grid", "Standard"], [ + "Use the newly created custom ocean grid as the wave grid.", + "Pick an existing (standard) wave grid.", + ] + return ["Standard"], ["Pick an existing (standard) wave grid."] + + cv_wav_grid_mode = cvars["WAV_GRID_MODE"] + cv_wav_grid_mode.options_spec = OptionsSpec( + func=wav_grid_mode_options_func, + args=(cvars["GRID_MODE"], cvars["COMP_WAV"], cvars["OCN_GRID_MODE"]), + ) + + # CUSTOM_WAV_GRID options: the existing wave grid to use when WAV_GRID_MODE == "Standard". + def custom_wav_grid_options_func(grid_mode, wav_grid_mode, comp_wav): + if grid_mode != "Custom": + return None, None + if wav_grid_mode == "Custom Ocean Grid": + return ["null"], ["(The custom ocean grid is used as the wave grid.)"] + if comp_wav == "swav": + return ["null"], ["(When stub WAV is selected, custom wave grid is set to null.)"] + # WAV_GRID_MODE == "Standard": list compatible standard wave grids. + compset_lname = cvars["COMPSET_LNAME"].value + compatible_wav_grids = [] + descriptions = [] + for wav_grid in cime.domains["wav"].values(): + if check_comp_grid("WAV", wav_grid, compset_lname) is False: + continue + compatible_wav_grids.append(wav_grid.name) + descriptions.append(wav_grid.desc) + return compatible_wav_grids, descriptions + + cv_custom_wav_grid = cvars["CUSTOM_WAV_GRID"] + cv_custom_wav_grid.options_spec = OptionsSpec( + func=custom_wav_grid_options_func, + args=(cvars["GRID_MODE"], cvars["WAV_GRID_MODE"], cvars["COMP_WAV"]), ) \ No newline at end of file diff --git a/visualCaseGen/stages/grid_stages.py b/visualCaseGen/stages/grid_stages.py index b0928de..68937fc 100644 --- a/visualCaseGen/stages/grid_stages.py +++ b/visualCaseGen/stages/grid_stages.py @@ -12,6 +12,7 @@ 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 +from visualCaseGen.custom_widget_types.ww3_input_generator import WW3InputGenerator logger = logging.getLogger("\t" + __name__.split(".")[-1]) @@ -334,3 +335,47 @@ def initialize_grid_stages(cime): ), varlist=[cvars["ROF_OCN_MAPPING_STATUS"]], ) + + stg_custom_wav_grid_mode = Stage( + title="Wave Grid Mode", + description="Choose the wave grid for the new, custom CESM grid. If you created a custom " + "ocean grid and waves are active (WW3), you may reuse that ocean grid as the wave grid; " + "otherwise, select an existing (standard) wave grid.", + widget=StageWidget(VBox), + parent=guard_custom_grid, + varlist=[cvars["WAV_GRID_MODE"]], + # Only relevant when a wave component is present. For stub waves (swav) there is no wave + # grid to configure, so this stage (and its branches) are skipped. + relevance_condition=cvars["COMP_WAV"] != "swav", + ) + + stg_custom_wav_grid_standard = Stage( + title="Wave Grid", + description="Select an existing (standard) wave grid to use within the new, custom CESM grid.", + widget=StageWidget(VBox), + parent=Guard( + title="Std Wav Grid", + parent=stg_custom_wav_grid_mode, + branch_condition=cvars["WAV_GRID_MODE"] == "Standard", + ), + varlist=[cvars["CUSTOM_WAV_GRID"]], + auto_set_default_value=False, + relevance_condition=cvars["COMP_WAV"] != "swav", + ) + + stg_custom_wav_ocn_sharing = Stage( + title="Wave Input Files", + description="Reusing the custom ocean grid as the wave grid requires generating the WW3 " + "grid-preprocessor input files from the ocean grid. Click the button below to generate " + "them, then click Confirm to proceed.", + widget=StageWidget( + VBox, + supplementary_widgets=[WW3InputGenerator()] + ), + parent=Guard( + title="WAV uses OCN grid", + parent=stg_custom_wav_grid_mode, + branch_condition=cvars["WAV_GRID_MODE"] == "Custom Ocean Grid", + ), + varlist=[cvars["WW3_INPUT_STATUS"]], + ) diff --git a/visualCaseGen/widgets/grid_widgets.py b/visualCaseGen/widgets/grid_widgets.py index f471206..52c98ba 100644 --- a/visualCaseGen/widgets/grid_widgets.py +++ b/visualCaseGen/widgets/grid_widgets.py @@ -31,6 +31,7 @@ def initialize_grid_widgets(cime): initialize_custom_ocn_grid_widgets() initialize_custom_lnd_grid_widgets() initialize_custom_rof_grid_widgets() + initialize_custom_wav_grid_widgets() def initialize_standard_grid_widgets(): """Initialize the widgets for the standard grid options.""" @@ -350,4 +351,23 @@ def initialize_custom_rof_grid_widgets(): description="Smoothing Fold (km):", layout={"width": "370px", "padding": "5px"}, style={"description_width": "250px"}, - ) \ No newline at end of file + ) + + +def initialize_custom_wav_grid_widgets(): + """Initialize the widgets for the custom wave grid options.""" + cv_wav_grid_mode = cvars["WAV_GRID_MODE"] + cv_wav_grid_mode.widget = ToggleButtons( + description="Wave Grid:", + layout={"display": "flex", "width": "max-content", "padding": "10px"}, + style={"button_width": "160px", "description_width": description_width}, + ) + + cv_custom_wav_grid = cvars["CUSTOM_WAV_GRID"] + cv_custom_wav_grid.widget = MultiCheckbox( + description="Custom Wave Grid:", + allow_multi_select=False, + ) + + cv_ww3_input_status = cvars["WW3_INPUT_STATUS"] + cv_ww3_input_status.widget = DisabledText(value='') \ No newline at end of file From 85b976b739e73bcfd28ac591b3a0561b893469b1 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Fri, 26 Jun 2026 15:42:01 -0600 Subject: [PATCH 7/9] Fix empty remaining-variable warning on OK; commit T_REF on enter --- .../custom_widget_types/stage_widget.py | 20 ++++++++++++------- visualCaseGen/widgets/grid_widgets.py | 1 + 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/visualCaseGen/custom_widget_types/stage_widget.py b/visualCaseGen/custom_widget_types/stage_widget.py index f6a2b8e..e573c04 100644 --- a/visualCaseGen/custom_widget_types/stage_widget.py +++ b/visualCaseGen/custom_widget_types/stage_widget.py @@ -342,13 +342,19 @@ def attempt_to_proceed(self, b): """ if self._stage.status == StageStat.COMPLETE: self._stage._proceed() - else: - alert_warning( - "Please complete all of the variables in this stage first. Remaining variable(s): " - + ", ".join( - [var.name for var in self._stage._varlist if var.value is None] - ) - ) + return + + remaining = [var.name for var in self._stage._varlist if var.value is None] + if not remaining: + # All variables are set but the stage is not COMPLETE -- e.g., it already + # auto-proceeded (and is now SEALED). Clicking OK again is a no-op rather than a + # confusing "remaining variable(s):" warning with an empty list. + return + + alert_warning( + "Please complete all of the variables in this stage first. Remaining variable(s): " + + ", ".join(remaining) + ) @owh.out.capture() def reset(self): diff --git a/visualCaseGen/widgets/grid_widgets.py b/visualCaseGen/widgets/grid_widgets.py index 52c98ba..469489b 100644 --- a/visualCaseGen/widgets/grid_widgets.py +++ b/visualCaseGen/widgets/grid_widgets.py @@ -153,6 +153,7 @@ def initialize_custom_ocn_grid_widgets(): description="Reference Temperature [degC]:", layout={"width": "370px", "padding": "5px"}, style={"description_width": "250px"}, + continuous_update=False, ) cv_temp_salt_z_init = cvars["TEMP_SALT_Z_INIT_FILE"] From 1b37b77a14ccb5f7cc0a9aaea49e2f37492b17d0 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Sun, 28 Jun 2026 15:26:22 -0600 Subject: [PATCH 8/9] Add wav grid to resolution entry for an active wave component. --- visualCaseGen/custom_widget_types/case_creator.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/visualCaseGen/custom_widget_types/case_creator.py b/visualCaseGen/custom_widget_types/case_creator.py index 5f78940..04390ca 100644 --- a/visualCaseGen/custom_widget_types/case_creator.py +++ b/visualCaseGen/custom_widget_types/case_creator.py @@ -347,6 +347,21 @@ def _update_modelgrid_aliases(self, custom_grid_path, ocn_grid, do_exec): ) new_rof_grid.text = rof_grid + # Add wav grid to resolution entry for an active wave component. + wav_grid = None + if cvars["COMP_WAV"].value != "swav": + if self._wav_uses_custom_ocn_grid(): + wav_grid = ocn_grid + elif (custom_wav_grid := cvars["CUSTOM_WAV_GRID"].value) not in (None, "", "null"): + wav_grid = custom_wav_grid + if wav_grid is not None: + new_wav_grid = SubElement( + new_resolution, + "grid", + attrib={"name": "wav"}, + ) + new_wav_grid.text = wav_grid + if not do_exec: return From 350773a9643f84bede38bb75071154cc60e75d00 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Mon, 29 Jun 2026 09:20:46 -0600 Subject: [PATCH 9/9] bump up mom6_forge --- external/mom6_forge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/mom6_forge b/external/mom6_forge index 4aa522a..ce14c7b 160000 --- a/external/mom6_forge +++ b/external/mom6_forge @@ -1 +1 @@ -Subproject commit 4aa522a183906496c5d4a66fdc99d3a8f684c8c0 +Subproject commit ce14c7bffef74e4897417d5e91182995a4b1a4e6