From 1d3f60017d42b3cc3db452c999598ef9f46c26c5 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 14:18:15 -0600 Subject: [PATCH 01/13] CLI + YAML Configuration --- CrocoDash/case.py | 22 ++ CrocoDash/cli.py | 81 +++--- CrocoDash/rm6 | 2 +- CrocoDash/shareable/bundle.py | 157 +++++------ CrocoDash/shareable/fork.py | 306 ++++++++++------------ CrocoDash/visualCaseGen | 2 +- CrocoDash/workflow.py | 261 ++++++++++++++++++ demos | 2 +- docs/source/for_users/case_information.md | 21 ++ docs/source/for_users/cli.md | 118 +++++++++ docs/source/for_users/index.md | 1 + docs/source/for_users/shareable.md | 57 ++-- tests/shareable/test_bundle.py | 50 ++-- tests/shareable/test_cli.py | 13 - tests/shareable/test_fork.py | 166 +++--------- 15 files changed, 770 insertions(+), 489 deletions(-) create mode 100644 CrocoDash/workflow.py create mode 100644 docs/source/for_users/cli.md diff --git a/CrocoDash/case.py b/CrocoDash/case.py index a92c551d..b0329ce8 100644 --- a/CrocoDash/case.py +++ b/CrocoDash/case.py @@ -125,11 +125,13 @@ def __init__( ) # Set instance attributes + self.cesmroot = Path(cesmroot) self.caseroot = Path(caseroot) self.inputdir = Path(inputdir) self.ocn_grid = ocn_grid self.ocn_topo = ocn_topo self.ocn_vgrid = ocn_vgrid + self.atm_grid_name = atm_grid_name self.ninst = ninst self.override = override self.ProductRegistry = ProductRegistry @@ -165,6 +167,8 @@ def __init__( self._apply_final_xmlchanges(ntasks_ocn, job_queue, job_wallclock_time) + self._write_state() + required_configurators = ForcingConfigRegistry.find_required_configurators( self.compset_lname ) @@ -872,6 +876,24 @@ def _configure_launch(self): # Variables that are not included in a stage: cvars["NINST"].value = self.ninst + def _write_state(self): + """Write case creation parameters to crocodash_state.json in caseroot.""" + state = { + "inputdir": str(self.inputdir), + "cesmroot": str(self.cesmroot), + "supergrid_path": self.supergrid_path, + "topo_path": self.topo_path, + "vgrid_path": self.vgrid_path, + "grid_name": self.ocn_grid.name, + "session_id": cvars["MB_ATTEMPT_ID"].value, + "compset_lname": self.compset_lname, + "machine": self.machine, + "project": self.project, + "atm_grid_name": self.atm_grid_name, + } + with open(self.caseroot / "crocodash_state.json", "w") as f: + json.dump(state, f, indent=2) + def _apply_final_xmlchanges( self, ntasks_ocn=None, job_queue=None, job_wallclock_time=None ): diff --git a/CrocoDash/cli.py b/CrocoDash/cli.py index 798657a8..e4245fb8 100644 --- a/CrocoDash/cli.py +++ b/CrocoDash/cli.py @@ -1,5 +1,21 @@ import argparse import json +import sys + + +def _create(args): + from CrocoDash.workflow import load_config, create_case_from_yaml + + config = load_config(args.config) + create_case_from_yaml(config, override=args.override) + + +def _dump(args): + from CrocoDash.workflow import case_to_yaml + import yaml + + config = case_to_yaml(args.caseroot) + yaml.dump(config, sys.stdout, default_flow_style=False, sort_keys=False) def _bundle(args): @@ -30,20 +46,9 @@ def _duplicate_case(args): def _fork(args): - from CrocoDash.shareable.fork import ForkCrocoDashBundle plan = json.loads(args.plan) if args.plan else None - extra_configs = ( - [x.strip() for x in args.extra_configs.split(",") if x.strip()] - if args.extra_configs - else None - ) - remove_configs = ( - [x.strip() for x in args.remove_configs.split(",") if x.strip()] - if args.remove_configs - else None - ) forker = ForkCrocoDashBundle(args.bundle) forker.fork( @@ -53,10 +58,6 @@ def _fork(args): new_caseroot=args.caseroot, new_inputdir=args.inputdir, plan=plan, - compset=args.compset, - extra_configs=extra_configs, - remove_configs=remove_configs, - extra_forcing_args_path=args.extra_forcing_args, ) @@ -64,6 +65,32 @@ def main(): parser = argparse.ArgumentParser(prog="crocodash") subparsers = parser.add_subparsers(dest="command", required=True) + # --- create --- + create_parser = subparsers.add_parser( + "create", + help="Create a new CrocoDash case from a YAML config file.", + ) + create_parser.add_argument( + "--config", required=True, help="Path to the YAML case config file." + ) + create_parser.add_argument( + "--override", + action="store_true", + default=False, + help="Overwrite existing caseroot and inputdir if they exist.", + ) + create_parser.set_defaults(func=_create) + + # --- dump --- + dump_parser = subparsers.add_parser( + "dump", + help="Print a YAML representation of an existing CrocoDash case to stdout.", + ) + dump_parser.add_argument( + "--caseroot", required=True, help="Path to the existing CESM caseroot." + ) + dump_parser.set_defaults(func=_dump) + # --- bundle --- bundle_parser = subparsers.add_parser( "bundle", @@ -136,32 +163,10 @@ def main(): "--machine", required=True, help="Machine name (e.g. derecho)." ) fork_parser.add_argument("--project", required=True, help="Project/account number.") - # optional bypass flags - fork_parser.add_argument( - "--compset", default=None, help="Override the compset from the bundle." - ) fork_parser.add_argument( "--plan", default=None, - help='JSON object controlling what to copy, e.g. \'{"xml_files": true, "user_nl": true, "source_mods": false, "xmlchanges": true}\'.', - ) - fork_parser.add_argument( - "--extra-configs", - default=None, - dest="extra_configs", - help="Comma-separated forcing configs to add.", - ) - fork_parser.add_argument( - "--remove-configs", - default=None, - dest="remove_configs", - help="Comma-separated forcing configs to drop.", - ) - fork_parser.add_argument( - "--extra-forcing-args", - default=None, - dest="extra_forcing_args", - help="Path to JSON file with extra forcing arguments.", + help='JSON object controlling what non-standard CESM state to copy, e.g. \'{"xml_files": true, "user_nl": true, "source_mods": false, "xmlchanges": true}\'.', ) fork_parser.set_defaults(func=_fork) diff --git a/CrocoDash/rm6 b/CrocoDash/rm6 index 78f768d1..d8197ce0 160000 --- a/CrocoDash/rm6 +++ b/CrocoDash/rm6 @@ -1 +1 @@ -Subproject commit 78f768d1626451d595469124a86c607dc0dcf3b7 +Subproject commit d8197ce0bad05d8ea08d6e7d4a7453ccb8551945 diff --git a/CrocoDash/shareable/bundle.py b/CrocoDash/shareable/bundle.py index f82bedea..5cad7a6a 100644 --- a/CrocoDash/shareable/bundle.py +++ b/CrocoDash/shareable/bundle.py @@ -1,7 +1,3 @@ -""" -Bundle is inordinately hard-coded, and probably can't be changed. Robust testing is needed to ensure we are picking up the correct information -""" - from pathlib import Path import dataclasses import json @@ -27,6 +23,7 @@ import importlib import sys import shutil +import yaml logger = setup_logger(__name__) @@ -45,16 +42,14 @@ def __init__(self, caseroot): self._get_case_machine() self._get_case_project() self._read_user_nls() - self._identify_CrocoDashCase_init_args() - self._identify_CrocoDashCase_forcing_config_args() + self._load_state_from_crocodash() self._read_xmlchanges() self._read_xmlfiles() self._read_sourcemods() def reread(self): self._read_user_nls() - self._identify_CrocoDashCase_init_args() - self._identify_CrocoDashCase_forcing_config_args() + self._load_state_from_crocodash() self._read_xmlchanges() self._read_xmlfiles() self._read_sourcemods() @@ -65,19 +60,6 @@ def case(self): self._case = get_case_obj(self.caseroot) return self._case - def generate_manifest(self) -> BundleManifest: - return BundleManifest( - paths={ - "casefiles": str(self.caseroot), - "inputfiles": self.init_args["inputdir_ocnice"], - }, - user_nl_info=self.user_nl_objs, - init_args=self.init_args, - forcing_config=self.forcing_config, - sourcemods=[str(f) for f in self.sourcemods], - xmlchanges=self.xmlchanges, - ) - def _read_xmlchanges(self): replay_path = self.caseroot / "replay.sh" self.xmlchanges = {} @@ -131,38 +113,39 @@ def _read_sourcemods(self): if f.is_file() } - def _identify_CrocoDashCase_init_args(self): + def _load_state_from_crocodash(self): + """Load case parameters from crocodash_state.json and extract_forcings/config.json.""" + from CrocoDash.workflow import case_to_yaml - logger.info(f"Finding initialization arguments from {self.caseroot}") + logger.info(f"Loading CrocoDash state from {self.caseroot}") + self.case_yaml = case_to_yaml(self.caseroot) - inputdir_ocnice = self.get_user_nl_value("mom", "INPUTDIR") + # Populate init_args in the legacy format for identify_non_standard / fork compatibility + state_path = self.caseroot / "crocodash_state.json" + with open(state_path) as f: + state = json.load(f) + inputdir_ocnice = str(Path(state["inputdir"]) / "ocnice") esmf_file = next(Path(inputdir_ocnice).glob("ESMF_mesh_*.nc"), None) self.init_args = { "inputdir_ocnice": inputdir_ocnice, - "supergrid_path": self.get_user_nl_value("mom", "GRID_FILE"), - "vgrid_path": self.get_user_nl_value("mom", "ALE_COORDINATE_CONFIG"), - "topo_path": self.get_user_nl_value("mom", "TOPO_FILE"), + "supergrid_path": Path(state["supergrid_path"]).name, + "vgrid_path": Path(state["vgrid_path"]).name, + "topo_path": Path(state["topo_path"]).name, "esmf_mesh_path": esmf_file.name if esmf_file else None, - "compset": self.case.get_value("COMPSET"), - "atm_grid_name": self.case.get_value("ATM_GRID"), + "compset": state["compset_lname"], + "atm_grid_name": state.get("atm_grid_name", "TL319"), } - return self.init_args - - def _identify_CrocoDashCase_forcing_config_args(self): - - logger.info(f"Loading forcing configuration from {self.caseroot}") - # The input directory is where the forcing config is. - - # Find the input directory - inputdir = self.get_user_nl_value("mom", "INPUTDIR") - - # Read in forcing config file - forcing_config_path = Path(inputdir).parent / "extract_forcings" / "config.json" + forcing_config_path = ( + Path(state["inputdir"]) / "extract_forcings" / "config.json" + ) + if forcing_config_path.exists(): + with open(forcing_config_path) as f: + self.forcing_config = json.load(f) + else: + self.forcing_config = {} - with open(forcing_config_path, "r") as f: - self.forcing_config = json.load(f) - return self.forcing_config + return self.init_args def get_user_nl_value(self, component, param): return ( @@ -327,12 +310,10 @@ def bundle(self, output_folder_location, machine=None, project=None): logger.info(f"Copying grid file: {src}") shutil.copy(src, ocnice_target / src.name) - # Write out manifest - logger.info(f"Writing out BundleCrocoDashCase manifest...") - with open(case_subfolder / "manifest.json", "w") as f: - json.dump( - dataclasses.asdict(self.generate_manifest()), f, indent=2, default=str - ) + # Write YAML (replaces manifest.json — init_args + forcing_config in human-readable form) + logger.info("Writing out crocodash_case.yaml...") + with open(case_subfolder / "crocodash_case.yaml", "w") as f: + yaml.dump(self.case_yaml, f, default_flow_style=False, sort_keys=False) # Write out differences logger.info(f"Writing out non standard CrocoDash information...") @@ -374,7 +355,7 @@ def duplicate_case(self, new_caseroot, new_inputdir, bundle_dir=None): def duplicate_case(caseroot, new_caseroot, new_inputdir, bundle_dir=None): """ Duplicate a CrocoDash case to a new location. Machine, project, and cesmroot - are read automatically from the original caseroot. + are read automatically from the original caseroot's crocodash_state.json. Parameters ---------- @@ -385,37 +366,57 @@ def duplicate_case(caseroot, new_caseroot, new_inputdir, bundle_dir=None): new_inputdir : str or Path Path for the new input directory. bundle_dir : str or Path, optional - Where to write the intermediate bundle. Defaults to inside - new_caseroot and is cleaned up automatically. + Where to copy the bundle for reference. If None, no bundle is saved. """ + from CrocoDash.workflow import case_to_yaml, create_case_from_yaml + from CrocoDash.shareable.apply import ( + copy_xml_files_from_case, + copy_user_nl_params_from_case, + copy_source_mods_from_case, + apply_xmlchanges_to_case, + copy_configurations_to_case, + ) + rcc = BundleCrocoDashCase(caseroot) + rcc.identify_non_standard_CrocoDash_case_information( + rcc.cesmroot, rcc.case_machine, rcc.case_project + ) + + # Patch paths in the YAML for the new location + config = rcc.case_yaml.copy() + config["case"] = config["case"].copy() + config["case"]["caseroot"] = str(new_caseroot) + config["case"]["inputdir"] = str(new_inputdir) + + result = create_case_from_yaml(config, override=True) - plan = { - "xml_files": True, - "user_nl": True, - "source_mods": True, - "xmlchanges": True, - } - - with tempfile.TemporaryDirectory() as tmp: - loc = rcc.bundle(tmp) - fcb = ForkCrocoDashBundle(loc) - result = fcb.fork( - rcc.cesmroot, - rcc.case_machine, - rcc.case_project, - new_caseroot, - new_inputdir, - plan=plan, - compset=rcc.init_args["compset"], - extra_configs=[], - remove_configs=[], + # Copy all non-standard CESM state (full plan) + if rcc.non_standard_case_info.xml_files_missing_in_new: + copy_xml_files_from_case( + rcc.caseroot, + result.caseroot, + rcc.non_standard_case_info.xml_files_missing_in_new, ) - dest = Path(new_caseroot) / loc.name - if bundle_dir is None: - shutil.copytree(loc, dest) - else: - shutil.copytree(loc, Path(bundle_dir) / loc.name) + if rcc.non_standard_case_info.user_nl_missing_params and any( + rcc.non_standard_case_info.user_nl_missing_params.values() + ): + copy_user_nl_params_from_case( + rcc.caseroot, rcc.non_standard_case_info.user_nl_missing_params + ) + if rcc.non_standard_case_info.source_mods_missing_files: + copy_source_mods_from_case( + rcc.caseroot, + result.caseroot, + rcc.non_standard_case_info.source_mods_missing_files, + ) + if rcc.non_standard_case_info.xmlchanges_missing: + apply_xmlchanges_to_case( + rcc.caseroot, rcc.non_standard_case_info.xmlchanges_missing + ) + + # Optionally save a bundle alongside the new case + if bundle_dir is not None: + rcc.bundle(bundle_dir) return result diff --git a/CrocoDash/shareable/fork.py b/CrocoDash/shareable/fork.py index 90062fe4..a718e61e 100644 --- a/CrocoDash/shareable/fork.py +++ b/CrocoDash/shareable/fork.py @@ -10,6 +10,7 @@ from CrocoDash.vgrid import VGrid from CrocoDash.topo import Topo import xarray as xr +import yaml from CrocoDash.logging import setup_logger logger = setup_logger(__name__) @@ -43,13 +44,30 @@ class ForkCrocoDashBundle: def __init__(self, bundle_location): self.bundle_location = Path(bundle_location) - json_file = self.bundle_location / "manifest.json" - assert json_file.exists() - with open(json_file) as f: - self.manifest = BundleManifest(**json.load(f)) + yaml_file = self.bundle_location / "crocodash_case.yaml" + assert yaml_file.exists(), f"Bundle is missing crocodash_case.yaml: {yaml_file}" + with open(yaml_file) as f: + self.bundle_yaml = yaml.safe_load(f) + + # Populate a minimal manifest for backwards-compatible apply_copy_plan usage + state = self.bundle_yaml + case_cfg = state.get("case", {}) + inputdir_ocnice = str( + Path(state.get("grid", {}).get("supergrid_path", "")).parent + ) + self.manifest = BundleManifest( + forcing_config={}, + init_args={ + "inputdir_ocnice": inputdir_ocnice, + "compset": case_cfg.get("compset", ""), + "atm_grid_name": case_cfg.get("atm_grid_name", "TL319"), + }, + ) json_file = self.bundle_location / "non_standard_case_info.json" - assert json_file.exists() + assert ( + json_file.exists() + ), f"Bundle is missing non_standard_case_info.json: {json_file}" with open(json_file) as f: self.differences = BundleDifferences(**json.load(f)) @@ -86,94 +104,155 @@ def fork( new_caseroot, new_inputdir, plan=None, - compset=None, - extra_configs=None, - remove_configs=None, - extra_forcing_args_path=None, ): """ - Share a CESM case by inspecting an existing bundle, optionally copying - non-standard components, resolving forcing configurations, and creating - a new case with equivalent forcings. + Create a new case from a bundle, guiding the user through YAML modifications. + + Prompts the user to update destination paths and machine settings, then + optionally opens $EDITOR for deeper edits. After confirmation, creates the + case and copies non-standard CESM state per the plan. Parameters ---------- cesmroot : str or Path - Path to the CESM root. + Path to the CESM root for the new case. machine : str - Machine name. + Machine name for the new case. project_number : str - Project/account number. + Project/account number for the new case. new_caseroot : str or Path Path for the new case root. new_inputdir : str or Path - Path for input data. + Path for the new input directory. plan : dict, optional Which non-standard items to copy, e.g. ``{"xml_files": True, "user_nl": False, "source_mods": True, "xmlchanges": True}``. When omitted the user is asked interactively. - compset : str, optional - Override the compset from the bundle. When omitted the user is asked interactively. - extra_configs : list, optional - Additional forcing configuration names to add beyond the bundle. - remove_configs : list, optional - Forcing configuration names from the bundle to drop. - extra_forcing_args_path : str or Path, optional - Path to a JSON file supplying arguments for any new forcing configs. """ + from CrocoDash.workflow import create_case_from_yaml - # Phase 1: gather all decisions (prompting interactively where params are None) - self._gather_inputs( - plan, compset, extra_configs, remove_configs, extra_forcing_args_path + # Phase 1: build patched YAML with new destination values + config = self._patch_yaml_for_fork( + cesmroot, machine, project_number, new_caseroot, new_inputdir ) - # Phase 2: pure execution — no prompts below this point - logger.info("Creating new case...") - self.manifest.init_args["inputdir_ocnice"] = str( - self.bundle_location / "ocnice" - ) - self.case = create_case( - self.manifest.init_args, - new_caseroot, - new_inputdir, - compset=self.compset, - machine=machine, - project_number=project_number, - cesmroot=cesmroot, - ) + # Phase 2: guided YAML review — prompt for each key field, offer editor + config = self._guide_yaml_review(config) + + # Phase 3: resolve which non-standard CESM items to copy + self._resolve_copy_plan(plan) + + # Phase 4: create the case + logger.info("Creating new case from YAML...") + self.case = create_case_from_yaml(config, override=True) - logger.info("Copying exact grid files from bundle...") + # Phase 5: copy bundle ocnice files then apply non-standard CESM state + logger.info("Copying forcing files from bundle...") bundle_ocnice = self.bundle_location / "ocnice" - for key in ("supergrid_path", "topo_path", "vgrid_path", "esmf_mesh_path"): - src_name = self.manifest.init_args.get(key) - dst = getattr(self.case, key, None) - if src_name and dst is not None: - src = bundle_ocnice / src_name - if src.exists(): - shutil.copy(src, dst) - - logger.info("Building configuration args") - self.case.configure_forcings(**self.configure_forcing_args) - - logger.info("Copying items to new case based on user input") + for src in bundle_ocnice.iterdir(): + dst = Path(self.case.inputdir) / "ocnice" / src.name + if not dst.exists(): + shutil.copy(src, dst) + + logger.info("Applying non-standard CESM state per plan...") self.apply_copy_plan() self.case.validate_case() print( - "\nYou're ready! If you requested any additional forcings, remember to " - "run them with your extract_forcings driver script." + "\nYou're ready! Remember to run the extract_forcings driver to " + "regenerate any forcing files for the new domain." ) return self.case - def _gather_inputs( - self, plan, compset, extra_configs, remove_configs, extra_forcing_args_path + def _patch_yaml_for_fork( + self, cesmroot, machine, project_number, new_caseroot, new_inputdir ): - """Gather all decisions before execution, prompting interactively where params are None.""" - self._resolve_copy_plan(plan) - self._resolve_compset(compset) - self._resolve_forcing_configurations(extra_configs, remove_configs) - self._resolve_forcing_args(extra_forcing_args_path) + """Return a copy of bundle_yaml with destination fields patched.""" + import copy + + config = copy.deepcopy(self.bundle_yaml) + config["case"]["cesmroot"] = str(cesmroot) + config["case"]["machine"] = machine + config["case"]["project"] = project_number + config["case"]["caseroot"] = str(new_caseroot) + config["case"]["inputdir"] = str(new_inputdir) + # Point grid/topo/vgrid at bundle ocnice copies + bundle_ocnice = str(self.bundle_location / "ocnice") + if "supergrid_path" in config.get("grid", {}): + config["grid"]["supergrid_path"] = str( + self.bundle_location + / "ocnice" + / Path(config["grid"]["supergrid_path"]).name + ) + if config.get("topo", {}).get("source", {}).get("type") == "from_file": + config["topo"]["source"]["topo_file_path"] = str( + self.bundle_location + / "ocnice" + / Path(config["topo"]["source"]["topo_file_path"]).name + ) + if config.get("vgrid", {}).get("type") == "from_file": + config["vgrid"]["filename"] = str( + self.bundle_location / "ocnice" / Path(config["vgrid"]["filename"]).name + ) + return config + + def _guide_yaml_review(self, config): + """Walk the user through key YAML fields and offer $EDITOR for deeper edits.""" + import copy + import os + import subprocess + import tempfile + + print("\n=== Fork: Review Case Configuration ===") + print( + "The following fields have been pre-filled. Press Enter to keep each value.\n" + ) + + fields = [ + ("case.caseroot", ["case", "caseroot"]), + ("case.inputdir", ["case", "inputdir"]), + ("case.cesmroot", ["case", "cesmroot"]), + ("case.machine", ["case", "machine"]), + ("case.project", ["case", "project"]), + ("case.compset", ["case", "compset"]), + ] + if "forcings" in config: + fields += [ + ("forcings.date_range", ["forcings", "date_range"]), + ("forcings.boundaries", ["forcings", "boundaries"]), + ] + + for label, keys in fields: + obj = config + for k in keys[:-1]: + obj = obj[k] + current = obj[keys[-1]] + response = ask_string(f" {label} [{current}]: ", default=str(current)) + if response != str(current): + if keys[-1] in ("date_range", "boundaries"): + obj[keys[-1]] = yaml.safe_load(response) + else: + obj[keys[-1]] = response + + editor = os.environ.get("EDITOR", "") + if editor and ask_yes_no("\nOpen $EDITOR for full YAML review?", default=False): + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as tmp: + yaml.dump(config, tmp, default_flow_style=False, sort_keys=False) + tmp_path = tmp.name + subprocess.call([editor, tmp_path]) + with open(tmp_path) as f: + config = yaml.safe_load(f) + Path(tmp_path).unlink() + + print("\nFinal configuration:") + print(yaml.dump(config, default_flow_style=False, sort_keys=False)) + if not ask_yes_no("Proceed with this configuration?", default=True): + raise RuntimeError("Fork cancelled by user.") + + return config def _resolve_copy_plan(self, plan): if plan is not None: @@ -207,103 +286,6 @@ def _resolve_copy_plan(self, plan): f"{self.differences.xmlchanges_missing}\nApply them?" ) - def _resolve_compset(self, compset): - self.compset = self.manifest.init_args["compset"] - if compset is not None and compset != self.compset: - self.compset = compset - print( - "Warning: Changing compset may have unintended consequences and " - "may require additional data." - ) - - def _resolve_forcing_configurations(self, extra_configs, remove_configs): - self.requested_configs = [] - - required = ForcingConfigRegistry.find_required_configurators(self.compset) - for cfg in required: - if cfg.name.lower() not in self.manifest.forcing_config: - print("Missing required configurator:", cfg) - self.requested_configs.append(cfg.name.lower()) - - valid = ForcingConfigRegistry.find_valid_configurators(self.compset) - already_ran = [] - - for cfg in self.manifest.forcing_config: - if cfg == "basic": - continue - config_class = ForcingConfigRegistry.get_configurator_from_name(cfg) - if config_class not in valid: - print(f"Forcing config '{cfg}' is no longer valid for this compset") - else: - already_ran.append(config_class) - valid.remove(config_class) - - if extra_configs is not None: - extra = set(extra_configs) - self.resolved_remove = ( - set(remove_configs) if remove_configs is not None else set() - ) - else: - extra_str = ask_string( - f"Enter any other configurations you want " - f"(comma-separated) from: {[obj.name for obj in valid]}", - default="[]", - ) - remove_str = ask_string( - f"Enter any configs you don't want " - f"(comma-separated) from: {[obj.name for obj in already_ran]}", - default="[]", - ) - extra = {x.strip() for x in extra_str.split(",") if x.strip()} - self.resolved_remove = { - x.strip() for x in remove_str.split(",") if x.strip() - } - - for thing in ForcingConfigRegistry.registered_types: - if thing.name in extra: - self.requested_configs.append(thing.name) - - def _resolve_forcing_args(self, extra_forcing_args_path): - self.configure_forcing_args = generate_configure_forcing_args( - self.manifest.forcing_config, self.resolved_remove - ) - if not self.requested_configs: - return - - print( - "\nYou requested or are required to add the following configurations:", - self.requested_configs, - ) - required_args = [ - user_arg - for config in self.requested_configs - for user_arg in ForcingConfigRegistry.get_user_args( - ForcingConfigRegistry.get_configurator_from_name(config) - ) - if not user_arg.startswith("case_") - and user_arg not in self.configure_forcing_args - ] - if extra_forcing_args_path is None: - print(f"Provide the following arguments in a JSON file: {required_args}") - extra_forcing_args_path = ask_string( - "Enter path to JSON file with the required arguments: " - ) - with open(extra_forcing_args_path) as f: - new_args = json.load(f) - - for config in self.requested_configs: - for user_arg in ForcingConfigRegistry.get_user_args( - ForcingConfigRegistry.get_configurator_from_name(config) - ): - if ( - not user_arg.startswith("case_") - and user_arg not in self.configure_forcing_args - and user_arg not in new_args - ): - raise ValueError(f"Missing arg: '{user_arg}' for {config}") - - self.configure_forcing_args.update(new_args) - def apply_copy_plan(self): if self.plan.get("xml_files"): copy_xml_files_from_case( @@ -331,9 +313,7 @@ def apply_copy_plan(self): self.differences.xmlchanges_missing, ) - copy_configurations_to_case( - self.manifest.forcing_config, self.case, self.bundle_location / "ocnice" - ) + # Forcing files are copied in fork() before apply_copy_plan is called. def ask_string(prompt: str, default="") -> str: diff --git a/CrocoDash/visualCaseGen b/CrocoDash/visualCaseGen index cd2103ca..185ad30e 160000 --- a/CrocoDash/visualCaseGen +++ b/CrocoDash/visualCaseGen @@ -1 +1 @@ -Subproject commit cd2103cad15b9506fb9c30eb3ab647512808688c +Subproject commit 185ad30e05dde50bc48ce1f2cf4f58fb8d096847 diff --git a/CrocoDash/workflow.py b/CrocoDash/workflow.py new file mode 100644 index 00000000..265fd7d3 --- /dev/null +++ b/CrocoDash/workflow.py @@ -0,0 +1,261 @@ +import json +from pathlib import Path + +import xarray as xr +import yaml + +from CrocoDash.case import Case +from CrocoDash.grid import Grid +from CrocoDash.topo import Topo +from CrocoDash.vgrid import VGrid + + +def load_config(path): + """Read a YAML case config file, validate its structure, and return the config dict.""" + with open(path) as f: + config = yaml.safe_load(f) + validate_config_structure(config) + return config + + +def validate_config_structure(config): + """Fast pre-flight structural checks on a config dict before any expensive work.""" + required_top = {"grid", "topo", "vgrid", "case"} + missing = required_top - set(config.keys()) + if missing: + raise ValueError(f"Config missing required top-level sections: {missing}") + + case_cfg = config["case"] + for key in ("cesmroot", "caseroot", "inputdir", "compset", "machine"): + if key not in case_cfg: + raise ValueError(f"case.{key} is required") + + topo_cfg = config.get("topo", {}) + source_cfg = topo_cfg.get("source", {}) + valid_topo_types = {"flat", "dataset", "from_file"} + if "source" in topo_cfg and source_cfg.get("type") not in valid_topo_types: + raise ValueError(f"topo.source.type must be one of {valid_topo_types}") + + vgrid_cfg = config.get("vgrid", {}) + valid_vgrid_types = {"uniform", "hyperbolic", "from_file"} + if vgrid_cfg.get("type") not in valid_vgrid_types: + raise ValueError(f"vgrid.type must be one of {valid_vgrid_types}") + + if "forcings" in config: + forcings_cfg = config["forcings"] + for key in ("date_range", "boundaries", "product_name", "function_name"): + if key not in forcings_cfg: + raise ValueError( + f"forcings.{key} is required when the forcings section is present" + ) + dr = forcings_cfg["date_range"] + if not (isinstance(dr, list) and len(dr) == 2): + raise ValueError( + "forcings.date_range must be a list of exactly 2 date strings" + ) + valid_boundaries = {"north", "south", "east", "west"} + bad = set(forcings_cfg["boundaries"]) - valid_boundaries + if bad: + raise ValueError(f"Invalid boundary values: {bad}") + if "tidal_constituents" in forcings_cfg: + for tide_key in ("tpxo_elevation_filepath", "tpxo_velocity_filepath"): + if tide_key not in forcings_cfg: + raise ValueError( + f"forcings.{tide_key} is required when tidal_constituents is set" + ) + + +def build_grid(grid_cfg): + """Build a Grid from a config dict. Uses supergrid_path for file-based grids.""" + if "supergrid_path" in grid_cfg: + grid = Grid.from_supergrid(grid_cfg["supergrid_path"]) + if grid_cfg.get("name"): + grid.name = grid_cfg["name"] + return grid + return Grid( + lenx=grid_cfg["lenx"], + leny=grid_cfg["leny"], + nx=grid_cfg.get("nx"), + ny=grid_cfg.get("ny"), + resolution=grid_cfg.get("resolution"), + xstart=grid_cfg.get("xstart", 0.0), + ystart=grid_cfg.get("ystart"), + cyclic_x=grid_cfg.get("cyclic_x", False), + name=grid_cfg.get("name"), + type=grid_cfg.get("type", "uniform_spherical"), + ) + + +def build_topo(topo_cfg, grid): + """Build a Topo from a config dict. Dispatches on topo.source.type.""" + min_depth = topo_cfg["min_depth"] + source = topo_cfg.get("source", {}) + source_type = source.get("type", "flat") + + if source_type == "from_file": + return Topo.from_topo_file( + grid, source["topo_file_path"], min_depth=min_depth, git=False + ) + + topo = Topo(grid, min_depth, git=False) + + if source_type == "flat": + topo.set_flat(source["depth"]) + elif source_type == "dataset": + topo.set_from_dataset( + bathymetry_path=source["bathymetry_path"], + longitude_coordinate_name=source.get("longitude_coordinate_name", "lon"), + latitude_coordinate_name=source.get("latitude_coordinate_name", "lat"), + vertical_coordinate_name=source.get( + "vertical_coordinate_name", "elevation" + ), + fill_channels=source.get("fill_channels", False), + is_input_positive_below_msl=source.get( + "is_input_positive_below_msl", False + ), + ) + else: + raise ValueError(f"Unknown topo.source.type: '{source_type}'") + + return topo + + +def build_vgrid(vgrid_cfg, topo): + """Build a VGrid from a config dict. If depth is omitted, uses topo.max_depth.""" + vgrid_type = vgrid_cfg.get("type", "uniform") + + if vgrid_type == "from_file": + return VGrid.from_file( + filename=vgrid_cfg["filename"], + variable_name=vgrid_cfg.get("variable_name", "dz"), + variable_type=vgrid_cfg.get("variable_type", "layer_thickness"), + name=vgrid_cfg.get("name"), + ) + + depth = vgrid_cfg.get("depth") or topo.max_depth + + if vgrid_type == "uniform": + return VGrid.uniform( + nk=vgrid_cfg["nk"], + depth=depth, + name=vgrid_cfg.get("name"), + ) + elif vgrid_type == "hyperbolic": + return VGrid.hyperbolic( + nk=vgrid_cfg["nk"], + depth=depth, + ratio=vgrid_cfg["ratio"], + name=vgrid_cfg.get("name"), + ) + else: + raise ValueError(f"Unknown vgrid.type: '{vgrid_type}'") + + +def create_case_from_yaml(config, override=False): + """ + Run the full case creation workflow from a config dict. + + Builds Grid, Topo, and VGrid objects, creates the CESM case, and (if a + forcings section is present) calls configure_forcings. Returns the Case. + """ + grid = build_grid(config["grid"]) + topo = build_topo(config["topo"], grid) + vgrid = build_vgrid(config["vgrid"], topo) + + case_cfg = config["case"] + case = Case( + cesmroot=case_cfg["cesmroot"], + caseroot=case_cfg["caseroot"], + inputdir=case_cfg["inputdir"], + compset=case_cfg["compset"], + ocn_grid=grid, + ocn_topo=topo, + ocn_vgrid=vgrid, + atm_grid_name=case_cfg.get("atm_grid_name", "TL319"), + rof_grid_name=case_cfg.get("rof_grid_name"), + ninst=case_cfg.get("ninst", 1), + machine=case_cfg["machine"], + project=case_cfg.get("project"), + override=override, + ntasks_ocn=case_cfg.get("ntasks_ocn"), + job_queue=case_cfg.get("job_queue"), + job_wallclock_time=case_cfg.get("job_wallclock_time"), + ) + + if "forcings" in config: + forcings_cfg = config["forcings"] + extra_kwargs = { + k: v + for k, v in forcings_cfg.items() + if k not in ("date_range", "boundaries", "product_name", "function_name") + } + case.configure_forcings( + date_range=forcings_cfg["date_range"], + boundaries=forcings_cfg["boundaries"], + product_name=forcings_cfg["product_name"], + function_name=forcings_cfg["function_name"], + **extra_kwargs, + ) + + return case + + +def case_to_yaml(caseroot): + """ + Reconstruct a YAML config dict from an existing case's state files. + + Reads crocodash_state.json (written by Case.__init__) and, if present, + extract_forcings/config.json (written by Case.configure_forcings). + Returns a dict suitable for passing to create_case_from_yaml or writing + to a YAML file with yaml.dump(). + """ + caseroot = Path(caseroot) + state_path = caseroot / "crocodash_state.json" + if not state_path.exists(): + raise FileNotFoundError( + f"No crocodash_state.json found in {caseroot}. " + "This case may not have been created with a recent version of CrocoDash." + ) + with open(state_path) as f: + state = json.load(f) + + topo_ds = xr.open_dataset(state["topo_path"]) + min_depth = float(topo_ds.attrs.get("min_depth", 0.0)) + topo_ds.close() + + config = { + "grid": { + "supergrid_path": state["supergrid_path"], + "name": state["grid_name"], + }, + "topo": { + "min_depth": min_depth, + "source": { + "type": "from_file", + "topo_file_path": state["topo_path"], + }, + }, + "vgrid": { + "type": "from_file", + "filename": state["vgrid_path"], + }, + "case": { + "cesmroot": state["cesmroot"], + "caseroot": str(caseroot), + "inputdir": state["inputdir"], + "compset": state["compset_lname"], + "machine": state["machine"], + "project": state.get("project"), + "atm_grid_name": state.get("atm_grid_name", "TL319"), + }, + } + + forcing_config_path = Path(state["inputdir"]) / "extract_forcings" / "config.json" + if forcing_config_path.exists(): + from CrocoDash.shareable.fork import generate_configure_forcing_args + + with open(forcing_config_path) as f: + forcing_config = json.load(f) + config["forcings"] = generate_configure_forcing_args(forcing_config) + + return config diff --git a/demos b/demos index 7709a4bd..2f6ce37b 160000 --- a/demos +++ b/demos @@ -1 +1 @@ -Subproject commit 7709a4bd904e686eee1e6b5ba93cdb0192ad3c1b +Subproject commit 2f6ce37b1ac1aaa04ebe6d64c2d21faef9f80348 diff --git a/docs/source/for_users/case_information.md b/docs/source/for_users/case_information.md index 3c31e4d5..9672965c 100644 --- a/docs/source/for_users/case_information.md +++ b/docs/source/for_users/case_information.md @@ -1,5 +1,26 @@ # Extra CESM Case Information +## Case State File + +At the end of `Case.__init__`, CrocoDash writes a `crocodash_state.json` file into the caseroot. It records the construction parameters needed to reconstruct or inspect the case later: + +```json +{ + "inputdir": "/path/to/croc_input/mycase", + "cesmroot": "/path/to/CROCESM", + "supergrid_path": "/path/to/.../ocean_hgrid_mygrid_abc123.nc", + "topo_path": "/path/to/.../ocean_topog_mygrid_abc123.nc", + "vgrid_path": "/path/to/.../ocean_vgrid_mygrid_abc123.nc", + "grid_name": "mygrid", + "session_id": "abc123", + "compset_lname": "1850_DATM%JRA_SLND_SICE_MOM6%REGIONAL_SROF_SGLC_SWAV", + "machine": "derecho", + "project": "NCGD0011", + "atm_grid_name": "TL319" +} +``` + +This file is read by `crocodash dump` to reconstruct a YAML config, and by `bundle`/`fork`/`duplicate` to avoid re-parsing CIME. It is the paired counterpart to `inputdir/extract_forcings/config.json`, which records forcing configuration written by `configure_forcings`. ## Available Compset Aliases diff --git a/docs/source/for_users/cli.md b/docs/source/for_users/cli.md new file mode 100644 index 00000000..6bb6bea9 --- /dev/null +++ b/docs/source/for_users/cli.md @@ -0,0 +1,118 @@ +# Command Line Interface + +CrocoDash ships a `crocodash` command (installed automatically with `pip install -e .`) that lets you run the full case setup workflow from a YAML config file and inspect or share existing cases — no Python scripting required. + +## Quick reference + +``` +crocodash create --config mycase.yaml [--override] +crocodash dump --caseroot /path/to/case +crocodash bundle --caseroot /path/to/case --output-dir /path/to/bundle_dir ... +crocodash fork --bundle /path/to/bundle --caseroot ... --inputdir ... --cesmroot ... --machine ... --project ... +crocodash duplicate --source /path/to/case --case /path/to/new_case --inputdir /path/to/new_inputdir +``` + +--- + +## `crocodash create` + +Creates a new CrocoDash case end-to-end from a YAML config file. Equivalent to calling `workflow.create_case_from_yaml()`. + +```bash +crocodash create --config mycase.yaml +crocodash create --config mycase.yaml --override # overwrite existing caseroot/inputdir +``` + +### YAML config schema + +```yaml +# --- Horizontal grid --- +grid: + lenx: 10.0 # domain width in degrees + leny: 10.0 # domain height in degrees + xstart: -60.0 # western edge longitude + ystart: 30.0 # southern edge latitude + resolution: 1.0 # degrees per cell (or use nx/ny instead) + name: "mygrid" + +# --- Bathymetry --- +topo: + min_depth: 10.0 # columns shallower than this are masked + source: + type: "flat" # flat | dataset | from_file + depth: 1000.0 # for type: flat — constant depth in metres + + # type: dataset — interpolate from a real bathymetry file (e.g. GEBCO) + # bathymetry_path: "/path/to/gebco.nc" + # longitude_coordinate_name: "lon" + # latitude_coordinate_name: "lat" + # vertical_coordinate_name: "elevation" + # is_input_positive_below_msl: false + # fill_channels: false + + # type: from_file — reuse an existing topog.nc + # topo_file_path: "/path/to/ocean_topog.nc" + +# --- Vertical grid --- +vgrid: + type: "uniform" # uniform | hyperbolic | from_file + nk: 10 # number of layers + # depth omitted → uses topo.max_depth automatically + name: "myvgrid" + + # type: hyperbolic — surface-intensified levels + # nk: 75 + # depth: 5000.0 + # ratio: 20.0 + + # type: from_file — reuse an existing vgrid.nc + # filename: "/path/to/ocean_vgrid.nc" + +# --- CESM case --- +case: + cesmroot: "/path/to/CROCESM" + caseroot: "/path/to/cases/mycase" + inputdir: "/path/to/croc_input/mycase" + compset: "CR_JRA" # alias or full long name + machine: "derecho" + project: "NCGD0011" + atm_grid_name: "TL319" # optional, default TL319 + +# --- Forcings (optional — skip section to stop after case creation) --- +forcings: + date_range: ["2020-01-01 00:00:00", "2020-02-01 00:00:00"] + boundaries: ["south", "east", "west"] + product_name: "GLORYS" + function_name: "get_glorys_data_from_rda" + + # Any extra kwargs are forwarded directly to configure_forcings: + # tidal_constituents: ["M2", "S2"] + # tpxo_elevation_filepath: "/path/to/TPXO_elevation.nc" + # tpxo_velocity_filepath: "/path/to/TPXO_velocity.nc" +``` + +After `create` completes the caseroot contains a `crocodash_state.json` recording all construction parameters, and (if forcings were configured) `inputdir/extract_forcings/config.json` recording the forcing setup. These files are the source of truth for `dump`, `bundle`, and `fork`. + +--- + +## `crocodash dump` + +Prints a YAML representation of an existing case to stdout. The output can be saved, edited, and passed back to `create` — making `dump` the exact inverse of `create`. + +```bash +# View the config for an existing case +crocodash dump --caseroot ~/croc_cases/mycase + +# Save it to a file and edit before re-creating +crocodash dump --caseroot ~/croc_cases/mycase > mycase_copy.yaml +# ... edit paths, dates, machine, etc. ... +crocodash create --config mycase_copy.yaml --override +``` + +The dumped YAML uses `supergrid_path`/`from_file` references pointing at the existing grid/topo/vgrid files. To create a fully independent copy, either update those paths or re-generate the grid from parameters. + +--- + +## `crocodash bundle`, `fork`, `duplicate` + +For sharing cases with others, see [Shareable Configuration](shareable.md). diff --git a/docs/source/for_users/index.md b/docs/source/for_users/index.md index c0441480..c44cc027 100644 --- a/docs/source/for_users/index.md +++ b/docs/source/for_users/index.md @@ -21,6 +21,7 @@ datasets forcing_configurations extract_forcings case_information +cli shareable additional_resources ``` diff --git a/docs/source/for_users/shareable.md b/docs/source/for_users/shareable.md index ce779b64..420fe7c1 100644 --- a/docs/source/for_users/shareable.md +++ b/docs/source/for_users/shareable.md @@ -5,9 +5,11 @@ Ever wanted to share your regional MOM6 setup? Get a summary of your unique chan Importable through `CrocoDash.shareable`, the module lets you: 1. **Bundle** - Inspect an existing CESM case, identify what makes it unique, and package it into a portable folder -2. **Fork** - Recreate a case from a bundle, with optional modifications +2. **Fork** - Recreate a case from a bundle, guided through any changes via an interactive YAML review 3. **Duplicate** - One-step shortcut to copy a case to a new location, reading machine/project/cesmroot automatically from the original +The shareable workflow is built on top of the [`create`/`dump` primitives](cli.md): `bundle` uses `dump` internally to write the case config as `crocodash_case.yaml`, and `fork` uses `create` internally to build the new case from (a modified copy of) that YAML. + --- ## Workflow @@ -23,15 +25,9 @@ case = BundleCrocoDashCase("/path/to/caseroot") bundle_path = case.bundle("/path/to/output_dir") ``` -If you need to override the machine or project used for the diff (e.g. generating a bundle on a different machine than the original), pass them explicitly: - -```python -bundle_path = case.bundle("/path/to/output_dir", machine="derecho", project="PROJ123") -``` - The bundle folder contains: -- `manifest.json` — grid paths, forcing config, all case metadata -- `non_standard_case_info.json` — diff against a standard case +- `crocodash_case.yaml` — complete case config (grid, topo, vgrid, case, forcings) +- `non_standard_case_info.json` — diff against a standard case (user_nl, xmlchanges, xml_files, SourceMods) - `ocnice/` — ocean/ice input files plus grid files - `user_nl_*` files, `replay.sh` - `xml_files/` and `SourceMods/` — any non-standard modifications @@ -52,11 +48,14 @@ case = forker.fork( ) ``` -By default `fork()` is interactive — it will ask you which non-standard items to copy over (XML files, user_nl params, SourceMods, xmlchanges) and whether you want to change the compset. +`fork()` guides you through the key fields interactively: -#### Non-interactive fork +1. **Path and machine review** — prompts for `caseroot`, `inputdir`, `cesmroot`, `machine`, `project`, `compset`, and (if forcings were configured) `date_range` and `boundaries`. Press Enter to keep the pre-filled value. +2. **EDITOR** — if `$EDITOR` is set, offers to open the full YAML for deeper modifications (changing forcing kwargs, compset modifiers, etc.). +3. **Confirmation** — shows the final config and asks to proceed. +4. **Plan** — asks interactively which non-standard CESM state to copy (XML files, user_nl params, SourceMods, xmlchanges). Pass `plan=` to skip the prompts. -All prompts can be bypassed by passing arguments directly: +#### Non-interactive fork ```python case = forker.fork( @@ -66,19 +65,13 @@ case = forker.fork( new_caseroot="/path/to/new_case", new_inputdir="/path/to/new_inputdir", plan={"xml_files": True, "user_nl": True, "source_mods": False, "xmlchanges": True}, - compset="GOMOM6", # omit to keep the bundle's compset - extra_configs=["tides"], # additional forcing configs to add - remove_configs=["bgc"], # forcing configs to drop - extra_forcing_args_path="/path/to/args.json", # only needed if adding new configs ) ``` -Any argument left as `None` (the default) will still prompt interactively, so you can pre-supply only some of them. +To change forcings, compset, or other parameters: run `crocodash dump` on the bundle's YAML, edit it, and pass it to `crocodash create` directly — no need to use fork for that. ### Duplicate (one-step shortcut) -If you just want an exact copy of an existing case without any modifications, use `duplicate_case`. It reads machine, project, and cesmroot directly from the original caseroot — no extra arguments needed. - ```python from CrocoDash.shareable.bundle import duplicate_case @@ -89,7 +82,7 @@ new_case = duplicate_case( ) ``` -The bundle is written into `new_caseroot` and kept there after duplicating. You can also specify a custom location: +Reads machine, project, and cesmroot from `crocodash_state.json` in the original case. Pass `bundle_dir=` to save the bundle for reference: ```python new_case = duplicate_case( @@ -104,8 +97,6 @@ new_case = duplicate_case( ## Command Line -After installing CrocoDash (`pip install -e .`), a `crocodash` command is available. - ### Bundle ```bash @@ -120,7 +111,7 @@ crocodash bundle \ ### Fork ```bash -# Interactive +# Interactive (guided YAML review) crocodash fork \ --bundle /path/to/bundle \ --caseroot /path/to/new_case \ @@ -129,7 +120,7 @@ crocodash fork \ --machine derecho \ --project PROJ123 -# Non-interactive +# Non-interactive (skip CESM-state copy prompts) crocodash fork \ --bundle /path/to/bundle \ --caseroot /path/to/new_case \ @@ -137,15 +128,9 @@ crocodash fork \ --cesmroot /path/to/cesm \ --machine derecho \ --project PROJ123 \ - --plan '{"xml_files": true, "user_nl": true, "source_mods": false, "xmlchanges": true}' \ - --compset GOMOM6 \ - --extra-configs tides,bgc \ - --remove-configs runoff \ - --extra-forcing-args /path/to/args.json + --plan '{"xml_files": true, "user_nl": true, "source_mods": false, "xmlchanges": true}' ``` -All `fork` flags beyond the six required ones are optional and only needed to bypass the interactive prompts. - ### Duplicate ```bash @@ -155,16 +140,6 @@ crocodash duplicate \ --inputdir /path/to/new_inputdir ``` -Machine, project, and cesmroot are read automatically from the original case. Optionally specify where to keep the bundle: - -```bash -crocodash duplicate \ - --source /path/to/existing_case \ - --case /path/to/new_case \ - --inputdir /path/to/new_inputdir \ - --bundle-dir /path/to/bundle -``` - --- ## What gets diffed? diff --git a/tests/shareable/test_bundle.py b/tests/shareable/test_bundle.py index c7d8fdb6..5e917600 100644 --- a/tests/shareable/test_bundle.py +++ b/tests/shareable/test_bundle.py @@ -1,6 +1,7 @@ from CrocoDash.shareable.bundle import * import pytest import subprocess +import yaml from pathlib import Path @@ -72,27 +73,20 @@ def test_diff_CESM_cases_alldiff(two_cesm_cases): assert output.xmlchanges_missing == ["JOB_PRIORITY"] -def test_identify_CrocoDashCase_init_args(get_case_with_cf, fake_RCC_empty_case): +def test_load_state_from_crocodash_init_args(get_case_with_cf): case = get_case_with_cf - rcc = fake_RCC_empty_case - rcc.caseroot = case.caseroot - rcc._get_cesmroot() - rcc._read_user_nls() - init_args = rcc._identify_CrocoDashCase_init_args() - print(init_args) + rcc = BundleCrocoDashCase(case.caseroot) + init_args = rcc.init_args assert str(case.inputdir / "ocnice") == str(init_args["inputdir_ocnice"]) - assert str(init_args["supergrid_path"]).startswith(str("ocean_hgrid_pana")) - - assert str(init_args["topo_path"]).startswith(str("ocean_topog_pana")) - - assert str(init_args["vgrid_path"]).startswith(str("ocean_vgrid_pana")) + assert str(init_args["supergrid_path"]).startswith("ocean_hgrid_pana") + assert str(init_args["topo_path"]).startswith("ocean_topog_pana") + assert str(init_args["vgrid_path"]).startswith("ocean_vgrid_pana") + assert "compset" in init_args - assert init_args["compset"] == "1850_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV_SESP" - -def test_identify_CrocoDashCase_forcing_config_args( - CrocoDash_case_factory, tmp_path_factory, fake_RCC_empty_case +def test_load_state_from_crocodash_forcing_config( + CrocoDash_case_factory, tmp_path_factory ): case1 = CrocoDash_case_factory(tmp_path_factory.mktemp("forcing_config_args")) case1.configure_forcings( @@ -101,13 +95,8 @@ def test_identify_CrocoDashCase_forcing_config_args( tpxo_elevation_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/h_tpxo9.v1.zarr/", tpxo_velocity_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/u_tpxo9.v1.zarr/", ) - rcc = fake_RCC_empty_case - rcc.caseroot = case1.caseroot - rcc._get_cesmroot() - rcc._read_user_nls() - forcing_config = rcc._identify_CrocoDashCase_forcing_config_args() - # Since this just reads the forcing_config json file in input directory, I'll only check one thing in it - assert "tides" in forcing_config + rcc = BundleCrocoDashCase(case1.caseroot) + assert "tides" in rcc.forcing_config def test_identify_non_standard_case_information(get_CrocoDash_case): @@ -234,16 +223,17 @@ def test_bundle_with_modifications(CrocoDash_case_factory, tmp_path_factory, tmp replay_sh_path = case_bundle / "replay.sh" assert replay_sh_path.exists() - # Check that manifest.json was written - json_file = case_bundle / "manifest.json" - assert json_file.exists() - with open(json_file) as f: - saved_output = json.load(f) + # Check that crocodash_case.yaml was written (replaces the old manifest.json) + yaml_file = case_bundle / "crocodash_case.yaml" + assert yaml_file.exists() + with open(yaml_file) as f: + saved_yaml = yaml.safe_load(f) json_file = case_bundle / "non_standard_case_info.json" with open(json_file) as f: differences = json.load(f) - assert "init_args" in saved_output - assert "forcing_config" in saved_output + assert "case" in saved_yaml + assert "grid" in saved_yaml + assert "forcings" in saved_yaml assert differences["xml_files_missing_in_new"] == ["custom_settings.xml"] assert differences["source_mods_missing_files"] == ["src.mom/custom_module.F90"] diff --git a/tests/shareable/test_cli.py b/tests/shareable/test_cli.py index 826d6543..60f73acf 100644 --- a/tests/shareable/test_cli.py +++ b/tests/shareable/test_cli.py @@ -50,7 +50,6 @@ def test_fork_cli(tmp_path): "source_mods": True, "xmlchanges": True, } - args_file = tmp_path / "args.json" with patch( "CrocoDash.shareable.fork.ForkCrocoDashBundle", return_value=mock_forker @@ -72,14 +71,6 @@ def test_fork_cli(tmp_path): "PROJ123", "--plan", json.dumps(plan), - "--compset", - "GOMOM6", - "--extra-configs", - "tides,bgc", - "--remove-configs", - "runoff", - "--extra-forcing-args", - str(args_file), ] ) @@ -90,10 +81,6 @@ def test_fork_cli(tmp_path): new_caseroot=str(tmp_path / "new_case"), new_inputdir=str(tmp_path / "inputdir"), plan=plan, - compset="GOMOM6", - extra_configs=["tides", "bgc"], - remove_configs=["runoff"], - extra_forcing_args_path=str(args_file), ) diff --git a/tests/shareable/test_fork.py b/tests/shareable/test_fork.py index bb1fda20..d305d388 100644 --- a/tests/shareable/test_fork.py +++ b/tests/shareable/test_fork.py @@ -1,7 +1,7 @@ from CrocoDash.shareable.fork import * import json import pytest -from types import SimpleNamespace +from pathlib import Path from unittest.mock import patch from uuid import uuid4 @@ -84,23 +84,51 @@ def test_resolve_copy_plan_with_provided_plan(fake_fcb_empty_case): assert fcb.plan is provided -def test_resolve_compset(fake_fcb_empty_case): - """Test _resolve_compset sets compset on self.""" +def test_patch_yaml_for_fork(fake_fcb_empty_case, tmp_path): + """Test _patch_yaml_for_fork correctly patches destination fields.""" fcb = fake_fcb_empty_case - bundle_compset = "1850_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV" - fcb.manifest = BundleManifest( - forcing_config={}, - init_args={"compset": bundle_compset}, + fcb.bundle_location = tmp_path / "bundle" + (fcb.bundle_location / "ocnice").mkdir(parents=True) + + bundle_yaml = { + "case": { + "cesmroot": "/old/cesm", + "machine": "old_machine", + "project": "OLD123", + "caseroot": "/old/case", + "inputdir": "/old/inputdir", + "compset": "1850_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV", + }, + "grid": {"supergrid_path": "/old/ocnice/ocean_hgrid.nc"}, + "topo": { + "source": { + "type": "from_file", + "topo_file_path": "/old/ocnice/ocean_topog.nc", + } + }, + "vgrid": {"type": "from_file", "filename": "/old/ocnice/ocean_vgrid.nc"}, + } + fcb.bundle_yaml = bundle_yaml + + config = fcb._patch_yaml_for_fork( + cesmroot="/new/cesm", + machine="new_machine", + project_number="NEW123", + new_caseroot="/new/case", + new_inputdir="/new/inputdir", ) - fcb._resolve_compset(None) - - assert fcb.compset == bundle_compset - - new_compset = "2000_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV" - fcb._resolve_compset(new_compset) - - assert fcb.compset == new_compset + assert config["case"]["cesmroot"] == "/new/cesm" + assert config["case"]["machine"] == "new_machine" + assert config["case"]["project"] == "NEW123" + assert config["case"]["caseroot"] == "/new/case" + assert config["case"]["inputdir"] == "/new/inputdir" + # Original must be unchanged + assert bundle_yaml["case"]["cesmroot"] == "/old/cesm" + # Grid/topo/vgrid paths are redirected to bundle ocnice + assert "ocean_hgrid.nc" in config["grid"]["supergrid_path"] + assert "ocean_topog.nc" in config["topo"]["source"]["topo_file_path"] + assert "ocean_vgrid.nc" in config["vgrid"]["filename"] def test_build_general_configure_forcing_args(sample_forcing_config): @@ -126,114 +154,6 @@ def test_build_general_configure_forcing_args(sample_forcing_config): assert "marbl_ic_filepath" in args -def test_resolve_forcing_args_no_configs(fake_fcb_empty_case, sample_forcing_config): - """Test _resolve_forcing_args sets configure_forcing_args unchanged when no configs requested.""" - fcb = fake_fcb_empty_case - fcb.manifest = BundleManifest(forcing_config=sample_forcing_config, init_args={}) - fcb.resolved_remove = {} - fcb.requested_configs = [] - - fcb._resolve_forcing_args(None) - - assert fcb.configure_forcing_args == { - "date_range": ["2020-01-01 00:00:00", "2020-01-09 00:00:00"], - "boundaries": ["north"], - "product_name": "GLORYS", - "function_name": "get_glorys_data_script_for_cli", - "tpxo_elevation_filepath": "ASd", - "tpxo_velocity_filepath": "ASd", - "tidal_constituents": ["M2", "K1"], - "marbl_ic_filepath": "qwreqwre", - } - - -def test_resolve_forcing_args_with_json_file( - fake_fcb_empty_case, sample_forcing_config, tmp_path -): - """Test that _resolve_forcing_args loads extra args from a JSON file path.""" - fcb = fake_fcb_empty_case - fcb.manifest = BundleManifest(forcing_config=sample_forcing_config, init_args={}) - fcb.resolved_remove = {} - fcb.requested_configs = ["tides"] - - args_file = tmp_path / "forcing_args.json" - args_file.write_text( - json.dumps( - { - "tidal_constituents": ["M2", "K1"], - "tpxo_elevation_filepath": "elev.nc", - "tpxo_velocity_filepath": "vel.nc", - "boundaries": ["north"], - } - ) - ) - - fcb._resolve_forcing_args(str(args_file)) - - assert fcb.configure_forcing_args["tidal_constituents"] == ["M2", "K1"] - - -def test_resolve_forcing_args_missing_required_arg( - fake_fcb_empty_case, sample_forcing_config, tmp_path -): - """Test that _resolve_forcing_args raises ValueError when required args are missing.""" - fcb = fake_fcb_empty_case - fcb.manifest = BundleManifest(forcing_config=sample_forcing_config, init_args={}) - fcb.resolved_remove = {"tides"} # remove tides so its args aren't pre-populated - fcb.requested_configs = ["tides"] - - args_file = tmp_path / "incomplete_args.json" - args_file.write_text(json.dumps({"tidal_constituents": ["M2"]})) - - with pytest.raises(ValueError, match="Missing arg"): - fcb._resolve_forcing_args(str(args_file)) - - -def test_resolve_forcing_configurations(fake_fcb_empty_case, sample_forcing_config): - """Test _resolve_forcing_configurations sets requested and removed configs on self.""" - fcb = fake_fcb_empty_case - fcb.manifest = BundleManifest(forcing_config=sample_forcing_config, init_args={}) - fcb.compset = "2000_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV" - - with patch( - "CrocoDash.shareable.fork.ForcingConfigRegistry.find_required_configurators", - return_value=[], - ): - with patch( - "CrocoDash.shareable.fork.ForcingConfigRegistry.find_valid_configurators", - return_value=[], - ): - with patch("CrocoDash.shareable.fork.ask_string", side_effect=["", "bgc"]): - fcb._resolve_forcing_configurations(None, None) - - assert isinstance(fcb.requested_configs, list) - assert isinstance(fcb.resolved_remove, set) - assert "bgc" in fcb.resolved_remove - - -def test_resolve_forcing_configurations_required_missing( - fake_fcb_empty_case, sample_forcing_config -): - """Test that a required configurator absent from the manifest is added to requested_configs.""" - fcb = fake_fcb_empty_case - # manifest has no "bgc" entry - fcb.manifest = BundleManifest(forcing_config={"basic": {}}, init_args={}) - fcb.compset = "2000_DATM%JRA_SLND_SICE_MOM6_SROF_SGLC_SWAV" - - mock_required = SimpleNamespace(name="BGC") - - with patch( - "CrocoDash.shareable.fork.ForcingConfigRegistry.find_required_configurators", - return_value=[mock_required], - ), patch( - "CrocoDash.shareable.fork.ForcingConfigRegistry.find_valid_configurators", - return_value=[], - ): - fcb._resolve_forcing_configurations(extra_configs=[], remove_configs=[]) - - assert "bgc" in fcb.requested_configs - - def test_ask_input_response(): """Test ask_yes_no returns True for yes/y response.""" with patch("builtins.input", return_value="yes"): From 021424aae3588daef4ea739ffbe781f125d984bc Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 15:09:35 -0600 Subject: [PATCH 02/13] Changes --- CrocoDash/shareable/bundle.py | 56 ++--- CrocoDash/shareable/fork.py | 67 ++--- CrocoDash/workflow.py | 44 +++- tests/test_workflow_functions.py | 415 +++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+), 83 deletions(-) create mode 100644 tests/test_workflow_functions.py diff --git a/CrocoDash/shareable/bundle.py b/CrocoDash/shareable/bundle.py index 5cad7a6a..95480610 100644 --- a/CrocoDash/shareable/bundle.py +++ b/CrocoDash/shareable/bundle.py @@ -1,29 +1,40 @@ -from pathlib import Path import dataclasses +import importlib import json +import logging import os +import shutil +import subprocess +import sys import tempfile +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path +from uuid import uuid4 + +import yaml +from CrocoDash.forcing_configurations.base import * from CrocoDash.grid import * -from CrocoDash.topo import * -from CrocoDash.vgrid import * +from CrocoDash.logging import setup_logger +from CrocoDash.shareable.apply import ( + INPUTDIR_FILE_PREFIXES, + apply_xmlchanges_to_case, + copy_source_mods_from_case, + copy_user_nl_params_from_case, + copy_xml_files_from_case, +) from CrocoDash.shareable.fork import ( + BundleDifferences, + BundleManifest, + ForkCrocoDashBundle, create_case, +) +from CrocoDash.topo import * +from CrocoDash.vgrid import * +from CrocoDash.workflow import ( + case_to_yaml, + create_case_from_yaml, generate_configure_forcing_args, - ForkCrocoDashBundle, - BundleManifest, - BundleDifferences, ) -from CrocoDash.shareable.apply import INPUTDIR_FILE_PREFIXES -from uuid import uuid4 -import subprocess -from CrocoDash.logging import setup_logger -from contextlib import redirect_stdout, redirect_stderr -import logging -from CrocoDash.forcing_configurations.base import * -import importlib -import sys -import shutil -import yaml logger = setup_logger(__name__) @@ -115,8 +126,6 @@ def _read_sourcemods(self): def _load_state_from_crocodash(self): """Load case parameters from crocodash_state.json and extract_forcings/config.json.""" - from CrocoDash.workflow import case_to_yaml - logger.info(f"Loading CrocoDash state from {self.caseroot}") self.case_yaml = case_to_yaml(self.caseroot) @@ -368,15 +377,6 @@ def duplicate_case(caseroot, new_caseroot, new_inputdir, bundle_dir=None): bundle_dir : str or Path, optional Where to copy the bundle for reference. If None, no bundle is saved. """ - from CrocoDash.workflow import case_to_yaml, create_case_from_yaml - from CrocoDash.shareable.apply import ( - copy_xml_files_from_case, - copy_user_nl_params_from_case, - copy_source_mods_from_case, - apply_xmlchanges_to_case, - copy_configurations_to_case, - ) - rcc = BundleCrocoDashCase(caseroot) rcc.identify_non_standard_CrocoDash_case_information( rcc.cesmroot, rcc.case_machine, rcc.case_project diff --git a/CrocoDash/shareable/fork.py b/CrocoDash/shareable/fork.py index a718e61e..8fc9a183 100644 --- a/CrocoDash/shareable/fork.py +++ b/CrocoDash/shareable/fork.py @@ -1,17 +1,23 @@ -from pathlib import Path -from CrocoDash.forcing_configurations.base import * -from CrocoDash.shareable.apply import * +import copy import json +import os import shutil -from datetime import datetime +import subprocess +import tempfile from dataclasses import dataclass, field -from CrocoDash.case import Case -from CrocoDash.grid import Grid -from CrocoDash.vgrid import VGrid -from CrocoDash.topo import Topo +from datetime import datetime +from pathlib import Path + import xarray as xr import yaml +from CrocoDash.case import Case +from CrocoDash.forcing_configurations.base import * +from CrocoDash.grid import Grid from CrocoDash.logging import setup_logger +from CrocoDash.shareable.apply import * +from CrocoDash.topo import Topo +from CrocoDash.vgrid import VGrid +from CrocoDash.workflow import create_case_from_yaml, generate_configure_forcing_args logger = setup_logger(__name__) @@ -129,8 +135,6 @@ def fork( ``{"xml_files": True, "user_nl": False, "source_mods": True, "xmlchanges": True}``. When omitted the user is asked interactively. """ - from CrocoDash.workflow import create_case_from_yaml - # Phase 1: build patched YAML with new destination values config = self._patch_yaml_for_fork( cesmroot, machine, project_number, new_caseroot, new_inputdir @@ -169,8 +173,6 @@ def _patch_yaml_for_fork( self, cesmroot, machine, project_number, new_caseroot, new_inputdir ): """Return a copy of bundle_yaml with destination fields patched.""" - import copy - config = copy.deepcopy(self.bundle_yaml) config["case"]["cesmroot"] = str(cesmroot) config["case"]["machine"] = machine @@ -199,11 +201,6 @@ def _patch_yaml_for_fork( def _guide_yaml_review(self, config): """Walk the user through key YAML fields and offer $EDITOR for deeper edits.""" - import copy - import os - import subprocess - import tempfile - print("\n=== Fork: Review Case Configuration ===") print( "The following fields have been pre-filled. Press Enter to keep each value.\n" @@ -400,39 +397,3 @@ def create_case( atm_grid_name=init_args["atm_grid_name"], ) return case - - -def generate_configure_forcing_args(forcing_config, remove_configs=None): - if remove_configs is None: - remove_configs = [] - logger.info("Setup configuration arguments...") - - start_str = forcing_config["basic"]["dates"]["start"] - end_str = forcing_config["basic"]["dates"]["end"] - date_format = forcing_config["basic"]["dates"]["format"] - start_dt = datetime.strptime(start_str, date_format) - end_dt = datetime.strptime(end_str, date_format) - - date_range = [ - start_dt.strftime("%Y-%m-%d %H:%M:%S"), - end_dt.strftime("%Y-%m-%d %H:%M:%S"), - ] - - configure_forcing_args = { - "date_range": date_range, - "boundaries": list( - forcing_config["basic"]["general"]["boundary_number_conversion"].keys() - ), - "product_name": forcing_config["basic"]["forcing"]["product_name"], - "function_name": forcing_config["basic"]["forcing"]["function_name"], - } - for key in forcing_config: - if key == "basic" or key in remove_configs: - continue - user_args = ForcingConfigRegistry.get_user_args( - ForcingConfigRegistry.get_configurator_from_name(key) - ) - for arg in user_args: - if not arg.startswith("case_"): - configure_forcing_args[arg] = forcing_config[key]["inputs"][arg] - return configure_forcing_args diff --git a/CrocoDash/workflow.py b/CrocoDash/workflow.py index 265fd7d3..cfc0e456 100644 --- a/CrocoDash/workflow.py +++ b/CrocoDash/workflow.py @@ -1,13 +1,18 @@ import json +from datetime import datetime from pathlib import Path import xarray as xr import yaml from CrocoDash.case import Case +from CrocoDash.forcing_configurations.base import ForcingConfigRegistry from CrocoDash.grid import Grid from CrocoDash.topo import Topo from CrocoDash.vgrid import VGrid +from CrocoDash.logging import setup_logger + +logger = setup_logger(__name__) def load_config(path): @@ -200,6 +205,43 @@ def create_case_from_yaml(config, override=False): return case +def generate_configure_forcing_args(forcing_config, remove_configs=None): + """Convert a config.json forcing_config dict into configure_forcings kwargs.""" + if remove_configs is None: + remove_configs = [] + logger.info("Setup configuration arguments...") + + start_str = forcing_config["basic"]["dates"]["start"] + end_str = forcing_config["basic"]["dates"]["end"] + date_format = forcing_config["basic"]["dates"]["format"] + start_dt = datetime.strptime(start_str, date_format) + end_dt = datetime.strptime(end_str, date_format) + + date_range = [ + start_dt.strftime("%Y-%m-%d %H:%M:%S"), + end_dt.strftime("%Y-%m-%d %H:%M:%S"), + ] + + configure_forcing_args = { + "date_range": date_range, + "boundaries": list( + forcing_config["basic"]["general"]["boundary_number_conversion"].keys() + ), + "product_name": forcing_config["basic"]["forcing"]["product_name"], + "function_name": forcing_config["basic"]["forcing"]["function_name"], + } + for key in forcing_config: + if key == "basic" or key in remove_configs: + continue + user_args = ForcingConfigRegistry.get_user_args( + ForcingConfigRegistry.get_configurator_from_name(key) + ) + for arg in user_args: + if not arg.startswith("case_"): + configure_forcing_args[arg] = forcing_config[key]["inputs"][arg] + return configure_forcing_args + + def case_to_yaml(caseroot): """ Reconstruct a YAML config dict from an existing case's state files. @@ -252,8 +294,6 @@ def case_to_yaml(caseroot): forcing_config_path = Path(state["inputdir"]) / "extract_forcings" / "config.json" if forcing_config_path.exists(): - from CrocoDash.shareable.fork import generate_configure_forcing_args - with open(forcing_config_path) as f: forcing_config = json.load(f) config["forcings"] = generate_configure_forcing_args(forcing_config) diff --git a/tests/test_workflow_functions.py b/tests/test_workflow_functions.py new file mode 100644 index 00000000..e051c153 --- /dev/null +++ b/tests/test_workflow_functions.py @@ -0,0 +1,415 @@ +""" +Unit tests for CrocoDash/workflow.py. + +Covers: +- validate_config_structure: valid/invalid variants +- load_config: file I/O + validation round-trip +- build_grid / build_topo / build_vgrid: each source type +- case_to_yaml: reads state files written by Case.__init__ + configure_forcings +- Round-trip: case_to_yaml output is a valid input for create_case_from_yaml +""" + +import json +import pytest +import yaml +from pathlib import Path + +from CrocoDash.workflow import ( + build_grid, + build_topo, + build_vgrid, + case_to_yaml, + load_config, + validate_config_structure, +) +from CrocoDash.grid import Grid +from CrocoDash.topo import Topo +from CrocoDash.vgrid import VGrid + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +MINIMAL_VALID_CONFIG = { + "grid": { + "lenx": 4.0, + "leny": 3.0, + "resolution": 0.1, + "xstart": 278.0, + "ystart": 7.0, + }, + "topo": { + "min_depth": 9.5, + "source": {"type": "flat", "depth": 100.0}, + }, + "vgrid": {"type": "uniform", "nk": 10, "depth": 100.0}, + "case": { + "cesmroot": "/cesm", + "caseroot": "/case", + "inputdir": "/inputdir", + "compset": "CR_JRA", + "machine": "derecho", + }, +} + + +# --------------------------------------------------------------------------- +# validate_config_structure +# --------------------------------------------------------------------------- + + +def test_validate_valid_config(): + validate_config_structure(MINIMAL_VALID_CONFIG) + + +def test_validate_missing_top_level_sections(): + bad = {"grid": {}, "topo": {}} # no vgrid, no case + with pytest.raises(ValueError, match="missing required top-level"): + validate_config_structure(bad) + + +@pytest.mark.parametrize( + "missing_key", ["cesmroot", "caseroot", "inputdir", "compset", "machine"] +) +def test_validate_missing_case_key(missing_key): + config = { + "grid": {}, + "topo": {}, + "vgrid": {"type": "uniform"}, + "case": { + k: "x" + for k in ("cesmroot", "caseroot", "inputdir", "compset", "machine") + if k != missing_key + }, + } + with pytest.raises(ValueError, match=f"case\\.{missing_key}"): + validate_config_structure(config) + + +def test_validate_invalid_topo_type(): + config = {**MINIMAL_VALID_CONFIG, "topo": {"source": {"type": "bogus"}}} + with pytest.raises(ValueError, match="topo\\.source\\.type"): + validate_config_structure(config) + + +def test_validate_invalid_vgrid_type(): + config = {**MINIMAL_VALID_CONFIG, "vgrid": {"type": "bogus"}} + with pytest.raises(ValueError, match="vgrid\\.type"): + validate_config_structure(config) + + +def test_validate_forcings_section_valid(): + config = { + **MINIMAL_VALID_CONFIG, + "forcings": { + "date_range": ["2020-01-01", "2020-02-01"], + "boundaries": ["north", "east"], + "product_name": "GLORYS", + "function_name": "get_glorys", + }, + } + validate_config_structure(config) + + +@pytest.mark.parametrize( + "missing_key", ["date_range", "boundaries", "product_name", "function_name"] +) +def test_validate_forcings_missing_required(missing_key): + forcings = { + "date_range": ["2020-01-01", "2020-02-01"], + "boundaries": ["north"], + "product_name": "GLORYS", + "function_name": "get_glorys", + } + del forcings[missing_key] + config = {**MINIMAL_VALID_CONFIG, "forcings": forcings} + with pytest.raises(ValueError, match=f"forcings\\.{missing_key}"): + validate_config_structure(config) + + +def test_validate_forcings_bad_date_range_not_list(): + config = { + **MINIMAL_VALID_CONFIG, + "forcings": { + "date_range": "2020-01-01", + "boundaries": ["north"], + "product_name": "GLORYS", + "function_name": "get_glorys", + }, + } + with pytest.raises(ValueError, match="date_range must be a list"): + validate_config_structure(config) + + +def test_validate_forcings_bad_date_range_wrong_length(): + config = { + **MINIMAL_VALID_CONFIG, + "forcings": { + "date_range": ["2020-01-01"], + "boundaries": ["north"], + "product_name": "GLORYS", + "function_name": "get_glorys", + }, + } + with pytest.raises(ValueError, match="date_range must be a list"): + validate_config_structure(config) + + +def test_validate_forcings_invalid_boundary(): + config = { + **MINIMAL_VALID_CONFIG, + "forcings": { + "date_range": ["2020-01-01", "2020-02-01"], + "boundaries": ["northwest"], + "product_name": "GLORYS", + "function_name": "get_glorys", + }, + } + with pytest.raises(ValueError, match="Invalid boundary"): + validate_config_structure(config) + + +# --------------------------------------------------------------------------- +# load_config +# --------------------------------------------------------------------------- + + +def test_load_config_valid_file(tmp_path): + config_file = tmp_path / "case.yaml" + config_file.write_text(yaml.dump(MINIMAL_VALID_CONFIG)) + loaded = load_config(config_file) + assert loaded["vgrid"]["type"] == "uniform" + assert loaded["case"]["machine"] == "derecho" + + +def test_load_config_invalid_file_raises(tmp_path): + bad = {"grid": {}, "topo": {}} + config_file = tmp_path / "bad.yaml" + config_file.write_text(yaml.dump(bad)) + with pytest.raises(ValueError): + load_config(config_file) + + +# --------------------------------------------------------------------------- +# build_grid +# --------------------------------------------------------------------------- + + +def test_build_grid_from_params(): + cfg = { + "lenx": 4.0, + "leny": 3.0, + "resolution": 0.5, + "xstart": 278.0, + "ystart": 7.0, + "name": "testgrid", + } + grid = build_grid(cfg) + assert isinstance(grid, Grid) + assert grid.name == "testgrid" + assert grid.lenx == pytest.approx(4.0, rel=0.01) + assert grid.leny == pytest.approx(3.0, rel=0.01) + + +def test_build_grid_from_supergrid_file(gen_grid_topo_vgrid, tmp_path): + orig_grid, _, _ = gen_grid_topo_vgrid + supergrid_path = tmp_path / "ocean_hgrid.nc" + orig_grid.write_supergrid(supergrid_path) + + cfg = {"supergrid_path": str(supergrid_path), "name": "reloaded"} + grid = build_grid(cfg) + assert isinstance(grid, Grid) + assert grid.name == "reloaded" + assert grid.nx == orig_grid.nx + assert grid.ny == orig_grid.ny + + +def test_build_grid_from_supergrid_preserves_shape(gen_grid_topo_vgrid, tmp_path): + orig_grid, _, _ = gen_grid_topo_vgrid + supergrid_path = tmp_path / "ocean_hgrid.nc" + orig_grid.write_supergrid(supergrid_path) + + grid = build_grid({"supergrid_path": str(supergrid_path)}) + assert grid.nx == orig_grid.nx + assert grid.ny == orig_grid.ny + + +# --------------------------------------------------------------------------- +# build_topo +# --------------------------------------------------------------------------- + + +def test_build_topo_flat(get_rect_grid): + cfg = {"min_depth": 9.5, "source": {"type": "flat", "depth": 500.0}} + topo = build_topo(cfg, get_rect_grid) + assert isinstance(topo, Topo) + assert topo.max_depth == pytest.approx(500.0, rel=0.01) + assert topo.min_depth == pytest.approx(9.5, rel=0.01) + + +def test_build_topo_from_file(gen_grid_topo_vgrid, tmp_path): + grid, orig_topo, _ = gen_grid_topo_vgrid + topo_path = tmp_path / "ocean_topog.nc" + orig_topo.write_topo(topo_path) + + cfg = { + "min_depth": orig_topo.min_depth, + "source": {"type": "from_file", "topo_file_path": str(topo_path)}, + } + topo = build_topo(cfg, grid) + assert isinstance(topo, Topo) + assert topo.max_depth == pytest.approx(orig_topo.max_depth, rel=0.01) + + +def test_build_topo_unknown_type_raises(get_rect_grid): + cfg = {"min_depth": 9.5, "source": {"type": "unknown"}} + with pytest.raises(ValueError, match="Unknown topo\\.source\\.type"): + build_topo(cfg, get_rect_grid) + + +# --------------------------------------------------------------------------- +# build_vgrid +# --------------------------------------------------------------------------- + + +def test_build_vgrid_uniform(get_rect_grid_and_topo): + _, topo = get_rect_grid_and_topo + cfg = {"type": "uniform", "nk": 10, "depth": 200.0} + vgrid = build_vgrid(cfg, topo) + assert isinstance(vgrid, VGrid) + assert vgrid.nk == 10 + assert vgrid.depth == pytest.approx(200.0, rel=0.01) + + +def test_build_vgrid_hyperbolic(get_rect_grid_and_topo): + _, topo = get_rect_grid_and_topo + cfg = {"type": "hyperbolic", "nk": 20, "depth": 1000.0, "ratio": 10.0} + vgrid = build_vgrid(cfg, topo) + assert isinstance(vgrid, VGrid) + assert vgrid.nk == 20 + + +def test_build_vgrid_depth_defaults_to_topo_max_depth(get_rect_grid_and_topo): + _, topo = get_rect_grid_and_topo + cfg = {"type": "uniform", "nk": 5} # no depth key + vgrid = build_vgrid(cfg, topo) + assert vgrid.depth == pytest.approx(topo.max_depth, rel=0.01) + + +def test_build_vgrid_from_file(get_vgrid, tmp_path): + vgrid_path = tmp_path / "vgrid.nc" + get_vgrid.write(vgrid_path) + + cfg = {"type": "from_file", "filename": str(vgrid_path)} + vgrid = build_vgrid(cfg, topo=None) + assert isinstance(vgrid, VGrid) + assert vgrid.nk == get_vgrid.nk + assert vgrid.depth == pytest.approx(get_vgrid.depth, rel=0.01) + + +def test_build_vgrid_unknown_type_raises(get_rect_grid_and_topo): + _, topo = get_rect_grid_and_topo + with pytest.raises(ValueError, match="Unknown vgrid\\.type"): + build_vgrid({"type": "bogus", "nk": 5}, topo) + + +# --------------------------------------------------------------------------- +# case_to_yaml +# --------------------------------------------------------------------------- + + +def test_case_to_yaml_missing_state_file(tmp_path): + with pytest.raises(FileNotFoundError, match="crocodash_state\\.json"): + case_to_yaml(tmp_path / "no_case_here") + + +def test_case_to_yaml_structure(get_CrocoDash_case): + case = get_CrocoDash_case + config = case_to_yaml(case.caseroot) + + assert set(config.keys()) >= {"grid", "topo", "vgrid", "case"} + assert "supergrid_path" in config["grid"] + assert "min_depth" in config["topo"] + assert config["topo"]["source"]["type"] == "from_file" + assert config["vgrid"]["type"] == "from_file" + assert "cesmroot" in config["case"] + assert "caseroot" in config["case"] + assert "inputdir" in config["case"] + assert "compset" in config["case"] + assert "machine" in config["case"] + + +def test_case_to_yaml_values_match_case(get_CrocoDash_case): + case = get_CrocoDash_case + config = case_to_yaml(case.caseroot) + + assert config["case"]["compset"] == case.compset_lname + assert config["case"]["machine"] == case.machine + assert config["case"]["caseroot"] == str(case.caseroot) + assert config["case"]["inputdir"] == str(case.inputdir) + assert config["grid"]["supergrid_path"] == case.supergrid_path + + +def test_case_to_yaml_with_forcings(get_case_with_cf): + case = get_case_with_cf + config = case_to_yaml(case.caseroot) + + assert "forcings" in config + assert "date_range" in config["forcings"] + assert "boundaries" in config["forcings"] + assert "product_name" in config["forcings"] + assert "function_name" in config["forcings"] + assert isinstance(config["forcings"]["date_range"], list) + assert len(config["forcings"]["date_range"]) == 2 + + +# --------------------------------------------------------------------------- +# Round-trip: case_to_yaml output is valid input for create_case_from_yaml +# --------------------------------------------------------------------------- + + +def test_case_to_yaml_round_trip_is_valid_config(get_CrocoDash_case): + """case_to_yaml output must pass validate_config_structure without error.""" + case = get_CrocoDash_case + config = case_to_yaml(case.caseroot) + validate_config_structure(config) + + +def test_case_to_yaml_round_trip_with_forcings_is_valid(get_case_with_cf): + """case_to_yaml output including forcings must pass validate_config_structure.""" + case = get_case_with_cf + config = case_to_yaml(case.caseroot) + validate_config_structure(config) + + +def test_case_to_yaml_round_trip_yaml_serializable(get_CrocoDash_case, tmp_path): + """case_to_yaml output can be written to YAML and reloaded identically.""" + case = get_CrocoDash_case + config = case_to_yaml(case.caseroot) + + yaml_path = tmp_path / "round_trip.yaml" + yaml_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + reloaded = yaml.safe_load(yaml_path.read_text()) + + assert reloaded["case"]["compset"] == config["case"]["compset"] + assert reloaded["case"]["machine"] == config["case"]["machine"] + assert reloaded["grid"]["supergrid_path"] == config["grid"]["supergrid_path"] + assert ( + reloaded["topo"]["source"]["topo_file_path"] + == config["topo"]["source"]["topo_file_path"] + ) + assert reloaded["vgrid"]["filename"] == config["vgrid"]["filename"] + + +def test_case_to_yaml_round_trip_forcings_preserved(get_case_with_cf, tmp_path): + """Forcings date_range and boundaries survive a YAML write-reload round-trip.""" + case = get_case_with_cf + config = case_to_yaml(case.caseroot) + + yaml_path = tmp_path / "round_trip_forcings.yaml" + yaml_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + reloaded = yaml.safe_load(yaml_path.read_text()) + + assert reloaded["forcings"]["date_range"] == config["forcings"]["date_range"] + assert reloaded["forcings"]["boundaries"] == config["forcings"]["boundaries"] + assert reloaded["forcings"]["product_name"] == config["forcings"]["product_name"] From 292c0f139e20f9d57e355cc7d65ba1d263d4c55d Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 15:11:23 -0600 Subject: [PATCH 03/13] Fix sm commits --- CrocoDash/rm6 | 2 +- CrocoDash/visualCaseGen | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CrocoDash/rm6 b/CrocoDash/rm6 index d8197ce0..786aa4b1 160000 --- a/CrocoDash/rm6 +++ b/CrocoDash/rm6 @@ -1 +1 @@ -Subproject commit d8197ce0bad05d8ea08d6e7d4a7453ccb8551945 +Subproject commit 786aa4b144158bf332ac3c23be384a919ed989a9 diff --git a/CrocoDash/visualCaseGen b/CrocoDash/visualCaseGen index 185ad30e..aee5f909 160000 --- a/CrocoDash/visualCaseGen +++ b/CrocoDash/visualCaseGen @@ -1 +1 @@ -Subproject commit 185ad30e05dde50bc48ce1f2cf4f58fb8d096847 +Subproject commit aee5f9097ed22fde4ffbc298588ee221b6a47be3 From 72ba6bfb971194d762a87da8dafd943c008e742a Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 16:03:28 -0600 Subject: [PATCH 04/13] New Change --- CrocoDash/visualCaseGen | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CrocoDash/visualCaseGen b/CrocoDash/visualCaseGen index aee5f909..0ac0cac3 160000 --- a/CrocoDash/visualCaseGen +++ b/CrocoDash/visualCaseGen @@ -1 +1 @@ -Subproject commit aee5f9097ed22fde4ffbc298588ee221b6a47be3 +Subproject commit 0ac0cac38bd4e29a7f7e77dac18f034d2f4aa5bf From 5ea60ed573c6f9b4a292dc3431476ebeb0855cf7 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 16:06:14 -0600 Subject: [PATCH 05/13] This --- CrocoDash/rm6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CrocoDash/rm6 b/CrocoDash/rm6 index 786aa4b1..d8197ce0 160000 --- a/CrocoDash/rm6 +++ b/CrocoDash/rm6 @@ -1 +1 @@ -Subproject commit 786aa4b144158bf332ac3c23be384a919ed989a9 +Subproject commit d8197ce0bad05d8ea08d6e7d4a7453ccb8551945 From 5b35bd1e446d0c27421161ba15de8f4216262571 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Wed, 10 Jun 2026 17:32:13 -0600 Subject: [PATCH 06/13] Require forcings in YAML config and call process_forcings in create_case_from_yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add forcings to required_top in validate_config_structure — every CrocoDash case must configure and process forcings - Call process_forcings() after configure_forcings() in create_case_from_yaml --- CrocoDash/workflow.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/CrocoDash/workflow.py b/CrocoDash/workflow.py index cfc0e456..23a0089b 100644 --- a/CrocoDash/workflow.py +++ b/CrocoDash/workflow.py @@ -25,7 +25,7 @@ def load_config(path): def validate_config_structure(config): """Fast pre-flight structural checks on a config dict before any expensive work.""" - required_top = {"grid", "topo", "vgrid", "case"} + required_top = {"grid", "topo", "vgrid", "case", "forcings"} missing = required_top - set(config.keys()) if missing: raise ValueError(f"Config missing required top-level sections: {missing}") @@ -46,28 +46,23 @@ def validate_config_structure(config): if vgrid_cfg.get("type") not in valid_vgrid_types: raise ValueError(f"vgrid.type must be one of {valid_vgrid_types}") - if "forcings" in config: - forcings_cfg = config["forcings"] - for key in ("date_range", "boundaries", "product_name", "function_name"): - if key not in forcings_cfg: + forcings_cfg = config["forcings"] + for key in ("date_range", "boundaries", "product_name", "function_name"): + if key not in forcings_cfg: + raise ValueError(f"forcings.{key} is required") + dr = forcings_cfg["date_range"] + if not (isinstance(dr, list) and len(dr) == 2): + raise ValueError("forcings.date_range must be a list of exactly 2 date strings") + valid_boundaries = {"north", "south", "east", "west"} + bad = set(forcings_cfg["boundaries"]) - valid_boundaries + if bad: + raise ValueError(f"Invalid boundary values: {bad}") + if "tidal_constituents" in forcings_cfg: + for tide_key in ("tpxo_elevation_filepath", "tpxo_velocity_filepath"): + if tide_key not in forcings_cfg: raise ValueError( - f"forcings.{key} is required when the forcings section is present" + f"forcings.{tide_key} is required when tidal_constituents is set" ) - dr = forcings_cfg["date_range"] - if not (isinstance(dr, list) and len(dr) == 2): - raise ValueError( - "forcings.date_range must be a list of exactly 2 date strings" - ) - valid_boundaries = {"north", "south", "east", "west"} - bad = set(forcings_cfg["boundaries"]) - valid_boundaries - if bad: - raise ValueError(f"Invalid boundary values: {bad}") - if "tidal_constituents" in forcings_cfg: - for tide_key in ("tpxo_elevation_filepath", "tpxo_velocity_filepath"): - if tide_key not in forcings_cfg: - raise ValueError( - f"forcings.{tide_key} is required when tidal_constituents is set" - ) def build_grid(grid_cfg): @@ -201,6 +196,7 @@ def create_case_from_yaml(config, override=False): function_name=forcings_cfg["function_name"], **extra_kwargs, ) + case.process_forcings() return case From eff8618a985723e6f55a98783528ef88a44a39c5 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Thu, 11 Jun 2026 13:59:01 -0600 Subject: [PATCH 07/13] Rename _patch_yaml_for_fork and fix tests for required forcings - Rename _patch_yaml_for_fork -> _configure_yaml_for_forked_case_args - Add forcings to MINIMAL_VALID_CONFIG and test configs that lacked it - Switch round-trip validation tests from get_CrocoDash_case to get_case_with_cf so they pass with forcings now required - Remove test_validate_forcings_section_valid (redundant; forcings always required) --- CrocoDash/shareable/fork.py | 6 +++--- tests/shareable/test_fork.py | 6 +++--- tests/test_workflow_functions.py | 33 ++++++++++++++++---------------- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/CrocoDash/shareable/fork.py b/CrocoDash/shareable/fork.py index 8fc9a183..941feb29 100644 --- a/CrocoDash/shareable/fork.py +++ b/CrocoDash/shareable/fork.py @@ -136,7 +136,7 @@ def fork( When omitted the user is asked interactively. """ # Phase 1: build patched YAML with new destination values - config = self._patch_yaml_for_fork( + config = self._configure_yaml_for_forked_case_args( cesmroot, machine, project_number, new_caseroot, new_inputdir ) @@ -169,10 +169,10 @@ def fork( ) return self.case - def _patch_yaml_for_fork( + def _configure_yaml_for_forked_case_args( self, cesmroot, machine, project_number, new_caseroot, new_inputdir ): - """Return a copy of bundle_yaml with destination fields patched.""" + """Return a copy of bundle_yaml with destination fields configured for the forked case.""" config = copy.deepcopy(self.bundle_yaml) config["case"]["cesmroot"] = str(cesmroot) config["case"]["machine"] = machine diff --git a/tests/shareable/test_fork.py b/tests/shareable/test_fork.py index d305d388..bd991da1 100644 --- a/tests/shareable/test_fork.py +++ b/tests/shareable/test_fork.py @@ -84,8 +84,8 @@ def test_resolve_copy_plan_with_provided_plan(fake_fcb_empty_case): assert fcb.plan is provided -def test_patch_yaml_for_fork(fake_fcb_empty_case, tmp_path): - """Test _patch_yaml_for_fork correctly patches destination fields.""" +def test_configure_yaml_for_forked_case_args(fake_fcb_empty_case, tmp_path): + """Test _configure_yaml_for_forked_case_args correctly patches destination fields.""" fcb = fake_fcb_empty_case fcb.bundle_location = tmp_path / "bundle" (fcb.bundle_location / "ocnice").mkdir(parents=True) @@ -110,7 +110,7 @@ def test_patch_yaml_for_fork(fake_fcb_empty_case, tmp_path): } fcb.bundle_yaml = bundle_yaml - config = fcb._patch_yaml_for_fork( + config = fcb._configure_yaml_for_forked_case_args( cesmroot="/new/cesm", machine="new_machine", project_number="NEW123", diff --git a/tests/test_workflow_functions.py b/tests/test_workflow_functions.py index e051c153..c4482ed8 100644 --- a/tests/test_workflow_functions.py +++ b/tests/test_workflow_functions.py @@ -50,6 +50,12 @@ "compset": "CR_JRA", "machine": "derecho", }, + "forcings": { + "date_range": ["2020-01-01 00:00:00", "2020-12-31 00:00:00"], + "boundaries": ["north", "south", "east", "west"], + "product_name": "GLORYS", + "function_name": "get_glorys_data_from_rda", + }, } @@ -81,6 +87,12 @@ def test_validate_missing_case_key(missing_key): for k in ("cesmroot", "caseroot", "inputdir", "compset", "machine") if k != missing_key }, + "forcings": { + "date_range": ["2020-01-01", "2020-02-01"], + "boundaries": ["north"], + "product_name": "GLORYS", + "function_name": "get_glorys", + }, } with pytest.raises(ValueError, match=f"case\\.{missing_key}"): validate_config_structure(config) @@ -98,19 +110,6 @@ def test_validate_invalid_vgrid_type(): validate_config_structure(config) -def test_validate_forcings_section_valid(): - config = { - **MINIMAL_VALID_CONFIG, - "forcings": { - "date_range": ["2020-01-01", "2020-02-01"], - "boundaries": ["north", "east"], - "product_name": "GLORYS", - "function_name": "get_glorys", - }, - } - validate_config_structure(config) - - @pytest.mark.parametrize( "missing_key", ["date_range", "boundaries", "product_name", "function_name"] ) @@ -368,9 +367,9 @@ def test_case_to_yaml_with_forcings(get_case_with_cf): # --------------------------------------------------------------------------- -def test_case_to_yaml_round_trip_is_valid_config(get_CrocoDash_case): +def test_case_to_yaml_round_trip_is_valid_config(get_case_with_cf): """case_to_yaml output must pass validate_config_structure without error.""" - case = get_CrocoDash_case + case = get_case_with_cf config = case_to_yaml(case.caseroot) validate_config_structure(config) @@ -382,9 +381,9 @@ def test_case_to_yaml_round_trip_with_forcings_is_valid(get_case_with_cf): validate_config_structure(config) -def test_case_to_yaml_round_trip_yaml_serializable(get_CrocoDash_case, tmp_path): +def test_case_to_yaml_round_trip_yaml_serializable(get_case_with_cf, tmp_path): """case_to_yaml output can be written to YAML and reloaded identically.""" - case = get_CrocoDash_case + case = get_case_with_cf config = case_to_yaml(case.caseroot) yaml_path = tmp_path / "round_trip.yaml" From d08a3cdc97f5bfbc712637b10060dc7b3c1a89cf Mon Sep 17 00:00:00 2001 From: manishvenu Date: Fri, 12 Jun 2026 10:26:41 -0600 Subject: [PATCH 08/13] New RM6 Commit --- CrocoDash/rm6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CrocoDash/rm6 b/CrocoDash/rm6 index d8197ce0..d9b42638 160000 --- a/CrocoDash/rm6 +++ b/CrocoDash/rm6 @@ -1 +1 @@ -Subproject commit d8197ce0bad05d8ea08d6e7d4a7453ccb8551945 +Subproject commit d9b42638980469dd394ce4aa1ffc05e5bdad5e8e From 4700a8d96d1d331c99e5930445cbcbd54bfefda6 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Fri, 12 Jun 2026 10:37:29 -0600 Subject: [PATCH 09/13] This --- CrocoDash/rm6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CrocoDash/rm6 b/CrocoDash/rm6 index d9b42638..84d4a220 160000 --- a/CrocoDash/rm6 +++ b/CrocoDash/rm6 @@ -1 +1 @@ -Subproject commit d9b42638980469dd394ce4aa1ffc05e5bdad5e8e +Subproject commit 84d4a22050e6cfa135678a23104072b6a29bbab5 From db912134501e7116390f19ff7a8df09403bd2907 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Fri, 12 Jun 2026 11:35:04 -0600 Subject: [PATCH 10/13] improve some things --- CrocoDash/case.py | 30 +++- CrocoDash/cli.py | 4 +- CrocoDash/{workflow.py => recipe.py} | 140 +++++++----------- CrocoDash/shareable/bundle.py | 2 +- CrocoDash/shareable/fork.py | 2 +- docs/source/for_users/cli.md | 6 +- tests/fixtures/objects.py | 4 +- tests/test_case.py | 32 +--- ..._functions.py => test_recipe_functions.py} | 35 +---- 9 files changed, 100 insertions(+), 155 deletions(-) rename CrocoDash/{workflow.py => recipe.py} (63%) rename tests/{test_workflow_functions.py => test_recipe_functions.py} (91%) diff --git a/CrocoDash/case.py b/CrocoDash/case.py index b0329ce8..a757ba7e 100644 --- a/CrocoDash/case.py +++ b/CrocoDash/case.py @@ -93,6 +93,27 @@ def __init__( Must be in the form hh:mm:ss. If None, defaults to the CESM defaults """ + # Capture scalar init args for state serialization before any local vars are added. + # Excludes: objects (stored as paths), args resolved to derived values, and ephemeral flags. + _locals = locals() + _SERIALIZABLE_EXCLUDE = frozenset( + { + "self", + "ocn_grid", + "ocn_topo", + "ocn_vgrid", + "compset", + "machine", + "cesmroot", + "caseroot", + "inputdir", + "override", + } + ) + self._init_args = { + k: v for k, v in _locals.items() if k not in _SERIALIZABLE_EXCLUDE + } + # Initialize visualCaseGen system and get the CIME interface self.cime = initialize_visualCaseGen(cesmroot) @@ -141,6 +162,10 @@ def __init__( self.compset_lname = compset_lname self.machine = machine or self.cime.machine self.project = project + self.rof_grid_name = rof_grid_name + self.ntasks_ocn = ntasks_ocn + self.job_queue = job_queue + self.job_wallclock_time = job_wallclock_time # Using visualCaseGen's configuration system, set the configuration variables for the case # based on the provided arguments. This includes setting the compset, grid, and launch variables. @@ -879,6 +904,7 @@ def _configure_launch(self): def _write_state(self): """Write case creation parameters to crocodash_state.json in caseroot.""" state = { + # Derived / resolved fields that can't come from init args directly "inputdir": str(self.inputdir), "cesmroot": str(self.cesmroot), "supergrid_path": self.supergrid_path, @@ -888,8 +914,8 @@ def _write_state(self): "session_id": cvars["MB_ATTEMPT_ID"].value, "compset_lname": self.compset_lname, "machine": self.machine, - "project": self.project, - "atm_grid_name": self.atm_grid_name, + # Scalar init args captured at construction time + **self._init_args, } with open(self.caseroot / "crocodash_state.json", "w") as f: json.dump(state, f, indent=2) diff --git a/CrocoDash/cli.py b/CrocoDash/cli.py index e4245fb8..a7505d81 100644 --- a/CrocoDash/cli.py +++ b/CrocoDash/cli.py @@ -4,14 +4,14 @@ def _create(args): - from CrocoDash.workflow import load_config, create_case_from_yaml + from CrocoDash.recipe import load_config, create_case_from_yaml config = load_config(args.config) create_case_from_yaml(config, override=args.override) def _dump(args): - from CrocoDash.workflow import case_to_yaml + from CrocoDash.recipe import case_to_yaml import yaml config = case_to_yaml(args.caseroot) diff --git a/CrocoDash/workflow.py b/CrocoDash/recipe.py similarity index 63% rename from CrocoDash/workflow.py rename to CrocoDash/recipe.py index 23a0089b..700d535f 100644 --- a/CrocoDash/workflow.py +++ b/CrocoDash/recipe.py @@ -14,6 +14,25 @@ logger = setup_logger(__name__) +_TOPO_SOURCE_TYPES = {"flat", "dataset", "from_file"} +_VGRID_TYPES = {"uniform", "hyperbolic", "from_file"} + +# State keys that are derived/resolved at init time and cannot be passed straight back +# to Case.__init__ — handled explicitly in case_to_yaml's "case" section. +_STATE_DERIVED_KEYS = frozenset( + { + "inputdir", + "cesmroot", + "supergrid_path", + "topo_path", + "vgrid_path", + "grid_name", + "session_id", + "compset_lname", + "machine", + } +) + def load_config(path): """Read a YAML case config file, validate its structure, and return the config dict.""" @@ -37,26 +56,25 @@ def validate_config_structure(config): topo_cfg = config.get("topo", {}) source_cfg = topo_cfg.get("source", {}) - valid_topo_types = {"flat", "dataset", "from_file"} - if "source" in topo_cfg and source_cfg.get("type") not in valid_topo_types: - raise ValueError(f"topo.source.type must be one of {valid_topo_types}") + if "source" in topo_cfg and source_cfg.get("type") not in _TOPO_SOURCE_TYPES: + raise ValueError(f"topo.source.type must be one of {_TOPO_SOURCE_TYPES}") vgrid_cfg = config.get("vgrid", {}) - valid_vgrid_types = {"uniform", "hyperbolic", "from_file"} - if vgrid_cfg.get("type") not in valid_vgrid_types: - raise ValueError(f"vgrid.type must be one of {valid_vgrid_types}") + vgrid_type = vgrid_cfg.get("type") + if vgrid_type is not None and vgrid_type not in _VGRID_TYPES: + raise ValueError(f"vgrid.type must be one of {_VGRID_TYPES}") forcings_cfg = config["forcings"] - for key in ("date_range", "boundaries", "product_name", "function_name"): - if key not in forcings_cfg: - raise ValueError(f"forcings.{key} is required") + if "date_range" not in forcings_cfg: + raise ValueError("forcings.date_range is required") dr = forcings_cfg["date_range"] if not (isinstance(dr, list) and len(dr) == 2): raise ValueError("forcings.date_range must be a list of exactly 2 date strings") - valid_boundaries = {"north", "south", "east", "west"} - bad = set(forcings_cfg["boundaries"]) - valid_boundaries - if bad: - raise ValueError(f"Invalid boundary values: {bad}") + if "boundaries" in forcings_cfg: + valid_boundaries = {"north", "south", "east", "west"} + bad = set(forcings_cfg["boundaries"]) - valid_boundaries + if bad: + raise ValueError(f"Invalid boundary values: {bad}") if "tidal_constituents" in forcings_cfg: for tide_key in ("tpxo_elevation_filepath", "tpxo_velocity_filepath"): if tide_key not in forcings_cfg: @@ -72,18 +90,7 @@ def build_grid(grid_cfg): if grid_cfg.get("name"): grid.name = grid_cfg["name"] return grid - return Grid( - lenx=grid_cfg["lenx"], - leny=grid_cfg["leny"], - nx=grid_cfg.get("nx"), - ny=grid_cfg.get("ny"), - resolution=grid_cfg.get("resolution"), - xstart=grid_cfg.get("xstart", 0.0), - ystart=grid_cfg.get("ystart"), - cyclic_x=grid_cfg.get("cyclic_x", False), - name=grid_cfg.get("name"), - type=grid_cfg.get("type", "uniform_spherical"), - ) + return Grid(**grid_cfg) def build_topo(topo_cfg, grid): @@ -93,27 +100,14 @@ def build_topo(topo_cfg, grid): source_type = source.get("type", "flat") if source_type == "from_file": - return Topo.from_topo_file( - grid, source["topo_file_path"], min_depth=min_depth, git=False - ) + return Topo.from_topo_file(grid, source["topo_file_path"], min_depth=min_depth) - topo = Topo(grid, min_depth, git=False) + topo = Topo(grid, min_depth) if source_type == "flat": topo.set_flat(source["depth"]) elif source_type == "dataset": - topo.set_from_dataset( - bathymetry_path=source["bathymetry_path"], - longitude_coordinate_name=source.get("longitude_coordinate_name", "lon"), - latitude_coordinate_name=source.get("latitude_coordinate_name", "lat"), - vertical_coordinate_name=source.get( - "vertical_coordinate_name", "elevation" - ), - fill_channels=source.get("fill_channels", False), - is_input_positive_below_msl=source.get( - "is_input_positive_below_msl", False - ), - ) + topo.set_from_dataset(**{k: v for k, v in source.items() if k != "type"}) else: raise ValueError(f"Unknown topo.source.type: '{source_type}'") @@ -125,28 +119,16 @@ def build_vgrid(vgrid_cfg, topo): vgrid_type = vgrid_cfg.get("type", "uniform") if vgrid_type == "from_file": - return VGrid.from_file( - filename=vgrid_cfg["filename"], - variable_name=vgrid_cfg.get("variable_name", "dz"), - variable_type=vgrid_cfg.get("variable_type", "layer_thickness"), - name=vgrid_cfg.get("name"), - ) + return VGrid.from_file(**{k: v for k, v in vgrid_cfg.items() if k != "type"}) depth = vgrid_cfg.get("depth") or topo.max_depth + kwargs = {k: v for k, v in vgrid_cfg.items() if k not in ("type", "depth")} + kwargs["depth"] = depth if vgrid_type == "uniform": - return VGrid.uniform( - nk=vgrid_cfg["nk"], - depth=depth, - name=vgrid_cfg.get("name"), - ) + return VGrid.uniform(**kwargs) elif vgrid_type == "hyperbolic": - return VGrid.hyperbolic( - nk=vgrid_cfg["nk"], - depth=depth, - ratio=vgrid_cfg["ratio"], - name=vgrid_cfg.get("name"), - ) + return VGrid.hyperbolic(**kwargs) else: raise ValueError(f"Unknown vgrid.type: '{vgrid_type}'") @@ -155,48 +137,24 @@ def create_case_from_yaml(config, override=False): """ Run the full case creation workflow from a config dict. - Builds Grid, Topo, and VGrid objects, creates the CESM case, and (if a - forcings section is present) calls configure_forcings. Returns the Case. + Builds Grid, Topo, and VGrid objects, creates the CESM case, then calls + configure_forcings and process_forcings. A forcings section is required. + Returns the Case. """ grid = build_grid(config["grid"]) topo = build_topo(config["topo"], grid) vgrid = build_vgrid(config["vgrid"], topo) - case_cfg = config["case"] case = Case( - cesmroot=case_cfg["cesmroot"], - caseroot=case_cfg["caseroot"], - inputdir=case_cfg["inputdir"], - compset=case_cfg["compset"], ocn_grid=grid, ocn_topo=topo, ocn_vgrid=vgrid, - atm_grid_name=case_cfg.get("atm_grid_name", "TL319"), - rof_grid_name=case_cfg.get("rof_grid_name"), - ninst=case_cfg.get("ninst", 1), - machine=case_cfg["machine"], - project=case_cfg.get("project"), override=override, - ntasks_ocn=case_cfg.get("ntasks_ocn"), - job_queue=case_cfg.get("job_queue"), - job_wallclock_time=case_cfg.get("job_wallclock_time"), + **config["case"], ) - if "forcings" in config: - forcings_cfg = config["forcings"] - extra_kwargs = { - k: v - for k, v in forcings_cfg.items() - if k not in ("date_range", "boundaries", "product_name", "function_name") - } - case.configure_forcings( - date_range=forcings_cfg["date_range"], - boundaries=forcings_cfg["boundaries"], - product_name=forcings_cfg["product_name"], - function_name=forcings_cfg["function_name"], - **extra_kwargs, - ) - case.process_forcings() + case.configure_forcings(**config["forcings"]) + case.process_forcings() return case @@ -278,13 +236,15 @@ def case_to_yaml(caseroot): "filename": state["vgrid_path"], }, "case": { + # Derived/resolved fields — require explicit mapping from state keys "cesmroot": state["cesmroot"], "caseroot": str(caseroot), "inputdir": state["inputdir"], "compset": state["compset_lname"], "machine": state["machine"], - "project": state.get("project"), - "atm_grid_name": state.get("atm_grid_name", "TL319"), + # Scalar init args stored verbatim by Case._init_args — pull dynamically + # so new Case.__init__ params flow through without touching this function. + **{k: v for k, v in state.items() if k not in _STATE_DERIVED_KEYS}, }, } diff --git a/CrocoDash/shareable/bundle.py b/CrocoDash/shareable/bundle.py index 95480610..7fa0aa1b 100644 --- a/CrocoDash/shareable/bundle.py +++ b/CrocoDash/shareable/bundle.py @@ -30,7 +30,7 @@ ) from CrocoDash.topo import * from CrocoDash.vgrid import * -from CrocoDash.workflow import ( +from CrocoDash.recipe import ( case_to_yaml, create_case_from_yaml, generate_configure_forcing_args, diff --git a/CrocoDash/shareable/fork.py b/CrocoDash/shareable/fork.py index 941feb29..d390a39a 100644 --- a/CrocoDash/shareable/fork.py +++ b/CrocoDash/shareable/fork.py @@ -17,7 +17,7 @@ from CrocoDash.shareable.apply import * from CrocoDash.topo import Topo from CrocoDash.vgrid import VGrid -from CrocoDash.workflow import create_case_from_yaml, generate_configure_forcing_args +from CrocoDash.recipe import create_case_from_yaml, generate_configure_forcing_args logger = setup_logger(__name__) diff --git a/docs/source/for_users/cli.md b/docs/source/for_users/cli.md index 6bb6bea9..4f478966 100644 --- a/docs/source/for_users/cli.md +++ b/docs/source/for_users/cli.md @@ -16,7 +16,7 @@ crocodash duplicate --source /path/to/case --case /path/to/new_case --inputdir / ## `crocodash create` -Creates a new CrocoDash case end-to-end from a YAML config file. Equivalent to calling `workflow.create_case_from_yaml()`. +Creates a new CrocoDash case end-to-end from a YAML config file. Equivalent to calling `recipe.create_case_from_yaml()`. ```bash crocodash create --config mycase.yaml @@ -78,7 +78,7 @@ case: project: "NCGD0011" atm_grid_name: "TL319" # optional, default TL319 -# --- Forcings (optional — skip section to stop after case creation) --- +# --- Forcings (required) --- forcings: date_range: ["2020-01-01 00:00:00", "2020-02-01 00:00:00"] boundaries: ["south", "east", "west"] @@ -91,7 +91,7 @@ forcings: # tpxo_velocity_filepath: "/path/to/TPXO_velocity.nc" ``` -After `create` completes the caseroot contains a `crocodash_state.json` recording all construction parameters, and (if forcings were configured) `inputdir/extract_forcings/config.json` recording the forcing setup. These files are the source of truth for `dump`, `bundle`, and `fork`. +After `create` completes the caseroot contains a `crocodash_state.json` recording all construction parameters, and `inputdir/extract_forcings/config.json` recording the forcing setup. These files are the source of truth for `dump`, `bundle`, and `fork`. --- diff --git a/tests/fixtures/objects.py b/tests/fixtures/objects.py index fee60a68..612502de 100644 --- a/tests/fixtures/objects.py +++ b/tests/fixtures/objects.py @@ -26,7 +26,7 @@ def setup_sample_rm6_expt(tmp_path): return expt -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def get_case_with_cf(CrocoDash_case_factory, tmp_path_factory): case = CrocoDash_case_factory(tmp_path_factory.mktemp(f"case-{uuid4().hex}")) case.configure_forcings( @@ -36,7 +36,7 @@ def get_case_with_cf(CrocoDash_case_factory, tmp_path_factory): return case -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def get_CrocoDash_case(CrocoDash_case_factory, tmp_path_factory): # Set some defaults diff --git a/tests/test_case.py b/tests/test_case.py index 765d8aee..216709c8 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -78,24 +78,8 @@ def test_create_grid_input(get_CrocoDash_case): assert len(files) > 0 -def test_case_expt_smoke(get_CrocoDash_case, tmp_path): - case = get_CrocoDash_case - case.configure_forcings( - date_range=["2020-01-01 00:00:00", "2020-02-01 00:00:00"], - tidal_constituents=["M2"], - tpxo_elevation_filepath=tmp_path, - tpxo_velocity_filepath=tmp_path, - chl_processed_filepath=tmp_path, - boundaries=["north", "south", "east"], - ) - assert case.expt is not None - - -def test_configure_forcings(get_CrocoDash_case, tmp_path): - """ - Test that the setup for the forcings works - """ - case = get_CrocoDash_case +def test_configure_forcings(CrocoDash_case_factory, tmp_path_factory, tmp_path): + case = CrocoDash_case_factory(tmp_path_factory.mktemp(f"case-{uuid4().hex}")) case.configure_forcings( date_range=["2020-01-01 00:00:00", "2020-02-01 00:00:00"], tidal_constituents=["M2"], @@ -105,16 +89,14 @@ def test_configure_forcings(get_CrocoDash_case, tmp_path): boundaries=["north", "south", "east"], ) + assert case.expt is not None assert case.date_range[0].year == 2020 assert case.fcr["tides"].tidal_constituents == ["M2"] assert case.boundaries == ["north", "south", "east"] -def test_process_forcing(get_CrocoDash_case, tmp_path): - """ - Test that the setup for the forcings works - """ - case = get_CrocoDash_case +def test_process_forcing(CrocoDash_case_factory, tmp_path_factory, tmp_path): + case = CrocoDash_case_factory(tmp_path_factory.mktemp(f"case-{uuid4().hex}")) case.configure_forcings( date_range=["2020-01-01 00:00:00", "2020-02-01 00:00:00"], tidal_constituents=["M2"], @@ -140,8 +122,8 @@ def test_process_forcing(get_CrocoDash_case, tmp_path): ) -def test_update_forcing_variables(get_CrocoDash_case): - case = get_CrocoDash_case +def test_update_forcing_variables(CrocoDash_case_factory, tmp_path_factory): + case = CrocoDash_case_factory(tmp_path_factory.mktemp(f"case-{uuid4().hex}")) search_string = "OBC_NUMBER_OF_SEGMENTS" found_user_nl_mom_adjusted_var = False diff --git a/tests/test_workflow_functions.py b/tests/test_recipe_functions.py similarity index 91% rename from tests/test_workflow_functions.py rename to tests/test_recipe_functions.py index c4482ed8..46bd3dd7 100644 --- a/tests/test_workflow_functions.py +++ b/tests/test_recipe_functions.py @@ -1,5 +1,5 @@ """ -Unit tests for CrocoDash/workflow.py. +Unit tests for CrocoDash/recipe.py. Covers: - validate_config_structure: valid/invalid variants @@ -14,7 +14,7 @@ import yaml from pathlib import Path -from CrocoDash.workflow import ( +from CrocoDash.recipe import ( build_grid, build_topo, build_vgrid, @@ -110,19 +110,14 @@ def test_validate_invalid_vgrid_type(): validate_config_structure(config) -@pytest.mark.parametrize( - "missing_key", ["date_range", "boundaries", "product_name", "function_name"] -) -def test_validate_forcings_missing_required(missing_key): +def test_validate_forcings_missing_date_range(): forcings = { - "date_range": ["2020-01-01", "2020-02-01"], "boundaries": ["north"], "product_name": "GLORYS", "function_name": "get_glorys", } - del forcings[missing_key] config = {**MINIMAL_VALID_CONFIG, "forcings": forcings} - with pytest.raises(ValueError, match=f"forcings\\.{missing_key}"): + with pytest.raises(ValueError, match="forcings\\.date_range"): validate_config_structure(config) @@ -374,15 +369,8 @@ def test_case_to_yaml_round_trip_is_valid_config(get_case_with_cf): validate_config_structure(config) -def test_case_to_yaml_round_trip_with_forcings_is_valid(get_case_with_cf): - """case_to_yaml output including forcings must pass validate_config_structure.""" - case = get_case_with_cf - config = case_to_yaml(case.caseroot) - validate_config_structure(config) - - -def test_case_to_yaml_round_trip_yaml_serializable(get_case_with_cf, tmp_path): - """case_to_yaml output can be written to YAML and reloaded identically.""" +def test_case_to_yaml_round_trip(get_case_with_cf, tmp_path): + """case_to_yaml output can be written to YAML and reloaded identically, including forcings.""" case = get_case_with_cf config = case_to_yaml(case.caseroot) @@ -398,17 +386,6 @@ def test_case_to_yaml_round_trip_yaml_serializable(get_case_with_cf, tmp_path): == config["topo"]["source"]["topo_file_path"] ) assert reloaded["vgrid"]["filename"] == config["vgrid"]["filename"] - - -def test_case_to_yaml_round_trip_forcings_preserved(get_case_with_cf, tmp_path): - """Forcings date_range and boundaries survive a YAML write-reload round-trip.""" - case = get_case_with_cf - config = case_to_yaml(case.caseroot) - - yaml_path = tmp_path / "round_trip_forcings.yaml" - yaml_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) - reloaded = yaml.safe_load(yaml_path.read_text()) - assert reloaded["forcings"]["date_range"] == config["forcings"]["date_range"] assert reloaded["forcings"]["boundaries"] == config["forcings"]["boundaries"] assert reloaded["forcings"]["product_name"] == config["forcings"]["product_name"] From c98b042cc27c6090472e4b342f412a0262fdf23b Mon Sep 17 00:00:00 2001 From: manishvenu Date: Fri, 12 Jun 2026 11:50:48 -0600 Subject: [PATCH 11/13] Changes --- CrocoDash/shareable/bundle.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CrocoDash/shareable/bundle.py b/CrocoDash/shareable/bundle.py index 7fa0aa1b..b7bad1f6 100644 --- a/CrocoDash/shareable/bundle.py +++ b/CrocoDash/shareable/bundle.py @@ -119,7 +119,7 @@ def _read_xmlfiles(self): def _read_sourcemods(self): self.sourcemods = { - f.relative_to(self.caseroot / "SourceMods") + str(f.relative_to(self.caseroot / "SourceMods")) for f in (self.caseroot / "SourceMods").rglob("*") if f.is_file() } @@ -165,7 +165,7 @@ def _read_user_nl_lines_as_obj(self, user_nl_comp="mom"): if not hasattr(self, "user_nl_reader"): # Import the CESM MOM_interface user_nl_mom reader - mod_path = ( + mod_path = str( self.cesmroot / "components" / "mom" @@ -178,7 +178,7 @@ def _read_user_nl_lines_as_obj(self, user_nl_comp="mom"): spec.loader.exec_module(self.user_nl_reader) return self.user_nl_reader.FType_MOM_params.from_MOM_input( - self.caseroot / f"user_nl_{user_nl_comp}" + str(self.caseroot / f"user_nl_{user_nl_comp}") )._data def _get_cesmroot(self): @@ -215,9 +215,7 @@ def diff(self, other_case): return BundleDifferences( xml_files_missing_in_new=sorted(list(self.xmlfiles - other_case.xmlfiles)), - source_mods_missing_files=sorted( - [str(f) for f in self.sourcemods - other_case.sourcemods] - ), + source_mods_missing_files=sorted(self.sourcemods - other_case.sourcemods), xmlchanges_missing=sorted( k for k in self.xmlchanges if k not in other_case.xmlchanges ), From d654be5475a90431214be17073efe17616b9158f Mon Sep 17 00:00:00 2001 From: manishvenu Date: Fri, 12 Jun 2026 13:50:47 -0600 Subject: [PATCH 12/13] Bugs --- tests/fixtures/objects.py | 12 ++++++++++++ tests/shareable/test_bundle.py | 12 +++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/objects.py b/tests/fixtures/objects.py index 612502de..a8423e8e 100644 --- a/tests/fixtures/objects.py +++ b/tests/fixtures/objects.py @@ -36,6 +36,18 @@ def get_case_with_cf(CrocoDash_case_factory, tmp_path_factory): return case +@pytest.fixture(scope="session") +def get_shareable_CrocoDash_case(CrocoDash_case_factory, tmp_path_factory): + case = CrocoDash_case_factory(tmp_path_factory.mktemp(f"case-{uuid4().hex}")) + case.configure_forcings( + date_range=["2020-01-01 00:00:00", "2020-01-09 00:00:00"], + tidal_constituents=["M2"], + tpxo_elevation_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/h_tpxo9.v1.zarr/", + tpxo_velocity_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/u_tpxo9.v1.zarr/", + ) + return case + + @pytest.fixture(scope="session") def get_CrocoDash_case(CrocoDash_case_factory, tmp_path_factory): diff --git a/tests/shareable/test_bundle.py b/tests/shareable/test_bundle.py index 5e917600..746d724a 100644 --- a/tests/shareable/test_bundle.py +++ b/tests/shareable/test_bundle.py @@ -99,15 +99,9 @@ def test_load_state_from_crocodash_forcing_config( assert "tides" in rcc.forcing_config -def test_identify_non_standard_case_information(get_CrocoDash_case): +def test_identify_non_standard_case_information(get_shareable_CrocoDash_case): - case1 = get_CrocoDash_case - case1.configure_forcings( - date_range=["2020-01-01 00:00:00", "2020-01-09 00:00:00"], - tidal_constituents=["M2"], - tpxo_elevation_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/h_tpxo9.v1.zarr/", - tpxo_velocity_filepath="s3://crocodile-cesm/CrocoDash/data/tpxo/u_tpxo9.v1.zarr/", - ) + case1 = get_shareable_CrocoDash_case xml_file = Path(case1.caseroot) / "test.xml" xml_file.write_text("data") @@ -296,7 +290,7 @@ def test_read_sourcemods(fake_RCC_empty_case, tmp_path): case._read_sourcemods() # Expected relative paths - expected = {Path("src.drv/file1.txt"), Path("src.mom/file2.txt")} + expected = {"src.drv/file1.txt", "src.mom/file2.txt"} # Assert assert case.sourcemods == expected From a00399373438643afc442b3f310365c3f1d85429 Mon Sep 17 00:00:00 2001 From: manishvenu Date: Sun, 14 Jun 2026 09:20:57 -0600 Subject: [PATCH 13/13] This --- tests/extract_forcings/test_case_integration.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/extract_forcings/test_case_integration.py b/tests/extract_forcings/test_case_integration.py index 08bbe6e8..e5bd9f7b 100644 --- a/tests/extract_forcings/test_case_integration.py +++ b/tests/extract_forcings/test_case_integration.py @@ -2,8 +2,8 @@ import json -def test_case_integration_driver(get_CrocoDash_case, skip_if_not_glade): - case = get_CrocoDash_case +def test_case_integration_driver(CrocoDash_case_factory, tmp_path, skip_if_not_glade): + case = CrocoDash_case_factory(tmp_path) case.configure_forcings( date_range=["2020-01-01 00:00:00", "2020-01-02 00:00:00"], boundaries=["north", "south", "east"], @@ -22,8 +22,8 @@ def test_case_integration_driver(get_CrocoDash_case, skip_if_not_glade): return -def test_case_integration_config(get_CrocoDash_case): - case = get_CrocoDash_case +def test_case_integration_config(CrocoDash_case_factory, tmp_path): + case = CrocoDash_case_factory(tmp_path) case.configure_forcings( date_range=["2020-01-01 00:00:00", "2020-02-01 00:00:00"], boundaries=["north", "south", "east"], @@ -45,11 +45,11 @@ def test_case_integration_config(get_CrocoDash_case): } -def test_driver_works(get_CrocoDash_case, tmp_path): +def test_driver_works(CrocoDash_case_factory, tmp_path): """ Test that the setup for the forcings works """ - case = get_CrocoDash_case + case = CrocoDash_case_factory(tmp_path / "case") case.configure_forcings( date_range=["2020-01-01 00:00:00", "2020-02-01 00:00:00"], tidal_constituents=["M2"],