From a26751fb7e1c381e7ae4e56c67b3a67143fad487 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 10:43:01 -0600 Subject: [PATCH 01/12] Strip inline-comment whitespace from user_nl_mom values and fix Path concatenation in error messages --- cime_config/MOM_RPS/FType_MOM_params.py | 13 ++++++------- cime_config/MOM_RPS/FType_diag_table.py | 6 +++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index 79da6ad..82c8c2b 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -67,11 +67,8 @@ def _read_MOM_input(input_path): if re.search("^\s*\w*\s*=\s*[^ \t\n\r\f\v!]+", line_j): eq_ix = line_j.index("=") varname = line_j[:eq_ix].strip() - val_str = line_j[eq_ix + 1 :].strip() - if "!" in val_str: - val_str = val_str.split("!")[ - 0 - ] # discard the comment in val str, if there is + # discard the comment in val str, if there is + val_str = line_j[eq_ix + 1 :].split("!")[0].strip() # add this module if not added before: if not curr_module in _data: @@ -83,14 +80,16 @@ def _read_MOM_input(input_path): "ERROR: " + varname + " listed more than once in " - + input_path + + str(input_path) ) # enter the parameter in the dictionary: _data[curr_module][varname] = {"value": val_str} else: raise SystemExit( - "ERROR: Cannot parse the following line in user_nl_mom: " + "ERROR: Cannot parse the following line in " + + str(input_path) + + ": " + line ) diff --git a/cime_config/MOM_RPS/FType_diag_table.py b/cime_config/MOM_RPS/FType_diag_table.py index 5b0bab5..5ce7340 100644 --- a/cime_config/MOM_RPS/FType_diag_table.py +++ b/cime_config/MOM_RPS/FType_diag_table.py @@ -32,9 +32,9 @@ def resolve(unresolved_diag_table_path, resolved_diag_table_path, casename): casename : str The casename to be resolved. """ - assert os.path.exists(unresolved_diag_table_path), ( - "Unresolved diag_table file not found: " + unresolved_diag_table_path - ) + assert os.path.exists( + unresolved_diag_table_path + ), f"Unresolved diag_table file not found: {unresolved_diag_table_path}" with open(resolved_diag_table_path, "w") as resolved_diag_table: with open(unresolved_diag_table_path, "r") as diag_table_unresolved: From 59fc7f42063140a12c93dc42ca7830a92d34abae Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 11:02:11 -0600 Subject: [PATCH 02/12] Validate parameter block structure and recognize comments as MOM6 does when reading MOM_input-style files --- cime_config/MOM_RPS/FType_MOM_params.py | 137 ++++++++++++++---------- 1 file changed, 81 insertions(+), 56 deletions(-) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index 82c8c2b..54dd95f 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -37,67 +37,92 @@ def _read_MOM_input(input_path): within_comment_block = False curr_module = "Global" for line in param_file: - line = line.strip() - if len(line) > 1: - line_s = line.split() - - # check if within comment block. - if (not within_comment_block) and line.strip()[0:2] == "/*": - within_comment_block = True - - if within_comment_block and line.strip()[-2:] == "*/": - within_comment_block = False - continue + # a comment starts at the first !, // or /*, and a comment block + # (/* ... */) may only be opened on a line that has nothing but a + # comment on it (as per MOM6 behavior) + if within_comment_block: + within_comment_block = "*/" not in line + continue + line_s = re.split(r"!|//|/\*", line, maxsplit=1)[0].split() + if not line_s: + opening = line.find("/*") + within_comment_block = ( + opening >= 0 and "*/" not in line[opening + 2 :] + ) + continue + line = " ".join(line_s) + + # check format: + if line[0] == "%": # closes a parameter block, by name or not + if curr_module == "Global": + raise SystemExit( + f"ERROR: {line} in {input_path} closes a parameter block " + "that is not open." + ) + if line[1:].strip() not in ("", curr_module): + raise SystemExit( + f"ERROR: {line} in {input_path} does not close the open " + f"parameter block, {curr_module}%." + ) + curr_module = "Global" + elif line[-1] == "%": # opens a parameter block + if curr_module != "Global": + raise SystemExit( + f"ERROR: {line} in {input_path} opens a parameter block " + f"within {curr_module}%, but nested blocks are not supported." + ) + curr_module = line[:-1].strip() + if not re.fullmatch(r"\w+", curr_module): + raise SystemExit( + f"ERROR: {line} in {input_path} does not open a parameter " + "block: block names may contain letters, digits and " + "underscores only." + ) + else: + # discard override keyword if provided: + if line_s[0] == "#override" and len(line_s) > 1: + line_s = line_s[1:] + line_j = " ".join(line_s) + + # now parse the line: + if re.search(r"^\w+\s*=\s*\S", line_j): + eq_ix = line_j.index("=") + varname = line_j[:eq_ix].strip() + val_str = line_j[eq_ix + 1 :].strip() + + # add this module if not added before: + if not curr_module in _data: + _data[curr_module] = dict() + + # check if param already provided: + if varname in _data[curr_module]: + raise SystemExit( + "ERROR: " + + varname + + " listed more than once in " + + str(input_path) + ) - if ( - not within_comment_block and line_s[0][0] != "!" - ): # not a single comment line either - # check format: - if (curr_module == "Global") and line.strip()[-1] == "%": - curr_module = line.strip()[:-1] - elif curr_module != "Global" and line.strip()[0] == "%": - curr_module = "Global" - else: - # discard override keyword if provided: - if line_s[0] == "#override" and len(line_s) > 1: - line_s = line_s[1:] - line_j = " ".join(line_s) - - # now parse the line: - if re.search("^\s*\w*\s*=\s*[^ \t\n\r\f\v!]+", line_j): - eq_ix = line_j.index("=") - varname = line_j[:eq_ix].strip() - # discard the comment in val str, if there is - val_str = line_j[eq_ix + 1 :].split("!")[0].strip() - - # add this module if not added before: - if not curr_module in _data: - _data[curr_module] = dict() - - # check if param already provided: - if varname in _data[curr_module]: - raise SystemExit( - "ERROR: " - + varname - + " listed more than once in " - + str(input_path) - ) - - # enter the parameter in the dictionary: - _data[curr_module][varname] = {"value": val_str} - else: - raise SystemExit( - "ERROR: Cannot parse the following line in " - + str(input_path) - + ": " - + line - ) + # enter the parameter in the dictionary: + _data[curr_module][varname] = {"value": val_str} + else: + raise SystemExit( + "ERROR: Cannot parse the following line in " + + str(input_path) + + ": " + + line + ) # Check if there is unclosed block: if within_comment_block: - raise SystemExit("ERROR: faulty comment block!") + raise SystemExit( + f"ERROR: A comment block (/* ... */) is not closed in {input_path}." + ) if curr_module != "Global": - raise SystemExit("ERROR: faulty module block!") + raise SystemExit( + f"ERROR: The parameter block {curr_module}% is not closed in " + f"{input_path}." + ) return _data From 4de7d8d5599591d471bd3f155ca3cd1481624df5 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 12:28:08 -0600 Subject: [PATCH 03/12] Add FType_MOM_params.get_value so templates see MOM6 parameter values as MOM6 reads them --- cime_config/MOM_RPS/FType_MOM_params.py | 35 ++++++++++++++++++++ cime_config/MOM_RPS/FType_diag_table.py | 4 +-- cime_config/MOM_RPS/FType_input_data_list.py | 2 +- cime_config/buildnml | 21 +++++------- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index 54dd95f..b51f090 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -126,6 +126,41 @@ def _read_MOM_input(input_path): return _data + def get_value(self, varname, module="Global"): + """Returns the value of a parameter, or None if the parameter is not set. + + The value is returned bare, like that of a case variable, so that $VAR and + ${VAR} in templates expand it as they would a case variable: stripped of + enclosing quotes, and, if it is a logical, as the string "True" or "False", + however it is spelled in the parameter file (MOM6 also accepts T, .true., + etc.) + + Parameters + ---------- + varname: str + Name of the parameter. + module: str + Name of the parameter block that the parameter belongs to, if any. + + Example + ------- + >>> params = FType_MOM_params({"Global": {"A": {"value": '"x"'}, "B": {"value": ".false."}}}) + >>> params.get_value("A"), params.get_value("B"), params.get_value("C") + ('x', 'False', None) + >>> FType_MOM_params({"KPP": {"N_SMOOTH": {"value": "2"}}}).get_value("N_SMOOTH", "KPP") + '2' + """ + value = self._data.get(module, {}).get(varname, {}).get("value") + if value is None: + return None + value = str(value).strip() + if value[:1] in ("'", '"') and value.find(value[0], 1) == len(value) - 1: + value = value[1:-1] + logical = re.fullmatch(r"\.?(t|true|f|false)\.?", value, re.IGNORECASE) + if logical: + value = "True" if logical.group(1).lower() in ("t", "true") else "False" + return value + def write( self, output_path, output_format, case=None, def_params=None, inst_suffix="" ): diff --git a/cime_config/MOM_RPS/FType_diag_table.py b/cime_config/MOM_RPS/FType_diag_table.py index 5ce7340..0c607ee 100644 --- a/cime_config/MOM_RPS/FType_diag_table.py +++ b/cime_config/MOM_RPS/FType_diag_table.py @@ -60,9 +60,7 @@ def write(self, output_path, case, MOM_input_final): def expand_func(varname): val = case.get_value(varname) if val is None: - val = ( - MOM_input_final.data.get("Global", {}).get(varname, {}).get("value") - ) + val = MOM_input_final.get_value(varname) if val is None: raise DiagTableError( "Cannot determine the value of the variable {} appearing in " diff --git a/cime_config/MOM_RPS/FType_input_data_list.py b/cime_config/MOM_RPS/FType_input_data_list.py index 6f2f7a9..5efa5a5 100644 --- a/cime_config/MOM_RPS/FType_input_data_list.py +++ b/cime_config/MOM_RPS/FType_input_data_list.py @@ -9,7 +9,7 @@ def write(self, output_path, case, MOM_input_final=None): def expand_func(varname): val = case.get_value(varname) if val is None: - val = MOM_input_final.data["Global"][varname]["value"] + val = MOM_input_final.get_value(varname) if val is None: raise RuntimeError("Cannot determine the value of variable: " + varname) return val diff --git a/cime_config/buildnml b/cime_config/buildnml index 8fb12ef..3175903 100755 --- a/cime_config/buildnml +++ b/cime_config/buildnml @@ -121,8 +121,8 @@ def prep_input(case, inst_suffixes): MOM_override_final = FType_MOM_params.from_MOM_input(rundir / f"MOM_override{inst_suffixes[0]}") MOM_input_final.append(MOM_override_final) # Need to know value of USE_MARBL_TRACERS from MOM_input - if "USE_MARBL_TRACERS" in MOM_input_final._data["Global"]: - use_MARBL = (MOM_input_final._data["Global"]["USE_MARBL_TRACERS"]["value"].strip().lower() == "true") + if MOM_input_final.get_value("USE_MARBL_TRACERS") is not None: + use_MARBL = MOM_input_final.get_value("USE_MARBL_TRACERS") == "True" else: print("NOTE: could not find USE_MARBL_TRACERS in MOM_input_final") use_MARBL = False @@ -210,16 +210,13 @@ def prep_input(case, inst_suffixes): lMARBL_output_all = case.get_value("TEST") # Include ALT_CO2 diagnostics if atm_co2_opt and atm_alt_co2_opt differ # (or if they are both "constant" but atm_co2_const != atm_alt_co2_const) - if "ATM_CO2_OPT" in MOM_input_final._data["Global"]: - atm_co2_opt = MOM_input_final._data["Global"]["ATM_CO2_OPT"]["value"].strip().lower().replace('"', "") - try: - atm_alt_co2_opt = MOM_input_final._data["Global"]["ATM_ALT_CO2_OPT"]["value"].strip().lower().replace('"', "") - except: - atm_alt_co2_opt = "constant" + if MOM_input_final.get_value("ATM_CO2_OPT") is not None: + atm_co2_opt = MOM_input_final.get_value("ATM_CO2_OPT").lower() + atm_alt_co2_opt = (MOM_input_final.get_value("ATM_ALT_CO2_OPT") or "constant").lower() if (atm_co2_opt == atm_alt_co2_opt): if atm_co2_opt == "constant": - lMARBL_output_alt_co2 = (MOM_input_final._data["Global"]["ATM_CO2_CONST"]["value"] != - MOM_input_final._data["Global"]["ATM_ALT_CO2_CONST"]["value"]) + lMARBL_output_alt_co2 = (MOM_input_final.get_value("ATM_CO2_CONST") != + MOM_input_final.get_value("ATM_ALT_CO2_CONST")) else: lMARBL_output_alt_co2 = False else: @@ -397,8 +394,8 @@ def postchecks(case, MOM_input_final): """Performs checks after input files are generated. To be called within prep_input() as a final step.""" ntasks_ocn = case.get_value("NTASKS_OCN") - niglobal = int(MOM_input_final._data["Global"]["NIGLOBAL"]['value']) - njglobal = int(MOM_input_final._data["Global"]["NJGLOBAL"]['value']) + niglobal = int(MOM_input_final.get_value("NIGLOBAL")) + njglobal = int(MOM_input_final.get_value("NJGLOBAL")) # Check whether given NTASKS_OCN is feasible by attempting to compute domain decomposition niproc, njproc = MOM_define_layout(niglobal, njglobal, ntasks_ocn) From d7f347d28641086d40bd10a063ea673996749b80 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 12:38:23 -0600 Subject: [PATCH 04/12] Fix tests/doctests.py to run the doctests of all MOM_RPS modules --- tests/doctests.py | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/doctests.py b/tests/doctests.py index 8c2f544..0b0730d 100755 --- a/tests/doctests.py +++ b/tests/doctests.py @@ -1,14 +1,37 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +"""Runs the doctests of the modules in cime_config/MOM_RPS. -import os, sys -import unittest -from doctest import testmod +Modules that depend on CIME, i.e., the FType classes built on ParamGen, are +tested only if CIMEROOT is set, e.g.: + CIMEROOT=/path/to/cime python tests/doctests.py +""" -sys.path.append(os.path.join("cime_config","MOM_RPS")) -sys.path.append(os.path.join("../","cime_config","MOM_RPS")) +import doctest +import importlib +import os +import sys -import rps_utils +MOM_RPS = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "cime_config", "MOM_RPS" +) +sys.path.append(MOM_RPS) +if os.environ.get("CIMEROOT"): + sys.path.append(os.environ["CIMEROOT"]) -if __name__ == '__main__': - testmod(rps_utils, verbose=False) - print("Done running doctests.") +failed = 0 +for filename in sorted(os.listdir(MOM_RPS)): + if not filename.endswith(".py"): + continue + name = filename[:-3] + try: + module = importlib.import_module(name) + except ModuleNotFoundError as error: + if error.name.split(".")[0] != "CIME": + raise + print("{}: skipped, as it requires CIME (set CIMEROOT)".format(name)) + continue + results = doctest.testmod(module) + failed += results.failed + print("{}: {} tests, {} failed".format(name, results.attempted, results.failed)) + +sys.exit(1 if failed else 0) From 997190dc92d577f56437893725f6368ec8cd7432 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 12:52:22 -0600 Subject: [PATCH 05/12] Write MOM_override in one place with a single header, and check SourceMods conflicts with expect --- cime_config/MOM_RPS/FType_MOM_params.py | 4 +- cime_config/buildnml | 54 +++++-------------------- 2 files changed, 12 insertions(+), 46 deletions(-) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index b51f090..cbe15c5 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -242,8 +242,8 @@ def expand_func(varname): def _write_MOM_override(self, output_path, def_params): MOM_override_header = """/* WARNING: DO NOT EDIT this file! Any user changes made in files - in RUNDIR will be overriden. This file is automatically generated. - MOM6 parameter changes may ve made via SourceMods or user_nl_mom + in RUNDIR will be overridden. This file is automatically generated. + MOM6 parameter changes may be made via SourceMods or user_nl_mom within CASEROOT.*/\n""" with open(os.path.join(output_path), "w") as MOM_override: diff --git a/cime_config/buildnml b/cime_config/buildnml index 3175903..e16d9b9 100755 --- a/cime_config/buildnml +++ b/cime_config/buildnml @@ -104,16 +104,21 @@ def prep_input(case, inst_suffixes): caseroot / f"user_nl_mom{inst_suffix}" ) if f"MOM_override{inst_suffix}" in SourceMods_listdir: - assert ( - len(user_nl_mom.data) == 0 - ), "Cannot provide parameter changes via both SourceMods and user_nl_mom!" + expect( + user_nl_mom.is_empty, + "Cannot provide parameter changes via both SourceMods and user_nl_mom!", + ) shutil.copy( SourceMods_dir / f"MOM_override{inst_suffix}", rundir / f"MOM_override{inst_suffix}", ) else: - init_MOM_override(rundir, inst_suffix) - process_user_nl_mom(case, inst_suffix) + # Write MOM_override based on the user_nl_mom changes to MOM_input + user_nl_mom.write( + output_path=rundir / f"MOM_override{inst_suffix}", + output_format="MOM_override", + def_params=FType_MOM_params.from_MOM_input(rundir / f"MOM_input{inst_suffix}"), + ) # 3. Read in final versions of MOM_input and MOM_override, so as to use them when inferring # values of expandable variables in the templates of subsequent MOM6 input files. @@ -241,45 +246,6 @@ def prep_input(case, inst_suffixes): postchecks(case, MOM_input_final) -def init_MOM_override(rundir, inst_suffix): - # Create an empty MOM_override: - with open(rundir / f"MOM_override{inst_suffix}", "w") as MOM_override: - MOM_override.write( - "! WARNING: DO NOT EDIT this file! Any user change made in this file will be\n" - + "! overriden. This file is automatically generated. MOM6 parameter\n" - + "! changes may be made via SourceMods or user_nl_mom.\n" - + "!-------------------------------------------------------------------------\n\n" - ) - - -def process_user_nl_mom(case, inst_suffix): - """Calls the appropriate MOM_RPS functions to parse user_nl_mom and create MOM_override.""" - caseroot = Path(case.get_value("CASEROOT")) - rundir = Path(case.get_value("RUNDIR")) - - user_nl_mom = FType_MOM_params.from_MOM_input(caseroot / f"user_nl_mom{inst_suffix}") - - # copy the user_nl_mom parameters into MOM_override: - if len(user_nl_mom.data) > 0: - - # check if a copy of MOM_override is provided in SourceMods: - SourceMods_dir = caseroot / "SourceMods" / "src.mom" - if f"MOM_override{inst_suffix}" in os.listdir(SourceMods_dir): - raise SystemExit( - "ERROR: Cannot provide parameter changes via both SourceMods and user_nl_mom!" - ) - - # parse the MOM_input file staged in rundir: - MOM_input_rundir = FType_MOM_params.from_MOM_input(rundir / f"MOM_input{inst_suffix}") - - # Write MOM_override (based on data from user_nl_mom) - user_nl_mom.write( - output_path=rundir / f"MOM_override{inst_suffix}", - output_format="MOM_override", - def_params=MOM_input_rundir, - ) - - def _copy_input_files(case, dest_dir, inst_suffixes): """Saves copies of MOM6 input files in momconf directory for the record.""" rundir = Path(case.get_value("RUNDIR")) From 9e20d5b5bfad5416255fd70c02ad14b9886043e3 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 13:07:12 -0600 Subject: [PATCH 06/12] Accept full-path parameters (e.g., KPP%N_SMOOTH = 2) in MOM_input-style files, as MOM6 does --- cime_config/MOM_RPS/FType_MOM_params.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index cbe15c5..0e3b177 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -85,17 +85,29 @@ def _read_MOM_input(input_path): line_j = " ".join(line_s) # now parse the line: - if re.search(r"^\w+\s*=\s*\S", line_j): + if re.search(r"^(\w+%)*\w+\s*=\s*\S", line_j): eq_ix = line_j.index("=") varname = line_j[:eq_ix].strip() val_str = line_j[eq_ix + 1 :].strip() + # a parameter may be prefixed by its block, e.g., KPP%N_SMOOTH, + # in which case that is its block, whether a block is open or not. + module = curr_module + if "%" in varname: + module, _, varname = varname.rpartition("%") + if "%" in module: + raise SystemExit( + f"ERROR: {line} in {input_path} sets a parameter " + f"within the nested block {module}%, but nested " + "blocks are not supported." + ) + # add this module if not added before: - if not curr_module in _data: - _data[curr_module] = dict() + if not module in _data: + _data[module] = dict() # check if param already provided: - if varname in _data[curr_module]: + if varname in _data[module]: raise SystemExit( "ERROR: " + varname @@ -104,7 +116,7 @@ def _read_MOM_input(input_path): ) # enter the parameter in the dictionary: - _data[curr_module][varname] = {"value": val_str} + _data[module][varname] = {"value": val_str} else: raise SystemExit( "ERROR: Cannot parse the following line in " From 162dadee6eef887e9bf285e13607c673b9a60612 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 13:18:52 -0600 Subject: [PATCH 07/12] Error out when user_nl_mom sets a parameter outside the block where MOM_input (and so MOM6) expects it --- cime_config/MOM_RPS/FType_MOM_params.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index 0e3b177..437f8b5 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -258,6 +258,29 @@ def _write_MOM_override(self, output_path, def_params): MOM6 parameter changes may be made via SourceMods or user_nl_mom within CASEROOT.*/\n""" + # MOM6 reads a parameter only where it expects it, i.e., outside of any block + # or within a particular block, and ignores it elsewhere. So a parameter that + # MOM_input sets elsewhere than user_nl_mom does would be silently ignored. + def scope(module): + if module == "Global": + return "outside of any block" + return f"within the {module}% block" + + for module in self._data: + for var, entry in self._data[module].items(): + if var in def_params.data.get(module, {}): + continue + expected = [m for m in def_params.data if var in def_params.data[m]] + if expected: + fix = f"{var} = {entry['value']} {scope('Global')}" + if expected[0] != "Global": + fix = f"{expected[0]}%{var} = {entry['value']}" + raise SystemExit( + f"ERROR: {var} is set {scope(module)} in user_nl_mom, but MOM6 " + f"expects it {scope(expected[0])}, as in MOM_input, and so " + f"would ignore it. Set it as {fix} instead." + ) + with open(os.path.join(output_path), "w") as MOM_override: MOM_override.write(MOM_override_header) From cafe5e0f1bb375d76f570008acbd2a24b69db881 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 13:31:20 -0600 Subject: [PATCH 08/12] Move the MOM_input-syntax reader out of FType_MOM_params into a CIME-free param_file_reader.read_MOM_params --- cime_config/MOM_RPS/FType_MOM_params.py | 117 +---------------------- cime_config/MOM_RPS/param_file_reader.py | 115 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 114 deletions(-) create mode 100644 cime_config/MOM_RPS/param_file_reader.py diff --git a/cime_config/MOM_RPS/FType_MOM_params.py b/cime_config/MOM_RPS/FType_MOM_params.py index 437f8b5..ddcc769 100644 --- a/cime_config/MOM_RPS/FType_MOM_params.py +++ b/cime_config/MOM_RPS/FType_MOM_params.py @@ -1,9 +1,10 @@ import os import re -from collections import OrderedDict from CIME.ParamGen.paramgen import ParamGen +from param_file_reader import read_MOM_params + class FType_MOM_params(ParamGen): """Encapsulates data and read/write methods for MOM6 case parameter files: MOM_input, user_nl.""" @@ -23,121 +24,9 @@ def from_MOM_input(cls, input_path): Path to MOM_input file to read in and generate a FType_MOM_params object. """ - _data = FType_MOM_params._read_MOM_input(input_path) + _data = read_MOM_params(input_path) return FType_MOM_params(_data) - @staticmethod - def _read_MOM_input(input_path): - """Reads in input files in MOM_input syntax. Note that this method may be used to - read in MOM_override and user_nl_mom too, since the syntax is the same, but - write methods for MOM_input and MOM_override are different.""" - - _data = OrderedDict() - with open(input_path, "r") as param_file: - within_comment_block = False - curr_module = "Global" - for line in param_file: - # a comment starts at the first !, // or /*, and a comment block - # (/* ... */) may only be opened on a line that has nothing but a - # comment on it (as per MOM6 behavior) - if within_comment_block: - within_comment_block = "*/" not in line - continue - line_s = re.split(r"!|//|/\*", line, maxsplit=1)[0].split() - if not line_s: - opening = line.find("/*") - within_comment_block = ( - opening >= 0 and "*/" not in line[opening + 2 :] - ) - continue - line = " ".join(line_s) - - # check format: - if line[0] == "%": # closes a parameter block, by name or not - if curr_module == "Global": - raise SystemExit( - f"ERROR: {line} in {input_path} closes a parameter block " - "that is not open." - ) - if line[1:].strip() not in ("", curr_module): - raise SystemExit( - f"ERROR: {line} in {input_path} does not close the open " - f"parameter block, {curr_module}%." - ) - curr_module = "Global" - elif line[-1] == "%": # opens a parameter block - if curr_module != "Global": - raise SystemExit( - f"ERROR: {line} in {input_path} opens a parameter block " - f"within {curr_module}%, but nested blocks are not supported." - ) - curr_module = line[:-1].strip() - if not re.fullmatch(r"\w+", curr_module): - raise SystemExit( - f"ERROR: {line} in {input_path} does not open a parameter " - "block: block names may contain letters, digits and " - "underscores only." - ) - else: - # discard override keyword if provided: - if line_s[0] == "#override" and len(line_s) > 1: - line_s = line_s[1:] - line_j = " ".join(line_s) - - # now parse the line: - if re.search(r"^(\w+%)*\w+\s*=\s*\S", line_j): - eq_ix = line_j.index("=") - varname = line_j[:eq_ix].strip() - val_str = line_j[eq_ix + 1 :].strip() - - # a parameter may be prefixed by its block, e.g., KPP%N_SMOOTH, - # in which case that is its block, whether a block is open or not. - module = curr_module - if "%" in varname: - module, _, varname = varname.rpartition("%") - if "%" in module: - raise SystemExit( - f"ERROR: {line} in {input_path} sets a parameter " - f"within the nested block {module}%, but nested " - "blocks are not supported." - ) - - # add this module if not added before: - if not module in _data: - _data[module] = dict() - - # check if param already provided: - if varname in _data[module]: - raise SystemExit( - "ERROR: " - + varname - + " listed more than once in " - + str(input_path) - ) - - # enter the parameter in the dictionary: - _data[module][varname] = {"value": val_str} - else: - raise SystemExit( - "ERROR: Cannot parse the following line in " - + str(input_path) - + ": " - + line - ) - - # Check if there is unclosed block: - if within_comment_block: - raise SystemExit( - f"ERROR: A comment block (/* ... */) is not closed in {input_path}." - ) - if curr_module != "Global": - raise SystemExit( - f"ERROR: The parameter block {curr_module}% is not closed in " - f"{input_path}." - ) - - return _data - def get_value(self, varname, module="Global"): """Returns the value of a parameter, or None if the parameter is not set. diff --git a/cime_config/MOM_RPS/param_file_reader.py b/cime_config/MOM_RPS/param_file_reader.py new file mode 100644 index 0000000..bcbaeea --- /dev/null +++ b/cime_config/MOM_RPS/param_file_reader.py @@ -0,0 +1,115 @@ +"""Readers for the MOM6 parameter files of a case. + +This module does not depend on CIME, so that it can be tested on its own. +""" + +import re +from collections import OrderedDict + + +def read_MOM_params(input_path): + """Reads in a parameter file in MOM_input syntax, e.g., MOM_input or MOM_override.""" + + _data = OrderedDict() + with open(input_path, "r") as param_file: + within_comment_block = False + curr_module = "Global" + for line in param_file: + # a comment starts at the first !, // or /*, and a comment block + # (/* ... */) may only be opened on a line that has nothing but a + # comment on it (as per MOM6 behavior) + if within_comment_block: + within_comment_block = "*/" not in line + continue + line_s = re.split(r"!|//|/\*", line, maxsplit=1)[0].split() + if not line_s: + opening = line.find("/*") + within_comment_block = opening >= 0 and "*/" not in line[opening + 2 :] + continue + line = " ".join(line_s) + + # check format: + if line[0] == "%": # closes a parameter block, by name or not + if curr_module == "Global": + raise SystemExit( + f"ERROR: {line} in {input_path} closes a parameter block " + "that is not open." + ) + if line[1:].strip() not in ("", curr_module): + raise SystemExit( + f"ERROR: {line} in {input_path} does not close the open " + f"parameter block, {curr_module}%." + ) + curr_module = "Global" + elif line[-1] == "%": # opens a parameter block + if curr_module != "Global": + raise SystemExit( + f"ERROR: {line} in {input_path} opens a parameter block " + f"within {curr_module}%, but nested blocks are not supported." + ) + curr_module = line[:-1].strip() + if not re.fullmatch(r"\w+", curr_module): + raise SystemExit( + f"ERROR: {line} in {input_path} does not open a parameter " + "block: block names may contain letters, digits and " + "underscores only." + ) + else: + # discard override keyword if provided: + if line_s[0] == "#override" and len(line_s) > 1: + line_s = line_s[1:] + line_j = " ".join(line_s) + + # now parse the line: + if re.search(r"^(\w+%)*\w+\s*=\s*\S", line_j): + eq_ix = line_j.index("=") + varname = line_j[:eq_ix].strip() + val_str = line_j[eq_ix + 1 :].strip() + + # a parameter may be prefixed by its block, e.g., KPP%N_SMOOTH, + # in which case that is its block, whether a block is open or not. + module = curr_module + if "%" in varname: + module, _, varname = varname.rpartition("%") + if "%" in module: + raise SystemExit( + f"ERROR: {line} in {input_path} sets a parameter " + f"within the nested block {module}%, but nested " + "blocks are not supported." + ) + + # add this module if not added before: + if not module in _data: + _data[module] = dict() + + # check if param already provided: + if varname in _data[module]: + raise SystemExit( + "ERROR: " + + varname + + " listed more than once in " + + str(input_path) + ) + + # enter the parameter in the dictionary: + _data[module][varname] = {"value": val_str} + else: + raise SystemExit( + "ERROR: Cannot parse the following line in " + + str(input_path) + + ": " + + line + ) + + # Check if there is unclosed block: + if within_comment_block: + raise SystemExit( + f"ERROR: A comment block (/* ... */) is not closed in {input_path}." + ) + if curr_module != "Global": + raise SystemExit( + f"ERROR: The parameter block {curr_module}% is not closed in " + f"{input_path}." + ) + + return _data From c9e97ae16ff6f11da72540685692f988e2c608dd Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 13:53:28 -0600 Subject: [PATCH 09/12] Skip input.nml variables whose guards are all false instead of writing them as None --- cime_config/MOM_RPS/FType_input_nml.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cime_config/MOM_RPS/FType_input_nml.py b/cime_config/MOM_RPS/FType_input_nml.py index dd6e130..42cc276 100644 --- a/cime_config/MOM_RPS/FType_input_nml.py +++ b/cime_config/MOM_RPS/FType_input_nml.py @@ -9,5 +9,10 @@ def write(self, output_path, case): # From the general template (input_nml.yaml), reduce a custom input.nml for this case self.reduce(lambda varname: case.get_value(varname)) + # Drop the variables whose guards are all false, rather than writing them as None + for group in self._data.values(): + for var in [var for var, entry in group.items() if entry["values"] is None]: + del group[var] + # write the data in namelist format self.write_nml(output_path) From 4d2b9062a0c223d46f35ebb77d12ff6b760e7dee Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Tue, 6 Oct 2026 15:36:00 -0600 Subject: [PATCH 10/12] Add StreamEdit to diag_table_streams, to apply user_nl_mom changes to diag_table streams --- cime_config/MOM_RPS/diag_table_streams.py | 185 +++++++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/cime_config/MOM_RPS/diag_table_streams.py b/cime_config/MOM_RPS/diag_table_streams.py index 3df5045..b8d79da 100644 --- a/cime_config/MOM_RPS/diag_table_streams.py +++ b/cime_config/MOM_RPS/diag_table_streams.py @@ -89,6 +89,13 @@ _FIELD_NAME = re.compile(r"^\w+$") _REGIONAL_SECTION = re.compile(r"^\s*(-?[\d.]+\s+){5}-?[\d.]+\s*$") +# Stream settings that are whole numbers. +_INT_SETTINGS = ("output_freq", "new_file_freq", "packing") + +# Names that streams added via user_nl_mom may have: the h. form of the file +# name segment that CESM short term archiving recognizes (see config_archive.xml). +_NEW_STREAM_NAME = re.compile(r"^h\.\w+$") + class DiagTableError(Exception): """Raised when a diag_table cannot be generated as specified.""" @@ -240,6 +247,8 @@ class Stream: labels: list of str Labels of the diag_table template entries that this stream was built from. Used in error messages only. + edited: bool + Whether user_nl_mom changes this stream. Used in error messages only. """ def __init__(self, name, settings=None, groups=None, labels=None): @@ -247,13 +256,18 @@ def __init__(self, name, settings=None, groups=None, labels=None): self.settings = dict(settings) if settings else {} self.groups = list(groups) if groups else [] self.labels = list(labels) if labels else [] + self.edited = False @property def source(self): """Describes this stream and its origin, for error messages.""" - return 'stream "{}" (from template entry {})'.format( - self.name, " and ".join(self.labels) or "(unknown)" - ) + if not self.labels: + origin = "user_nl_mom" if self.edited else "(unknown)" + else: + origin = "template entry " + " and ".join(self.labels) + if self.edited: + origin += ", as changed via user_nl_mom" + return 'stream "{}" (from {})'.format(self.name, origin) @property def date_template(self): @@ -394,6 +408,171 @@ def __repr__(self): return "Stream({!r}, {} fields)".format(self.name, len(self.output_names())) +class StreamEdit: + """The changes that user_nl_mom makes to one stream, each given on a line of the + form diag_table%% , where the key is one of: + + - fields: with +=, the fields to add; with -=, the fields to remove, by output + name; with =, the fields to replace all fields of the stream with, or none. + - module: the diagnostics module of the fields to add, if the stream does not + have exactly one module already, e.g., if the stream is new. + - a stream setting (see STREAM_SETTINGS), with =. + + Fields are given as in the diag_table template, i.e., as + name[:output_name[:reduction]], separated by commas. The settings are to be + applied as if given in the template entry of the stream, so that the settings + derived from them follow: e.g., a stream changed to daily output also starts a + new file daily, unless its new_file_freq_units is changed too. The field changes + are applied in order, once the stream is built. + + Example + ------- + >>> edit = StreamEdit("h.sfc") + >>> edit.add("fields", "+=", "SSH, tos:tos_max:max") + >>> edit.add("fields", "-=", "sos") + >>> edit.add("output_freq", "=", "5") + >>> edit.settings + {'output_freq': 5} + >>> group = FieldGroup("ocean_model", [Field("tos"), Field("sos")]) + >>> stream = Stream("h.sfc", groups=[group]) + >>> edit.apply_fields(stream) + >>> stream.output_names() + ['tos', 'SSH', 'tos_max'] + """ + + def __init__(self, name): + self.name = name + self.settings = {} + self.module = None + self.field_changes = [] # (operator, fields, line), in the order given + + def add(self, key, operator, value): + """Records one diag_table%% change.""" + line = "diag_table%{}%{} {} {}".format(self.name, key, operator, value) + try: + if key != "fields" and key != "module" and key not in STREAM_SETTINGS: + raise DiagTableError( + "{} is not one of: fields, module, {}".format( + key, ", ".join(STREAM_SETTINGS) + ) + ) + operators = ("=", "+=", "-=") if key == "fields" else ("=",) + if operator not in operators: + raise DiagTableError( + "{} may only be given with {}".format(key, " or ".join(operators)) + ) + if key == "fields": + specs = [spec.strip().strip("'\"") for spec in value.split(",")] + if operator == "=" and specs == ["none"]: + fields = [] + else: + fields = [Field(spec) for spec in specs] + self.field_changes.append((operator, fields, line)) + return + value = value.strip().strip("'\"") + if key in self.settings or (key == "module" and self.module): + raise DiagTableError("{} is given more than once".format(key)) + if key == "module": + if value not in MODULES: + raise DiagTableError( + 'the module "{}" is not one of: {}'.format( + value, ", ".join(MODULES) + ) + ) + self.module = value + elif key in _INT_SETTINGS: + if not re.match(r"^-?\d+$", value): + raise DiagTableError('"{}" is not a whole number'.format(value)) + self.settings[key] = int(value) + else: + self.settings[key] = value + except DiagTableError as error: + raise DiagTableError( + "Cannot apply {} from user_nl_mom: {}.".format( + line, str(error).rstrip(".") + ) + ) from error + + def check_new(self, stream_names): + """Checks that this edit may add its stream, which is not among the streams + of the case, i.e., stream_names.""" + problem = None + if not _NEW_STREAM_NAME.match(self.name): + problem = "the names of new streams must be of the form h." + elif self.module is None: + problem = ( + "adding a stream requires the module of its fields, given as " + "diag_table%{}%module = ".format( + self.name, ", ".join(MODULES) + ) + ) + if problem: + raise DiagTableError( + 'Cannot add the stream "{}" via user_nl_mom: {}. (If you meant to ' + "change an existing stream, the streams of this case are: " + "{}.)".format(self.name, problem, ", ".join(stream_names)) + ) + + def apply_fields(self, stream): + """Applies the field changes to the stream that this edit is for.""" + stream.edited = True + for operator, fields, line in self.field_changes: + try: + if operator == "-=": + for field in fields: + if field.output_name not in stream.output_names(): + raise DiagTableError( + 'it has no field "{}"'.format(field.output_name) + ) + for group in stream.groups: + group.fields = [ + f + for f in group.fields + if f.output_name != field.output_name + ] + continue + if operator == "=": + module = self._module(stream) if fields else None + stream.groups = [] + if not fields: + continue + else: + module = self._module(stream) + group = stream.group_for(module) + for field in fields: + if field.output_name in stream.output_names(): + raise DiagTableError( + 'it already has a field "{}"'.format(field.output_name) + ) + group.fields.append(field) + except DiagTableError as error: + raise DiagTableError( + "Cannot apply {} from user_nl_mom to {}: {}.".format( + line, stream.source, str(error).rstrip(".") + ) + ) from error + + def _module(self, stream): + """Returns the diagnostics module of the fields to add to the stream.""" + if self.module: + return self.module + modules = sorted({group.module for group in stream.groups}) + if len(modules) != 1: + raise DiagTableError( + "{}, so give the module of the fields to add as " + "diag_table%{}%module = ".format( + ( + "it has fields from " + ", ".join(modules) + if modules + else "it has no fields" + ), + self.name, + ", ".join(MODULES), + ) + ) + return modules[0] + + def write_diag_table(streams, output_path): """Writes a diag_table for the given streams. From 5235e72de5d82421efd383c07a6c732354e447b8 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Wed, 7 Oct 2026 10:06:59 -0600 Subject: [PATCH 11/12] Allow changing the diag_table via diag_table%% lines in user_nl_mom --- cime_config/MOM_RPS/FType_diag_table.py | 27 +++++++++++--- cime_config/MOM_RPS/param_file_reader.py | 45 ++++++++++++++++++++++-- cime_config/buildnml | 27 +++++++++++--- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/cime_config/MOM_RPS/FType_diag_table.py b/cime_config/MOM_RPS/FType_diag_table.py index 0c607ee..42a0876 100644 --- a/cime_config/MOM_RPS/FType_diag_table.py +++ b/cime_config/MOM_RPS/FType_diag_table.py @@ -11,6 +11,7 @@ int_setting, REQUIRED_SETTINGS, Stream, + StreamEdit, STREAM_SETTINGS, write_diag_table, ) @@ -41,7 +42,7 @@ def resolve(unresolved_diag_table_path, resolved_diag_table_path, casename): for line in diag_table_unresolved: resolved_diag_table.write(line.replace(CASENAME, casename)) - def write(self, output_path, case, MOM_input_final): + def write(self, output_path, case, MOM_input_final, changes=()): """Writes out the diag_table of a case. Parameters @@ -55,6 +56,8 @@ def write(self, output_path, case, MOM_input_final): The MOM6 parameters of the case, i.e., MOM_input updated with MOM_override. Consulted for expandable variables that are MOM6 parameters rather than case variables. + changes : list of (stream, key, operator, value), optional + The diag_table lines of user_nl_mom (see diag_table_streams.StreamEdit). """ def expand_func(varname): @@ -69,13 +72,20 @@ def expand_func(varname): ) return val + edits = OrderedDict() # the changes from user_nl_mom, by stream + for stream, key, operator, value in changes: + if stream not in edits: + edits[stream] = StreamEdit(stream) + edits[stream].add(key, operator, value) + # From the general template (diag_table.yaml), reduce a custom diag_table # for this case, and turn its file entries into streams. self.reduce(expand_func) - write_diag_table(list(self._streams().values()), output_path) + write_diag_table(list(self._streams(edits).values()), output_path) - def _streams(self): - """Returns the streams of this case, keyed and ordered by stream name.""" + def _streams(self, edits): + """Returns the streams of this case, as changed by the StreamEdits of + user_nl_mom, keyed and ordered by stream name.""" assert self.reduced, "May only collect streams from a reduced diag_table." defaults = self.data.get("FileDefaults") or {} _check_defaults(defaults) @@ -86,11 +96,20 @@ def _streams(self): ) streams = OrderedDict() for label, entry in self.data["Files"].items(): + edit = edits.get(entry.get("name") or label) + if edit: # the settings of an edit apply as if given in the entry + entry = dict(entry, **edit.settings) stream = _stream_from_entry(label, entry, defaults) if stream.name in streams: streams[stream.name].merge(stream) else: streams[stream.name] = stream + for name, edit in edits.items(): + if name not in streams: # a stream that user_nl_mom adds + edit.check_new(list(streams)) + streams[name] = _stream_from_entry(name, edit.settings, defaults) + streams[name].labels = [] + edit.apply_fields(streams[name]) return streams diff --git a/cime_config/MOM_RPS/param_file_reader.py b/cime_config/MOM_RPS/param_file_reader.py index bcbaeea..238ad56 100644 --- a/cime_config/MOM_RPS/param_file_reader.py +++ b/cime_config/MOM_RPS/param_file_reader.py @@ -9,8 +9,35 @@ def read_MOM_params(input_path): """Reads in a parameter file in MOM_input syntax, e.g., MOM_input or MOM_override.""" + return _read(input_path, user_nl=False)[0] + + +def read_user_nl_mom(input_path): + """Reads in a user_nl_mom file, which holds changes to MOM6 parameters, in + MOM_input syntax, and changes to the diag_table, given on lines of the form + + diag_table%% + + (see diag_table_streams.StreamEdit). Returns the parameters, as read_MOM_params + does, and the diag_table changes, as a list of (stream, key, operator, value). + """ + return _read(input_path, user_nl=True) + + +# A diag_table line of user_nl_mom, e.g., diag_table%h.native%fields += SSH +_DIAG_TABLE_LINE = re.compile( + r"^diag_table%(?P[^%\s]+)%(?P\w+)\s*(?P\+=|-=|=)\s*" + r"(?P\S.*)$" +) + + +def _read(input_path, user_nl): + """Reads in a parameter file, i.e., MOM_input, MOM_override or, if user_nl, + user_nl_mom, which may also hold diag_table lines. Returns the parameters and + the diag_table changes.""" _data = OrderedDict() + changes = [] # the diag_table changes with open(input_path, "r") as param_file: within_comment_block = False curr_module = "Global" @@ -29,7 +56,21 @@ def read_MOM_params(input_path): line = " ".join(line_s) # check format: - if line[0] == "%": # closes a parameter block, by name or not + if user_nl and line.startswith("diag_table%"): # changes the diag_table + if curr_module != "Global": + raise SystemExit( + f"ERROR: {line} in {input_path} is within the parameter " + f"block {curr_module}%, but diag_table lines may not be." + ) + match = _DIAG_TABLE_LINE.match(line) + if not match: + raise SystemExit( + f"ERROR: {line} in {input_path} is not of the form " + "diag_table%% , where the " + "operator is =, += or -=." + ) + changes.append(match.group("stream", "key", "operator", "value")) + elif line[0] == "%": # closes a parameter block, by name or not if curr_module == "Global": raise SystemExit( f"ERROR: {line} in {input_path} closes a parameter block " @@ -112,4 +153,4 @@ def read_MOM_params(input_path): f"{input_path}." ) - return _data + return _data, changes diff --git a/cime_config/buildnml b/cime_config/buildnml index e16d9b9..6f61ef1 100755 --- a/cime_config/buildnml +++ b/cime_config/buildnml @@ -32,6 +32,7 @@ from FType_MOM_params import FType_MOM_params from FType_input_nml import FType_input_nml from FType_input_data_list import FType_input_data_list from FType_diag_table import FType_diag_table +from param_file_reader import read_user_nl_mom from MARBL_settings import MARBL_settings_for_MOM from MARBL_diagnostics import MARBL_diagnostics_for_MOM from MOM_MARBL_diagnostics import write_MARBL_diagnostics_file, get_2D_vars_from_MARBL_diagnostics @@ -99,10 +100,11 @@ def prep_input(case, inst_suffixes): ) # 2. Create MOM_override: + diag_table_changes_by_inst = [] # the diag_table lines of each user_nl_mom for inst_suffix in inst_suffixes: - user_nl_mom = FType_MOM_params.from_MOM_input( - caseroot / f"user_nl_mom{inst_suffix}" - ) + params, changes = read_user_nl_mom(caseroot / f"user_nl_mom{inst_suffix}") + user_nl_mom = FType_MOM_params(params) + diag_table_changes_by_inst.append(changes) if f"MOM_override{inst_suffix}" in SourceMods_listdir: expect( user_nl_mom.is_empty, @@ -183,7 +185,19 @@ def prep_input(case, inst_suffixes): ocn_grid, run_type, continue_run, marbl_config) MARBL_settings.write_settings_file(rundir / "marbl_in") - # 7. Create diag_table: + # 7. Create diag_table, which all instances share, and so must change alike: + expect( + all( + changes == diag_table_changes_by_inst[0] + for changes in diag_table_changes_by_inst + ), + "All instances share one diag_table, so the diag_table lines in all user_nl_mom files must be the same!", + ) + if "diag_table" in SourceMods_listdir or "diag_table.unresolved" in SourceMods_listdir: + expect( + not diag_table_changes_by_inst[0], + "Cannot provide diag_table changes via both SourceMods and user_nl_mom!", + ) unresolved_diag_table_confdir = momconfdir / "diag_table.unresolved" if "diag_table" in SourceMods_listdir: # A resolved diag_table is provided in SourceMods. Directly copy it to rundir. @@ -235,7 +249,10 @@ def prep_input(case, inst_suffixes): unresolved_diag_table.append(FType_diag_table.from_json(MARBL_diag_table_json)) unresolved_diag_table.write( - unresolved_diag_table_confdir, case, MOM_input_final + unresolved_diag_table_confdir, + case, + MOM_input_final, + diag_table_changes_by_inst[0], ) # Resolve unresolved diag_table in momconf and write it to rundir FType_diag_table.resolve( From c08b500151432148724db6a2736c537dbeedabf5 Mon Sep 17 00:00:00 2001 From: alperaltuntas Date: Wed, 7 Oct 2026 10:25:03 -0600 Subject: [PATCH 12/12] Document full-path parameters and diag_table changes in user_nl_mom --- cime_config/config_component.xml | 5 +++-- cime_config/user_nl_mom | 27 +++++++++++++++++++++++---- param_templates/diag_table.yaml | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/cime_config/config_component.xml b/cime_config/config_component.xml index 97b5ee6..95e16e4 100644 --- a/cime_config/config_component.xml +++ b/cime_config/config_component.xml @@ -115,8 +115,9 @@ This variable is used to choose from a set of predefined ocean diagnostics configurations. Each configuration corresponds to a different combination of frequency, precision, and field lists. Configuration ordered in increasing scope/frequency: "spinup", "production", "development". - Further customization of diagnostics may be done by copying an edited version of - diag_table file in SourceMods/src.mom/ + Further customization of diagnostics may be done via diag_table lines in user_nl_mom + (see the comments in user_nl_mom), or by copying an edited version of diag_table file + in SourceMods/src.mom/ diff --git a/cime_config/user_nl_mom b/cime_config/user_nl_mom index ac694d7..8bfdb2d 100644 --- a/cime_config/user_nl_mom +++ b/cime_config/user_nl_mom @@ -3,19 +3,38 @@ ! these comments as follows: ! parameter_name = new_parameter_value ! -! If the parameter belongs to a module, enter the parameter in MOM6 -! runtime parameter format ,e.g., change N_SMOOTH parameter within the -! KPP module as follows: +! If the parameter belongs to a parameter block, enter it within the +! block, or prefixed with the block name, e.g., change N_SMOOTH within +! the KPP block as follows: ! KPP% ! N_SMOOTH = 3 ! %KPP +! or: +! KPP%N_SMOOTH = 3 ! ! Note: The parameter changes added to this file will automatically be ! applied to: ! $RUNDIR/MOM_override ! +! The diag_table may be changed via lines of the form +! diag_table%% +! where is the name of an output file, e.g., h.native for the +! files ${CASE}.mom6.h.native.*.nc, and is fields, module, or a +! file setting (see param_templates/diag_table.yaml), e.g.: +! diag_table%h.native%fields += MLD_0125, Kd_heat:Kd_heat_max:max +! diag_table%h.native%fields -= KE +! diag_table%h.sfc%output_freq = 5 +! diag_table%h.z%fields = none +! Fields are given as name[:output_name[:reduction]], and are removed +! by output name. A new stream also needs the diagnostics module of its +! fields (ocean_model, ocean_model_z or ocean_model_rho2), e.g.: +! diag_table%h.mine%module = ocean_model_z +! diag_table%h.mine%fields = thetao, so +! ! WARNING: If a MOM_override file is placed in SourceMods/src.mom/, -! any additional parameter changes in this file will trigger an error. +! any additional parameter changes in this file will trigger an error, +! and so will diag_table changes if a diag_table (or +! diag_table.unresolved) file is placed there. ! ! Parameters: !--------------------------------------------------------------------- diff --git a/param_templates/diag_table.yaml b/param_templates/diag_table.yaml index e80b905..ed4c14d 100644 --- a/param_templates/diag_table.yaml +++ b/param_templates/diag_table.yaml @@ -106,7 +106,7 @@ FileDefaults: # The files, i.e., the output streams, to be added to the diag_table. The # label of each entry is the name of the stream: it ends up in the file name # as ${CASE}.mom6.