Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Checks ISMIP7 NetCDF simulation datasets for compliance with the [ISMIP7 data re
1. **Naming** — variable name, region field, ISM member id (`mNNN`), ESM name (CMIP6/CMIP7 registry), forcing member id (`fNNN`), set counter (`[C|E|P]NNN`), and year range (`YYYY-YYYY` matching the actual time axis).
2. **Numerical** — units match the data request; all values lie within the allowed min/max range for the relevant region; array is not entirely fill values.
3. **Spatial** *(xyt variables only)* — grid corners lie within the expected AIS or GrIS extents; resolution is one of the allowed values; x and y cell size are equal.
4. **Time** — time dimension is present, unlimited, and monotonically increasing; annual cadence; experiment start/end dates and duration match `experiments_ismip7.csv`, accounting for the variable type (ST or FL, see [Time encoding](#time-encoding)).
4. **Time** — time dimension is present, unlimited, and monotonically increasing; annual cadence for `x,y,t` and `t` variables; sparse snapshot timestamps for `x,y,z,t` variables (see [Time encoding](#time-encoding)); experiment start/end dates and duration match `experiments_ismip7.csv` for `x,y,t` and `t` variables.
5. **Attributes** — required global and coordinate attributes are present and have correct values; `standard_name` matches data request; `_FillValue` equals the NetCDF4 default for the variable's dtype; variable and time are float32; `scale_factor` and `add_offset` are not allowed.

Compliance criteria are defined in `conventions/ISMIP7_variable_request.csv` (variable metadata) and `experiments_ismip7.csv` (valid experiment year ranges and durations).
Expand All @@ -27,6 +27,17 @@ For example, a 286-year `ctrl` simulation covering nominal years 2015–2300:

The filename year range (`YYYY-YYYY`) always refers to the **nominal simulation years** (2015–2300 in the example above), regardless of variable type. The checker accounts for this when validating the filename against the time axis.

### Snapshot variables (`x,y,z,t`, e.g. `litemp`)

`x,y,z,t` variables carry a sparse set of ST snapshots rather than a full annual time series. The required snapshot nominal years depend on the experiment type:

| Experiment | Required snapshots |
|---|---|
| `historical` | first year of run, 1900 (if in range), 2000 (if in range), last year of run |
| projection (e.g. `ssp585`, `ctrl`) | 2100, 2200, 2300 (each if within the experiment's year range) |

Together, a `historical` run ending at 2014 and a projection starting at 2015 provide snapshots at 100-year intervals (1900, 2000, 2100, 2200, 2300) as well as at the first and last years of the historical run. The filename year range for `litemp` reflects the full simulation year range (e.g. `2015-2300`), not the first/last snapshot year. Duration and start/end timestamp checks are not applied to snapshot variables.

Reference lookup tables are available in the companion repository [`ismip7-time-encoding`](https://github.com/ismip/ismip7-time-encoding).

---
Expand All @@ -48,18 +59,18 @@ The script must be run from the repository root. It writes `compliance_checker_l

```bash
# Check x,y,t (3D spatial) variables
python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE --variable-list ismip7_xyt
python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_xyt

# Check scalar (time-only) variables
python compliance_checker.py --source-path ./Models/AIS/ISMIP7/SYNTH1/CORE --variable-list ismip7_scalars
python compliance_checker.py --source-path ./Models/AIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_scalars

# Check both
python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE --variable-list ismip7
python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7
```

| Option | Default | Description |
|--------|---------|-------------|
| `--source-path` | `./Models/GrIS/ISMIP7/SYNTH1/CORE` | Directory containing `.nc` files to check |
| `--source-path` | `./Models/GrIS/ISMIP7/SYNTH1/CORE/C001` | Set-counter subdirectory containing `.nc` files to check |
| `--variable-list` | `ismip7_scalars` | `ismip7_xyt`, `ismip7_scalars`, or `ismip7` (both) |

`experiments_ismip7.csv` defines the allowed nominal year ranges and durations for each experiment. The checker derives the expected FL and ST timestamps from these year values at runtime (see [Time encoding](#time-encoding)).
Expand Down
93 changes: 72 additions & 21 deletions compliance_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from tqdm import tqdm


DEFAULT_SOURCE_PATH = "./Models/GrIS/ISMIP7/SYNTH1/CORE"
DEFAULT_SOURCE_PATH = "./Models/GrIS/ISMIP7/SYNTH1/CORE/C001"
DEFAULT_VARIABLE_LIST = "ismip7_scalars"
VARIABLE_LIST_CHOICES = ("ismip7_scalars", "ismip7_xyt", "ismip7")

Expand Down Expand Up @@ -219,7 +219,7 @@ def _load_criteria(workdir: str, variable_list: str):
df = df.dropna(subset=["Variable Name"])

if variable_list == "ismip7_xyt":
df = df[df["Dim"] == "x,y,t"]
df = df[df["Dim"].isin(["x,y,t", "x,y,z,t", "x,y"])]
elif variable_list == "ismip7_scalars":
df = df[df["Dim"] == "t"]

Expand Down Expand Up @@ -739,9 +739,11 @@ def _check_naming(
)
errors += 1

is_static = not ({"time", "t"} & dim)
year_range_field = parts[ISMIP7_FILENAME_YEAR_RANGE_IDX].removesuffix(".nc")
year_range_match = re.fullmatch(r"(\d{4})-(\d{4})", year_range_field)
if not year_range_match:
if is_static:
log_file.write(f" - Filename year range: N/A (static spatial variable)\n")
elif not (year_range_match := re.fullmatch(r"(\d{4})-(\d{4})", year_range_field)):
log_file.write(
f" - ERROR: year range '{year_range_field}' (field {ISMIP7_FILENAME_YEAR_RANGE_IDX}) does not match expected format YYYY-YYYY (e.g. 2015-2300).\n"
)
Expand All @@ -760,17 +762,23 @@ def _check_naming(
_yr_offset = 1 if var_type == "ST" else 0
actual_start = min(_decoded_time).item().year - _yr_offset
actual_end = max(_decoded_time).item().year - _yr_offset
if fn_start_year != actual_start:
log_file.write(
f" - ERROR: filename start year {fn_start_year} does not match first time step year {actual_start}.\n"
)
errors += 1
is_snapshot = "z" in dim
yr_range_ok = True
if not is_snapshot:
# For regular time-series variables the filename start year must match the first time step.
if fn_start_year != actual_start:
log_file.write(
f" - ERROR: filename start year {fn_start_year} does not match first time step year {actual_start}.\n"
)
errors += 1
yr_range_ok = False
if fn_end_year != actual_end:
log_file.write(
f" - ERROR: filename end year {fn_end_year} does not match last time step year {actual_end}.\n"
)
errors += 1
if fn_start_year == actual_start and fn_end_year == actual_end:
yr_range_ok = False
if yr_range_ok:
log_file.write(
f" - Filename year range {fn_start_year}-{fn_end_year} matches time axis: OK\n"
)
Expand Down Expand Up @@ -915,6 +923,10 @@ def _check_time(

log_file.write("TIME Tests \n")
if not ({"t"}.issubset(dim) or {"time"}.issubset(dim)):
if {"x", "y"}.issubset(dim):
# Static spatial variable (x,y) — no time axis is expected.
log_file.write(" - Time axis: N/A (static spatial variable)\n")
return errors
log_file.write(
" - ERROR: The time dimension is missing. Time Tests have been ignored.\n"
)
Expand Down Expand Up @@ -960,7 +972,34 @@ def _check_time(
)
return errors + 1

if len(ds["time"].values) > 1:
is_snapshot_var = "z" in dim
if is_snapshot_var:
# x,y,z,t variable (e.g. litemp): sparse snapshot time axis.
# Each time value must be an ST-encoded timestamp for one of the known snapshot nominal years.
_LITEMP_SNAPSHOT_NOMINAL_YEARS = {1900, 2000, 2100, 2200, 2300}
_yr_offset = 1 if var_type == "ST" else 0
nominal_years = [t.year - _yr_offset for t in ds["time"].values]
exp_for_snaps = experiments[index_exp]
valid_snapshot_years = {nominal_years[-1]} | {
y for y in _LITEMP_SNAPSHOT_NOMINAL_YEARS
if exp_for_snaps["start_year_min"] <= y <= exp_for_snaps["end_year"]
}
if exp_for_snaps["experiment"] == "historical":
valid_snapshot_years.add(nominal_years[0])
snap_errors = 0
for ny in nominal_years:
if ny not in valid_snapshot_years:
log_file.write(
f" - ERROR: time snapshot nominal year {ny} is not in the valid"
f" snapshot set {sorted(valid_snapshot_years)}.\n"
)
snap_errors += 1
if snap_errors == 0:
log_file.write(
f" - Snapshot time axis nominal years {nominal_years} are all valid: OK\n"
)
errors += snap_errors
elif len(ds["time"].values) > 1:
if isinstance(ds["time"].values[1] - ds["time"].values[0], datetime.timedelta):
time_step = (ds["time"].values[1] - ds["time"].values[0]).days
elif isinstance(ds["time"].values[1] - ds["time"].values[0], np.timedelta64):
Expand Down Expand Up @@ -991,6 +1030,12 @@ def _check_time(
end_exp.item().year, end_exp.item().month, end_exp.item().day
)

if is_snapshot_var:
log_file.write(
" - Duration / start / end timestamp checks: N/A (snapshot variable — time axis holds sparse snapshots only).\n"
)
return errors

# Compute expected timestamp windows from nominal years + var_type (±1 day tolerance).
_tol = datetime.timedelta(days=1)
startinf = _nominal_to_timestamp(exp["start_year_min"], var_type) - _tol
Expand All @@ -1012,14 +1057,15 @@ def _check_time(
)
errors += 1

_yr_off = 1 if var_type == "ST" else 0
dateformat_start_exp = datetime.datetime(
start_exp.item().year, start_exp.item().month, start_exp.item().day
)
if startinf <= dateformat_start_exp <= startsup:
log_file.write(
" - Experiment starts correctly on "
" - First time stamp correctly set to "
+ start_exp.item().strftime("%Y-%m-%d")
+ ".\n"
+ f" (nominal year {start_exp.item().year - _yr_off}).\n"
)
else:
log_file.write(
Expand All @@ -1035,9 +1081,9 @@ def _check_time(

if endinf <= dateformat_end_exp <= endsup:
log_file.write(
" - Experiment ends correctly on "
" - Last time stamp correctly set to "
+ end_exp.item().strftime("%Y-%m-%d")
+ ".\n"
+ f" (nominal year {end_exp.item().year - _yr_off}).\n"
)
else:
log_file.write(
Expand All @@ -1053,16 +1099,17 @@ def _check_time(
else:
if duration_years == exp["duration"]:
log_file.write(" - Experiment lasts " + str(duration_years) + " years.\n")
_yr_off = 1 if var_type == "ST" else 0
dateformat_start_exp = datetime.datetime(
start_exp.item().year,
start_exp.item().month,
start_exp.item().day,
)
if startinf <= dateformat_start_exp <= startsup:
log_file.write(
" - Experiment starts correctly on "
" - First time stamp correctly set to "
+ start_exp.item().strftime("%Y-%m-%d")
+ ".\n"
+ f" (nominal year {start_exp.item().year - _yr_off}).\n"
)
else:
log_file.write(
Expand All @@ -1078,9 +1125,9 @@ def _check_time(

if endinf <= dateformat_end_exp <= endsup:
log_file.write(
" - Experiment ends correctly on "
" - Last time stamp correctly set to "
+ end_exp.item().strftime("%Y-%m-%d")
+ ".\n"
+ f" (nominal year {end_exp.item().year - _yr_off}).\n"
)
else:
log_file.write(
Expand Down Expand Up @@ -1157,9 +1204,12 @@ def _check_attributes(
if name in ds.coords:
time_coord = name
break
if time_coord is None:
is_static_spatial = time_coord is None and {"x", "y"}.issubset(set(ds.coords))
if time_coord is None and not is_static_spatial:
log_file.write(" - ERROR (attributes): coordinate 'time' not found.\n")
coord_errors += 1
elif time_coord is None and is_static_spatial:
log_file.write(" - Time coordinate: N/A (static spatial variable)\n")
else:
# xarray decodes 'units' and 'calendar' into .encoding; 'bounds' stays in .attrs
time_var = ds[time_coord]
Expand All @@ -1184,7 +1234,8 @@ def _check_attributes(
)
coord_errors += 1
if not isscalar:
for coord in ("x", "y"):
spatial_coords = ("x", "y", "z") if "z" in ds.coords else ("x", "y")
for coord in spatial_coords:
if coord in ds.coords:
if "units" not in ds[coord].attrs:
log_file.write(
Expand Down
Loading
Loading