From 8103bd37393958f20e0ace9dc81541dc324042d4 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 30 Sep 2026 10:55:54 +0200 Subject: [PATCH 1/3] Add the ocx experiment The observationally constrained experiment (C011) was missing from experiments_ismip7.csv, so the checker refused every ocx file. It now starts in any year from 1950, the earliest year of any OCX forcing, to 2015, and ends in 2025. OCX has no ESM: its atmosphere comes from ERA5, the Greenland ocean from EN4 and the Antarctic ocean from expert judgement. An ocx file may leave the ESM field out or put any name there but a CMIP model's. A name without the field is normalized to ten fields with an empty ESM by _file_name_fields, which every place that reads fields by index now uses, and the companion lookup rebuilds the name from the file's own fields so that it keeps the omission. Like historical, ocx chooses its own start year, so litemp must carry a snapshot at the run's first year as well as its last. The generator writes that snapshot, and leaves the ESM field out when given an empty ESM id. An experiment named in the wrong case (OCX, as the forcing directories spell it) is now told that experiment names are lower case. The 9-field match is case-insensitive so that such a file reaches that hint rather than a field-count error. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/_ext/data_request_tables.py | 6 +- docs/dev/generating-test-files.md | 2 +- docs/user/checks.md | 10 +++ docs/user/data-request.md | 6 +- docs/user/time-encoding.md | 1 + isschecker/checker.py | 95 ++++++++++++++------ isschecker/data/experiments_ismip7.csv | 1 + isschecker/generate.py | 25 +++--- tests/test_compliance_checker.py | 119 +++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 47 deletions(-) diff --git a/docs/_ext/data_request_tables.py b/docs/_ext/data_request_tables.py index 2832ec8..18e6c77 100644 --- a/docs/_ext/data_request_tables.py +++ b/docs/_ext/data_request_tables.py @@ -214,9 +214,9 @@ def _experiments_table(rows: list[dict[str, str]]) -> list[str]: _plain(row['start_year_min']), _plain(row['start_year_max']), _plain(row['end_year']), - # `historical` carries -1, which is the file's way of saying - # that its duration follows from the start year the modeler - # chose rather than being fixed by the protocol. + # `historical` and `ocx` carry -1, which is the file's way of + # saying that the duration follows from the start year the + # modeler chose rather than being fixed by the protocol. 'set by the start year' if row['duration'].strip() == '-1' else _plain(row['duration']), diff --git a/docs/dev/generating-test-files.md b/docs/dev/generating-test-files.md index 4a2283e..43cc772 100644 --- a/docs/dev/generating-test-files.md +++ b/docs/dev/generating-test-files.md @@ -68,7 +68,7 @@ ismip7-generate-test-files --grid GrIS_16000m --scenario ctrl \ - Time encoding: `days since 1850-01-01`, `calendar='standard'`. - State (ST) variables: timestamp is Jan 1 of year N+1 (end-of-year snapshot). No `time_bounds`. - Flux (FL) variables: timestamp is Jul 1 of year N (mid-year), with `time_bounds` = [Jan 1 of N, Jan 1 of N+1]. - - `x,y,z,t` variables (e.g. `litemp`): ST snapshots at a sparse set of nominal years. For `historical`: first year of run, 1900 (if in range), last year of run. For projection scenarios: 2100, 2200, 2300 (each if within the simulation year range). The filename year range reflects the full simulation period, not the first/last snapshot year. The set is `CENTURY_SNAPSHOT_YEARS` in `isschecker.checker`, imported here so the generator and the checker cannot disagree; note that no snapshot is written at 2000, which the checker accepts but does not require (see {doc}`../user/time-encoding`). + - `x,y,z,t` variables (e.g. `litemp`): ST snapshots at a sparse set of nominal years. For `historical` and `ocx`: first year of run, 1900 (if in range), last year of run; `--esm-id ''` leaves the ESM field out, as `ocx` allows. For projection scenarios: 2100, 2200, 2300 (each if within the simulation year range). The filename year range reflects the full simulation period, not the first/last snapshot year. The set is `CENTURY_SNAPSHOT_YEARS` in `isschecker.checker`, imported here so the generator and the checker cannot disagree; note that no snapshot is written at 2000, which the checker accepts but does not require (see {doc}`../user/time-encoding`). - An analytic ice sheet, so that the files agree with one another. Most variables are still drawn at random within the min/max range the data request allows them, but seven cannot be: `sftgif`, `sftgrf`, `sftflf`, `lithk`, `topg`, `base` and `orog` are computed from a radially symmetric dome on a bowl-shaped bed, because the checker compares them against each other and a random thickness does not agree with a random mask about where the ice is. The identities hold exactly rather than to a tolerance: grounded plus floating fraction is the ice fraction, thickness is positive exactly where the ice fraction is, surface elevation is base plus thickness, and the ice base rests on the bed where the ice is grounded and above it where it floats. The geometry is deliberately analytic rather than taken from BedMachine or Bedmap: a synthetic file's job is to be predictable, and depending on one dataset would invite depending on datasets for everything else. - Missing values follow each variable's `fill_policy`. A variable the request defines only where there is ice is written as the fill value wherever the ice fraction is zero, and likewise for grounded-only and floating-only variables; the ones defined only inside the computational domain are missing outside it; and the rest are defined everywhere. - Single precision (`float32`) for all variables and time. diff --git a/docs/user/checks.md b/docs/user/checks.md index 0111623..5d570aa 100644 --- a/docs/user/checks.md +++ b/docs/user/checks.md @@ -24,6 +24,16 @@ lithk_GrIS_VUW_PISM1_m001_CESM2-WACCM_f001_ctrl_C001_2015-2300.nc Each field is checked. What the year range *means* is checked under [Time](#4-time). +The ocx experiment is not forced by an ESM. Its files may leave the ESM +field out, or put a name there that is not a CMIP model name: + +``` +lithk_GrIS_VUW_PISM1_m001_f001_ocx_C011_1979-2025.nc +lithk_GrIS_VUW_PISM1_m001_ERA5_f001_ocx_C011_1979-2025.nc +``` + +Experiment names are lower case: ocx, not OCX as in the forcing directories. + Inside the file, the variable the name promises is present with the dimensions the data request asks for, in (time, z, y, x) order. Nothing else is in the file beyond the coordinates and the companion variables CF lets diff --git a/docs/user/data-request.md b/docs/user/data-request.md index e4b64dc..69a66d4 100644 --- a/docs/user/data-request.md +++ b/docs/user/data-request.md @@ -67,9 +67,9 @@ everywhere; see [Value ranges](errors-and-warnings.md#value-ranges). ## Experiments An experiment's row fixes the nominal years its files may cover, and with -them the time axis every annual file must carry. The historical run is the -one experiment whose start year the modeler chooses; every projection runs -from 2015 to a fixed end year. +them the time axis every annual file must carry. The modeler chooses the +start year of historical and ocx, within the range given; every projection +runs from 2015 to a fixed end year. ```{include} ../_generated/experiments.md ``` diff --git a/docs/user/time-encoding.md b/docs/user/time-encoding.md index 242280d..e32231a 100644 --- a/docs/user/time-encoding.md +++ b/docs/user/time-encoding.md @@ -32,6 +32,7 @@ series. Which nominal years are required depends on the experiment: |---|---| | historical | first year of the run, 1900 (if in range), last year of the run (2014) | | projection (ssp585, ctrl, ...) | 2100, 2200, 2300 (each if within the run) | +| ocx | first year of the run, last year of the run (2025) | Together, a historical run and a projection give snapshots at the first historical year, 1900, 2014, 2100, 2200 and 2300. A projection's initial diff --git a/isschecker/checker.py b/isschecker/checker.py index 41f4120..fd2fa57 100644 --- a/isschecker/checker.py +++ b/isschecker/checker.py @@ -13,7 +13,9 @@ # An unrecognised region costs only the checks that need it (value range, # grid extent and resolution, crs); the rest of the file is still checked. # - ISM member id (field 4) matches format mNNN (e.g. m001). -# - ESM name (field 5) is a recognised CMIP6/CMIP7 model name. +# - ESM name (field 5) is a recognised CMIP6/CMIP7 model name. An +# experiment not forced by an ESM (ocx) may leave the field out or put +# any other name there, but not a CMIP model name. # - Forcing member id (field 6) matches format fNNN (e.g. f001). # - Set counter (field 8) matches format [C|E|P]NNN (e.g. C001, E041, P132). # - Year range (field 9) matches format YYYY-YYYY and start <= end. What the @@ -73,7 +75,7 @@ # cadence reasoning cannot see. # - For x,y,z,t snapshot variables the axis is instead checked against the # required set of snapshot nominal years: the run's last year, the century -# marks inside it, and (for historical only) the run's first year. A +# marks inside it, and (for historical and ocx only) the run's first year. A # missing snapshot is an error; an unasked-for one is a warning, since the # request specifies the snapshots as a minimum set. That includes a # snapshot at 2000, which the data request does not ask for -- see @@ -253,6 +255,31 @@ def _read_data_csv(filename: str, **kwargs) -> pd.DataFrame: "UKESM2-0-LL", } +# Experiments not forced by an ESM. OCX is forced by reanalysis (ERA5 for the +# atmosphere, EN4 for the Greenland ocean) and by expert judgement (the +# Antarctic ocean), so no one name describes it. Its files may leave the ESM +# field out, or put any name there but a CMIP model's. +EXPERIMENTS_WITHOUT_ESM = frozenset({"ocx"}) + + +def _file_name_fields(file_name: str) -> list[str] | None: + """The ten fields of an ISMIP7 file name, or None if it has another shape. + + A file of an experiment without an ESM may leave the ESM field out; it is + returned as an empty field, so that every field keeps its index. The + experiment is matched regardless of case, so that a file naming it OCX is + told it should be ocx rather than that it has too few fields. + """ + parts = file_name.split("_") + if len(parts) == ISMIP7_FILENAME_PARTS: + return parts + if ( + len(parts) == ISMIP7_FILENAME_PARTS - 1 + and parts[ISMIP7_FILENAME_EXPERIMENT_IDX - 1].lower() in EXPERIMENTS_WITHOUT_ESM + ): + return parts[:ISMIP7_FILENAME_ESM_IDX] + [""] + parts[ISMIP7_FILENAME_ESM_IDX:] + return None + class Reporter: """Writes the log and counts what it wrote, by severity and category. @@ -698,10 +725,10 @@ def _timestamp_to_nominal_year(timestamp, var_type: str) -> int: def _expected_nominal_years(exp: dict, start_year: int) -> list[int]: """The nominal simulation years a file for this experiment should carry. - Every experiment but 'historical' pins its start year in + Every experiment but 'historical' and 'ocx' pins its start year in experiments_ismip7.csv, so the whole axis follows from the table alone. - 'historical' may start anywhere in [start_year_min, start_year_max] (which - is what a duration of -1 records), so it needs one number from the file -- + Those two may start anywhere in [start_year_min, start_year_max] (which is + what a duration of -1 records), so they need one number from the file -- see :func:`_axis_start_year`. """ if exp["duration"] != -1: @@ -866,8 +893,8 @@ def _required_snapshot_years(exp: dict, run_years: list[int]) -> set[int]: The final year of the run is always reported, as are the century marks inside it. The *first* year is only required where the protocol leaves it - open -- that is, for 'historical', whose start year is the modeller's choice - (duration -1) and so is not recorded anywhere else. A projection's initial + open -- that is, for 'historical' and 'ocx', whose start year is the + modeller's choice (duration -1) and so is not recorded anywhere else. A projection's initial state is the historical run's final state, already reported as historical's last-year snapshot, so requiring it again in every projection would ask for the same field twice. @@ -987,8 +1014,8 @@ def _group_files_by_experiment(source_path: str) -> dict: for f in sorted(os.listdir(source_path)): if not f.endswith(".nc"): continue - parts = f.split("_") - exp_name = parts[ISMIP7_FILENAME_EXPERIMENT_IDX] if len(parts) == ISMIP7_FILENAME_PARTS else "_unknown" + fields = _file_name_fields(f) + exp_name = fields[ISMIP7_FILENAME_EXPERIMENT_IDX] if fields is not None else "_unknown" if exp_name not in groups: groups[exp_name] = [] groups[exp_name].append(f) @@ -1279,12 +1306,21 @@ def _process_single_experiment( reporter.write(" ** Experiment: " + experiment_name + "\n ") reporter.write("**********************************************************\n") reporter.write("\n ") + experiment_names = [exp["experiment"] for exp in experiments] + # The forcing directories are named OCX, so a group copying them gets + # the case wrong; experiment names are lower case, like ssp585. + case_hint = ( + f" Experiment names are lower case: '{experiment_name.lower()}'." + if experiment_name.lower() in experiment_names + else "" + ) naming_reporter.error( "The compliance check is ignored for experiment " + experiment_name + " as it is not in " - + str([exp["experiment"] for exp in experiments]) + + str(experiment_names) + "." + + case_hint ) report_naming_issues.append( "Compliance check ignored : experiment " @@ -1330,7 +1366,8 @@ def _process_single_file( return file_variables = list(ds.data_vars) - if len(file_name_split) != ISMIP7_FILENAME_PARTS: + fields = _file_name_fields(file_name) + if fields is None: naming_reporter.error( "the file name " + file_name @@ -1345,7 +1382,7 @@ def _process_single_file( ) return - experiment_varname = file_name_split[ISMIP7_FILENAME_EXPERIMENT_IDX] + experiment_varname = fields[ISMIP7_FILENAME_EXPERIMENT_IDX] if experiment_varname != experiment_name: naming_reporter.error( "in the file name " @@ -1484,8 +1521,8 @@ def _check_naming( + " due to wrong naming." ) - parts = file_name.split("_") - if len(parts) == ISMIP7_FILENAME_PARTS: + parts = _file_name_fields(file_name) + if parts is not None: ism_member = parts[ISMIP7_FILENAME_ISM_MEMBER_IDX] if not re.fullmatch(r"m\d{3}", ism_member): reporter.error( @@ -1493,7 +1530,13 @@ def _check_naming( ) esm_name = parts[ISMIP7_FILENAME_ESM_IDX] - if esm_name not in VALID_ESM_NAMES: + experiment_name = parts[ISMIP7_FILENAME_EXPERIMENT_IDX] + if experiment_name in EXPERIMENTS_WITHOUT_ESM: + if esm_name in VALID_ESM_NAMES: + reporter.error( + f"ESM name '{esm_name}' (field {ISMIP7_FILENAME_ESM_IDX}) is a CMIP model name, but experiment '{experiment_name}' is not forced by an ESM." + ) + elif esm_name not in VALID_ESM_NAMES: reporter.error( f"ESM name '{esm_name}' (field {ISMIP7_FILENAME_ESM_IDX}) is not a recognised CMIP6/CMIP7 model name." ) @@ -1978,17 +2021,17 @@ def _companion_path(source_path: str, file_name: str, variable: str) -> str | No accepted when it picks out exactly one file, since guessing between several would be worse than not checking. """ - parts = file_name.split("_") - if len(parts) != ISMIP7_FILENAME_PARTS: + if _file_name_fields(file_name) is None: return None - exact = list(parts) - exact[ISMIP7_FILENAME_VAR_IDX] = variable - candidate = os.path.join(source_path, "_".join(exact)) + # Split afresh rather than using the fields: a name without an ESM field + # has to be rebuilt without one. + parts = file_name.split("_") + candidate = os.path.join(source_path, "_".join([variable] + parts[1:])) if os.path.exists(candidate): return candidate - stem = "_".join([variable] + parts[1:ISMIP7_FILENAME_YEAR_RANGE_IDX]) + stem = "_".join([variable] + parts[1:-1]) matches = sorted(glob.glob(os.path.join(source_path, stem + "_*.nc"))) return matches[0] if len(matches) == 1 else None @@ -2743,11 +2786,11 @@ def _check_time( def _axis_start_year(exp: dict, filename_years, actual: list, var_type: str) -> int: """The nominal year the expected time axis should begin at. - Only 'historical' has a say in the matter -- every other experiment pins its - start year in experiments_ismip7.csv -- and for it the file name is what - decides: it is the file's own declared claim about its contents, and any - start year in [start_year_min, start_year_max] is permitted, so there is - nothing else to measure the file against. + Only 'historical' and 'ocx' have a say in the matter -- every other + experiment pins its start year in experiments_ismip7.csv -- and for them the + file name is what decides: it is the file's own declared claim about its + contents, and any start year in [start_year_min, start_year_max] is + permitted, so there is nothing else to measure the file against. Falls back to the axis when the file name cannot supply a usable year, so that the cadence and the end year are still checked rather than the file diff --git a/isschecker/data/experiments_ismip7.csv b/isschecker/data/experiments_ismip7.csv index ecaa1d6..3d41a3e 100644 --- a/isschecker/data/experiments_ismip7.csv +++ b/isschecker/data/experiments_ismip7.csv @@ -4,3 +4,4 @@ ssp370;2015;2015;2100;86 ssp126;2015;2015;2300;286 ssp585;2015;2015;2300;286 ctrl;2015;2015;2300;286 +ocx;1950;2015;2025;-1 diff --git a/isschecker/generate.py b/isschecker/generate.py index b20413c..1193c3b 100644 --- a/isschecker/generate.py +++ b/isschecker/generate.py @@ -490,10 +490,13 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st source_id = group ism_id = model experiment_id = scenario - filename_template = ( - f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" - f"{experiment_id}_{set_counter}_{time_range}.nc" - ) + # An empty esm_id leaves the ESM field out, as ocx allows. + run_fields = [domain_id, source_id, ism_id, ism_member_id] + if esm_id: + run_fields.append(esm_id) + run_fields += [forcing_member_id, experiment_id, set_counter] + run_id = "_".join(run_fields) + filename_template = f"{run_id}_{time_range}.nc" # Create separate file for each variable created_files = [] @@ -649,7 +652,7 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st snap_set = {end_year} | { y for y in CENTURY_SNAPSHOT_YEARS if start_year <= y <= end_year } - if scenario == 'historical': + if scenario in ('historical', 'ocx'): snap_set.add(start_year) snapshot_years = sorted(snap_set) origin = datetime(1850, 1, 1).date() @@ -712,10 +715,7 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st }) snap_time_range = f"{start_year}-{end_year}" - snap_filename_template = ( - f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" - f"{experiment_id}_{set_counter}_{snap_time_range}.nc" - ) + snap_filename_template = f"{run_id}_{snap_time_range}.nc" filename = f"{var_name}_{snap_filename_template}" output_path = output_dir / filename @@ -781,10 +781,7 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st }) # Static field: use 0000-0000 as year-range placeholder (checker skips this check) - static_filename_template = ( - f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" - f"{experiment_id}_{set_counter}_0000-0000.nc" - ) + static_filename_template = f"{run_id}_0000-0000.nc" filename = f"{var_name}_{static_filename_template}" output_path = output_dir / filename @@ -1050,7 +1047,7 @@ def main(): parser.add_argument( '--esm-id', default='CESM2-WACCM', - help='ESM id (default: CESM2-WACCM)' + help="ESM id; '' leaves the field out, as ocx allows (default: CESM2-WACCM)" ) parser.add_argument( '--forcing-member-id', diff --git a/tests/test_compliance_checker.py b/tests/test_compliance_checker.py index fb790ea..8f99957 100644 --- a/tests/test_compliance_checker.py +++ b/tests/test_compliance_checker.py @@ -639,6 +639,125 @@ def test_checker_reports_historical_time_range_violation(case_dir): ) +def generate_ocx_run(root: Path, start_year: int, include_xyt: bool) -> Path: + """An OCX run ending in 2025, with no ESM field in its file names.""" + generate_test_files.create_netcdf_file( + None, + grid_name="GrIS_16000m", + scenario="ocx", + start_year=start_year, + nyears=2025 - start_year + 1, + include_scalars=not include_xyt, + include_xyt=include_xyt, + include_non_mandatory=include_xyt, + esm_id="", + set_counter="C011", + output_root=root, + ) + return root / "GrIS" / "ISMIP7" / "SYNTH1" / "CORE" / "C011" + + +def insert_ocx_esm_field(file_path: Path, esm_name: str) -> Path: + """Put an ESM field back into an OCX file name that leaves it out.""" + parts = file_path.name.split("_") + parts.insert(checker.ISMIP7_FILENAME_ESM_IDX, esm_name) + renamed_path = file_path.with_name("_".join(parts)) + file_path.rename(renamed_path) + return renamed_path + + +def test_checker_accepts_an_ocx_run_without_an_esm_field(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 1950, include_xyt=False) + assert first_dataset(core_dir).name.endswith("_m001_f001_ocx_C011_1950-2025.nc") + + summary = run_checker(core_dir) + + assert summary["total_errors"] == 0, summary["log_text"] + assert ( + "covering nominal years 1950-2025, as experiment 'ocx' requires: OK" + in summary["log_text"] + ) + + +def test_checker_accepts_an_ocx_run_with_a_non_cmip_esm_field(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + for file_path in sorted(core_dir.glob("*.nc")): + insert_ocx_esm_field(file_path, "ERA5") + + summary = run_checker(core_dir) + + assert summary["total_errors"] == 0, summary["log_text"] + + +def test_checker_accepts_ocx_snapshots_at_the_first_and_last_year(tmp_path): + """OCX does not branch from historical, so its initial state is required.""" + core_dir = generate_ocx_run(tmp_path / "gen", 2010, include_xyt=True) + + summary = run_xyt_checker(core_dir) + + assert summary["total_errors"] == 0, summary["log_text"] + + set_time_axis( + dataset_for_variable(core_dir, "litemp"), state_timestamps([2025]) + ) + summary = run_xyt_checker(core_dir) + + assert summary["total_time_errors"] == 1 + assert ( + "required snapshot nominal year(s) missing: 2010" in summary["log_text"] + ) + + +def test_checker_reports_a_cmip_model_in_ocx(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + insert_ocx_esm_field(first_dataset(core_dir), "CESM2-WACCM") + + summary = run_checker(core_dir) + + assert summary["total_errors"] == 1 + assert summary["total_naming_errors"] == 1 + assert ( + "ESM name 'CESM2-WACCM' (field 5) is a CMIP model name, but experiment" + " 'ocx' is not forced by an ESM." in summary["log_text"] + ) + + +def test_checker_reports_ocx_starting_before_1950(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 1949, include_xyt=False) + + summary = run_checker(core_dir) + + assert ( + "the file name starts at year 1949, but experiment 'ocx' must be" + " between 1950 and 2015." in summary["log_text"] + ) + + +def test_checker_reports_ocx_ending_before_2025(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + target_file = dataset_for_variable(core_dir, "lim") + set_time_axis(target_file, state_timestamps(range(2015, 2025))) + rename_file_part(target_file, -1, "2015-2024.nc") + + summary = run_checker(core_dir) + + assert summary["total_naming_errors"] == 0 + assert ( + "the file name ends at year 2024, but experiment 'ocx' must end at 2025." + in summary["log_text"] + ) + + +def test_checker_says_experiment_names_are_lower_case(tmp_path): + """The forcing directories are named OCX; the experiment is ocx.""" + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + for file_path in sorted(core_dir.glob("*.nc")): + file_path.rename(file_path.with_name(file_path.name.replace("_ocx_", "_OCX_"))) + + summary = run_checker(core_dir) + + assert "Experiment names are lower case: 'ocx'." in summary["log_text"] + def test_checker_reports_time_axis_hollowed_out_between_correct_endpoints( ctrl_case_dir, ): From 986e2720e9a8d43140837aa2ad7a99f68eeb45b2 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 30 Sep 2026 10:56:59 +0200 Subject: [PATCH 2/3] Bump the version to 0.6.0 for the ocx experiment Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c49d5e9..7d5bbc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "isschecker" -version = "0.5.1" +version = "0.6.0" description = "ISMIP7 compliance checker for ice sheet model simulation NetCDF datasets" readme = "README.md" license = "MIT" From d89e7d915b07a809ffd0a22c368ee4ac23493563 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Wed, 30 Sep 2026 11:35:49 +0200 Subject: [PATCH 3/3] Keep the ESM field in ocx names, and accept any name there Following the discussion on #38: leaving the field out would make ocx names harder to parse downstream, and groups may use forcing other than the products ISMIP7 provides, so an ocx file carries the ESM field with any name in it, CMIP model names included. The name must be letters, digits and hyphens, which rules out an empty field and the dots other fields of the convention also forbid. This drops _file_name_fields and the generator's empty ESM id, since a name always has ten fields again. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/dev/generating-test-files.md | 2 +- docs/user/checks.md | 7 +-- isschecker/checker.py | 62 ++++++++++---------------- isschecker/generate.py | 23 +++++----- tests/test_compliance_checker.py | 72 ++++++++++++++++++------------- 5 files changed, 81 insertions(+), 85 deletions(-) diff --git a/docs/dev/generating-test-files.md b/docs/dev/generating-test-files.md index 43cc772..658a5a9 100644 --- a/docs/dev/generating-test-files.md +++ b/docs/dev/generating-test-files.md @@ -68,7 +68,7 @@ ismip7-generate-test-files --grid GrIS_16000m --scenario ctrl \ - Time encoding: `days since 1850-01-01`, `calendar='standard'`. - State (ST) variables: timestamp is Jan 1 of year N+1 (end-of-year snapshot). No `time_bounds`. - Flux (FL) variables: timestamp is Jul 1 of year N (mid-year), with `time_bounds` = [Jan 1 of N, Jan 1 of N+1]. - - `x,y,z,t` variables (e.g. `litemp`): ST snapshots at a sparse set of nominal years. For `historical` and `ocx`: first year of run, 1900 (if in range), last year of run; `--esm-id ''` leaves the ESM field out, as `ocx` allows. For projection scenarios: 2100, 2200, 2300 (each if within the simulation year range). The filename year range reflects the full simulation period, not the first/last snapshot year. The set is `CENTURY_SNAPSHOT_YEARS` in `isschecker.checker`, imported here so the generator and the checker cannot disagree; note that no snapshot is written at 2000, which the checker accepts but does not require (see {doc}`../user/time-encoding`). + - `x,y,z,t` variables (e.g. `litemp`): ST snapshots at a sparse set of nominal years. For `historical` and `ocx`: first year of run, 1900 (if in range), last year of run. For projection scenarios: 2100, 2200, 2300 (each if within the simulation year range). The filename year range reflects the full simulation period, not the first/last snapshot year. The set is `CENTURY_SNAPSHOT_YEARS` in `isschecker.checker`, imported here so the generator and the checker cannot disagree; note that no snapshot is written at 2000, which the checker accepts but does not require (see {doc}`../user/time-encoding`). - An analytic ice sheet, so that the files agree with one another. Most variables are still drawn at random within the min/max range the data request allows them, but seven cannot be: `sftgif`, `sftgrf`, `sftflf`, `lithk`, `topg`, `base` and `orog` are computed from a radially symmetric dome on a bowl-shaped bed, because the checker compares them against each other and a random thickness does not agree with a random mask about where the ice is. The identities hold exactly rather than to a tolerance: grounded plus floating fraction is the ice fraction, thickness is positive exactly where the ice fraction is, surface elevation is base plus thickness, and the ice base rests on the bed where the ice is grounded and above it where it floats. The geometry is deliberately analytic rather than taken from BedMachine or Bedmap: a synthetic file's job is to be predictable, and depending on one dataset would invite depending on datasets for everything else. - Missing values follow each variable's `fill_policy`. A variable the request defines only where there is ice is written as the fill value wherever the ice fraction is zero, and likewise for grounded-only and floating-only variables; the ones defined only inside the computational domain are missing outside it; and the rest are defined everywhere. - Single precision (`float32`) for all variables and time. diff --git a/docs/user/checks.md b/docs/user/checks.md index 5d570aa..a07c2e8 100644 --- a/docs/user/checks.md +++ b/docs/user/checks.md @@ -24,11 +24,12 @@ lithk_GrIS_VUW_PISM1_m001_CESM2-WACCM_f001_ctrl_C001_2015-2300.nc Each field is checked. What the year range *means* is checked under [Time](#4-time). -The ocx experiment is not forced by an ESM. Its files may leave the ESM -field out, or put a name there that is not a CMIP model name: +The ocx experiment is not forced by an ESM. Its files still have the ESM +field, but any name of letters, digits and hyphens is accepted there, such as +NONE or the reanalysis you used: ``` -lithk_GrIS_VUW_PISM1_m001_f001_ocx_C011_1979-2025.nc +lithk_GrIS_VUW_PISM1_m001_NONE_f001_ocx_C011_1979-2025.nc lithk_GrIS_VUW_PISM1_m001_ERA5_f001_ocx_C011_1979-2025.nc ``` diff --git a/isschecker/checker.py b/isschecker/checker.py index fd2fa57..7915284 100644 --- a/isschecker/checker.py +++ b/isschecker/checker.py @@ -14,8 +14,8 @@ # grid extent and resolution, crs); the rest of the file is still checked. # - ISM member id (field 4) matches format mNNN (e.g. m001). # - ESM name (field 5) is a recognised CMIP6/CMIP7 model name. An -# experiment not forced by an ESM (ocx) may leave the field out or put -# any other name there, but not a CMIP model name. +# experiment not forced by an ESM (ocx) may put any name of letters, +# digits and hyphens there. # - Forcing member id (field 6) matches format fNNN (e.g. f001). # - Set counter (field 8) matches format [C|E|P]NNN (e.g. C001, E041, P132). # - Year range (field 9) matches format YYYY-YYYY and start <= end. What the @@ -257,30 +257,13 @@ def _read_data_csv(filename: str, **kwargs) -> pd.DataFrame: # Experiments not forced by an ESM. OCX is forced by reanalysis (ERA5 for the # atmosphere, EN4 for the Greenland ocean) and by expert judgement (the -# Antarctic ocean), so no one name describes it. Its files may leave the ESM -# field out, or put any name there but a CMIP model's. +# Antarctic ocean), and groups may choose other products, so no one name +# describes it. Its files still carry the ESM field, so that every file name +# has the same fields, but any name of letters, digits and hyphens will do: +# a placeholder such as NONE, the forcing's source, or even a CMIP model name. EXPERIMENTS_WITHOUT_ESM = frozenset({"ocx"}) -def _file_name_fields(file_name: str) -> list[str] | None: - """The ten fields of an ISMIP7 file name, or None if it has another shape. - - A file of an experiment without an ESM may leave the ESM field out; it is - returned as an empty field, so that every field keeps its index. The - experiment is matched regardless of case, so that a file naming it OCX is - told it should be ocx rather than that it has too few fields. - """ - parts = file_name.split("_") - if len(parts) == ISMIP7_FILENAME_PARTS: - return parts - if ( - len(parts) == ISMIP7_FILENAME_PARTS - 1 - and parts[ISMIP7_FILENAME_EXPERIMENT_IDX - 1].lower() in EXPERIMENTS_WITHOUT_ESM - ): - return parts[:ISMIP7_FILENAME_ESM_IDX] + [""] + parts[ISMIP7_FILENAME_ESM_IDX:] - return None - - class Reporter: """Writes the log and counts what it wrote, by severity and category. @@ -894,8 +877,8 @@ def _required_snapshot_years(exp: dict, run_years: list[int]) -> set[int]: The final year of the run is always reported, as are the century marks inside it. The *first* year is only required where the protocol leaves it open -- that is, for 'historical' and 'ocx', whose start year is the - modeller's choice (duration -1) and so is not recorded anywhere else. A projection's initial - state is the historical run's final state, already reported as historical's + modeller's choice (duration -1) and so is not recorded anywhere else. A + projection's initial state is the historical run's final state, already reported as historical's last-year snapshot, so requiring it again in every projection would ask for the same field twice. """ @@ -1014,8 +997,8 @@ def _group_files_by_experiment(source_path: str) -> dict: for f in sorted(os.listdir(source_path)): if not f.endswith(".nc"): continue - fields = _file_name_fields(f) - exp_name = fields[ISMIP7_FILENAME_EXPERIMENT_IDX] if fields is not None else "_unknown" + parts = f.split("_") + exp_name = parts[ISMIP7_FILENAME_EXPERIMENT_IDX] if len(parts) == ISMIP7_FILENAME_PARTS else "_unknown" if exp_name not in groups: groups[exp_name] = [] groups[exp_name].append(f) @@ -1366,8 +1349,7 @@ def _process_single_file( return file_variables = list(ds.data_vars) - fields = _file_name_fields(file_name) - if fields is None: + if len(file_name_split) != ISMIP7_FILENAME_PARTS: naming_reporter.error( "the file name " + file_name @@ -1382,7 +1364,7 @@ def _process_single_file( ) return - experiment_varname = fields[ISMIP7_FILENAME_EXPERIMENT_IDX] + experiment_varname = file_name_split[ISMIP7_FILENAME_EXPERIMENT_IDX] if experiment_varname != experiment_name: naming_reporter.error( "in the file name " @@ -1521,8 +1503,8 @@ def _check_naming( + " due to wrong naming." ) - parts = _file_name_fields(file_name) - if parts is not None: + parts = file_name.split("_") + if len(parts) == ISMIP7_FILENAME_PARTS: ism_member = parts[ISMIP7_FILENAME_ISM_MEMBER_IDX] if not re.fullmatch(r"m\d{3}", ism_member): reporter.error( @@ -1532,9 +1514,9 @@ def _check_naming( esm_name = parts[ISMIP7_FILENAME_ESM_IDX] experiment_name = parts[ISMIP7_FILENAME_EXPERIMENT_IDX] if experiment_name in EXPERIMENTS_WITHOUT_ESM: - if esm_name in VALID_ESM_NAMES: + if not re.fullmatch(r"[A-Za-z0-9-]+", esm_name): reporter.error( - f"ESM name '{esm_name}' (field {ISMIP7_FILENAME_ESM_IDX}) is a CMIP model name, but experiment '{experiment_name}' is not forced by an ESM." + f"ESM name '{esm_name}' (field {ISMIP7_FILENAME_ESM_IDX}) must be a name of letters, digits and hyphens for experiment '{experiment_name}' (e.g. NONE or ERA5)." ) elif esm_name not in VALID_ESM_NAMES: reporter.error( @@ -2021,17 +2003,17 @@ def _companion_path(source_path: str, file_name: str, variable: str) -> str | No accepted when it picks out exactly one file, since guessing between several would be worse than not checking. """ - if _file_name_fields(file_name) is None: + parts = file_name.split("_") + if len(parts) != ISMIP7_FILENAME_PARTS: return None - # Split afresh rather than using the fields: a name without an ESM field - # has to be rebuilt without one. - parts = file_name.split("_") - candidate = os.path.join(source_path, "_".join([variable] + parts[1:])) + exact = list(parts) + exact[ISMIP7_FILENAME_VAR_IDX] = variable + candidate = os.path.join(source_path, "_".join(exact)) if os.path.exists(candidate): return candidate - stem = "_".join([variable] + parts[1:-1]) + stem = "_".join([variable] + parts[1:ISMIP7_FILENAME_YEAR_RANGE_IDX]) matches = sorted(glob.glob(os.path.join(source_path, stem + "_*.nc"))) return matches[0] if len(matches) == 1 else None diff --git a/isschecker/generate.py b/isschecker/generate.py index 1193c3b..dfb8f7a 100644 --- a/isschecker/generate.py +++ b/isschecker/generate.py @@ -490,13 +490,10 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st source_id = group ism_id = model experiment_id = scenario - # An empty esm_id leaves the ESM field out, as ocx allows. - run_fields = [domain_id, source_id, ism_id, ism_member_id] - if esm_id: - run_fields.append(esm_id) - run_fields += [forcing_member_id, experiment_id, set_counter] - run_id = "_".join(run_fields) - filename_template = f"{run_id}_{time_range}.nc" + filename_template = ( + f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" + f"{experiment_id}_{set_counter}_{time_range}.nc" + ) # Create separate file for each variable created_files = [] @@ -715,7 +712,10 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st }) snap_time_range = f"{start_year}-{end_year}" - snap_filename_template = f"{run_id}_{snap_time_range}.nc" + snap_filename_template = ( + f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" + f"{experiment_id}_{set_counter}_{snap_time_range}.nc" + ) filename = f"{var_name}_{snap_filename_template}" output_path = output_dir / filename @@ -781,7 +781,10 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st }) # Static field: use 0000-0000 as year-range placeholder (checker skips this check) - static_filename_template = f"{run_id}_0000-0000.nc" + static_filename_template = ( + f"{domain_id}_{source_id}_{ism_id}_{ism_member_id}_{esm_id}_{forcing_member_id}_" + f"{experiment_id}_{set_counter}_0000-0000.nc" + ) filename = f"{var_name}_{static_filename_template}" output_path = output_dir / filename @@ -1047,7 +1050,7 @@ def main(): parser.add_argument( '--esm-id', default='CESM2-WACCM', - help="ESM id; '' leaves the field out, as ocx allows (default: CESM2-WACCM)" + help='ESM id (default: CESM2-WACCM)' ) parser.add_argument( '--forcing-member-id', diff --git a/tests/test_compliance_checker.py b/tests/test_compliance_checker.py index 8f99957..503759e 100644 --- a/tests/test_compliance_checker.py +++ b/tests/test_compliance_checker.py @@ -640,7 +640,7 @@ def test_checker_reports_historical_time_range_violation(case_dir): def generate_ocx_run(root: Path, start_year: int, include_xyt: bool) -> Path: - """An OCX run ending in 2025, with no ESM field in its file names.""" + """An OCX run ending in 2025, with NONE in the ESM field.""" generate_test_files.create_netcdf_file( None, grid_name="GrIS_16000m", @@ -650,25 +650,15 @@ def generate_ocx_run(root: Path, start_year: int, include_xyt: bool) -> Path: include_scalars=not include_xyt, include_xyt=include_xyt, include_non_mandatory=include_xyt, - esm_id="", + esm_id="NONE", set_counter="C011", output_root=root, ) return root / "GrIS" / "ISMIP7" / "SYNTH1" / "CORE" / "C011" -def insert_ocx_esm_field(file_path: Path, esm_name: str) -> Path: - """Put an ESM field back into an OCX file name that leaves it out.""" - parts = file_path.name.split("_") - parts.insert(checker.ISMIP7_FILENAME_ESM_IDX, esm_name) - renamed_path = file_path.with_name("_".join(parts)) - file_path.rename(renamed_path) - return renamed_path - - -def test_checker_accepts_an_ocx_run_without_an_esm_field(tmp_path): +def test_checker_accepts_an_ocx_run(tmp_path): core_dir = generate_ocx_run(tmp_path / "gen", 1950, include_xyt=False) - assert first_dataset(core_dir).name.endswith("_m001_f001_ocx_C011_1950-2025.nc") summary = run_checker(core_dir) @@ -679,16 +669,48 @@ def test_checker_accepts_an_ocx_run_without_an_esm_field(tmp_path): ) -def test_checker_accepts_an_ocx_run_with_a_non_cmip_esm_field(tmp_path): +@pytest.mark.parametrize("esm_name", ["ERA5", "CESM2-WACCM"]) +def test_checker_accepts_any_esm_name_in_ocx(tmp_path, esm_name): core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) for file_path in sorted(core_dir.glob("*.nc")): - insert_ocx_esm_field(file_path, "ERA5") + rename_file_part(file_path, checker.ISMIP7_FILENAME_ESM_IDX, esm_name) summary = run_checker(core_dir) assert summary["total_errors"] == 0, summary["log_text"] +@pytest.mark.parametrize("esm_name", ["", "RACMO2.3p2-ERA"]) +def test_checker_reports_a_malformed_esm_name_in_ocx(tmp_path, esm_name): + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + rename_file_part( + first_dataset(core_dir), checker.ISMIP7_FILENAME_ESM_IDX, esm_name + ) + + summary = run_checker(core_dir) + + assert summary["total_errors"] == 1 + assert summary["total_naming_errors"] == 1 + assert ( + f"ESM name '{esm_name}' (field 5) must be a name of letters, digits and" + " hyphens for experiment 'ocx' (e.g. NONE or ERA5)." in summary["log_text"] + ) + + +def test_checker_reports_an_ocx_file_without_an_esm_field(tmp_path): + core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) + file_path = dataset_for_variable(core_dir, "lim") + file_path.rename(file_path.with_name(file_path.name.replace("_NONE_", "_"))) + + summary = run_checker(core_dir) + + assert summary["total_errors"] == 2 + assert ( + "In experiment ocx, these mandatory variable(s) is (are) missing: ['lim']" + in summary["log_text"] + ) + + def test_checker_accepts_ocx_snapshots_at_the_first_and_last_year(tmp_path): """OCX does not branch from historical, so its initial state is required.""" core_dir = generate_ocx_run(tmp_path / "gen", 2010, include_xyt=True) @@ -708,20 +730,6 @@ def test_checker_accepts_ocx_snapshots_at_the_first_and_last_year(tmp_path): ) -def test_checker_reports_a_cmip_model_in_ocx(tmp_path): - core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) - insert_ocx_esm_field(first_dataset(core_dir), "CESM2-WACCM") - - summary = run_checker(core_dir) - - assert summary["total_errors"] == 1 - assert summary["total_naming_errors"] == 1 - assert ( - "ESM name 'CESM2-WACCM' (field 5) is a CMIP model name, but experiment" - " 'ocx' is not forced by an ESM." in summary["log_text"] - ) - - def test_checker_reports_ocx_starting_before_1950(tmp_path): core_dir = generate_ocx_run(tmp_path / "gen", 1949, include_xyt=False) @@ -737,7 +745,9 @@ def test_checker_reports_ocx_ending_before_2025(tmp_path): core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) target_file = dataset_for_variable(core_dir, "lim") set_time_axis(target_file, state_timestamps(range(2015, 2025))) - rename_file_part(target_file, -1, "2015-2024.nc") + rename_file_part( + target_file, checker.ISMIP7_FILENAME_YEAR_RANGE_IDX, "2015-2024.nc" + ) summary = run_checker(core_dir) @@ -752,7 +762,7 @@ def test_checker_says_experiment_names_are_lower_case(tmp_path): """The forcing directories are named OCX; the experiment is ocx.""" core_dir = generate_ocx_run(tmp_path / "gen", 2015, include_xyt=False) for file_path in sorted(core_dir.glob("*.nc")): - file_path.rename(file_path.with_name(file_path.name.replace("_ocx_", "_OCX_"))) + rename_file_part(file_path, checker.ISMIP7_FILENAME_EXPERIMENT_IDX, "OCX") summary = run_checker(core_dir)