Skip to content

Feat/cmip7 awiesm3 veg hr#266

Open
JanStreffing wants to merge 388 commits into
prep-releasefrom
feat/cmip7-awiesm3-veg-hr
Open

Feat/cmip7 awiesm3 veg hr#266
JanStreffing wants to merge 388 commits into
prep-releasefrom
feat/cmip7-awiesm3-veg-hr

Conversation

@JanStreffing

@JanStreffing JanStreffing commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

CMIP7 cmorization for AWI-ESM3-VEG-HR

Adds full CMIP7 support targeting AWI-ESM3-VEG-HR, including a native compound-name
architecture that replaces the legacy cmip6-table-based data request lookup.

Key changes

CMIP7 data request

  • Load DataRequest from CMIP7_DReq_metadata JSON instead of cmip6 tables
  • Refactor to native compound-name architecture (ocean.tos.tavg-u-hxy-sea.mon.GLB)
  • Fix JSON key mismatch: cmip6_tablecmip6_cmor_table in vendored metadata
  • Improve compound_name matching against cmip6_compound_name and cmip7_compound_name attributes
  • Derive table_id from compound name when not set explicitly
  • Strict ValueError on zero DRV matches (instead of silent skip)

Pipeline

  • Add generic vertical_integrate custom pipeline step
  • Remove duplicate convert() step from DefaultPipeline
  • Fix Prefect State objects not being unwrapped to actual results in parallel runs
  • Propagate pipeline/flow errors instead of silently logging them

Standard library

  • Add time bounds support (src/pycmor/std_lib/time_bounds.py)
  • Fix dimension mapping to use getattr + _pycmor_cfg fallback
  • Fix global_attributes to derive table_id from CMIP6/CMIP7 compound names

Xarray accessor API

  • Lazy accessor registration and StdLibAccessor with .process()

Test infrastructure

  • Modernize with entry-point model discovery (pycmor.fixtures.model_runs)
  • Add pycmor.tutorial dataset system (xarray.tutorial-style API)
  • Fix stub generator to use monotonic coordinate values for multi-file datasets

Misc fixes

  • Python 3.9 entry_points() compatibility
  • Guard pyfesom2 imports for environments without it
  • Fix tarball double-nesting extraction on Python 3.12+
  • Rename non-standard time dimension on load (OpenIFS support)

Test plan

  • Unit tests pass: pytest tests/unit/
  • pycmor process examples/awiesm3-cmip7-minimal.yaml runs successfully on Levante
  • core_atm runs with tco95_core2 on Levante
  • core_land runs with tco95_core2 on Levante
  • core_ocean runs with tco95_core2 on Levante
  • core_seaice runs with tco95_core2 on Levante
  • lrcs_land runs with tco95_core2 on Levante
  • lrcs_oce runs with tco95_core2 on Levante
  • lrcs_seaice runs with tco95_core2 on Levante
  • cap7_atm runs with tco95_core2 on Levante
  • cap7_ocean runs with tco95_core2 on Levante
  • cap7_seaice runs with tco95_core2 on Levante
  • cap7_land runs with tco95_core2 on Levante
  • cap7_aerosol runs with tco95_core2 on Levante
  • veg_atm runs with tco95_core2 on Levante
  • veg_land runs with tco95_core2 on Levante
  • veg_seaice runs with tco95_core2 on Levante
  • extra_atm runs with tco95_core2 on Levante
  • extra_land runs with tco95_core2 on Levante
  • final check of all rules.

@JanStreffing JanStreffing changed the base branch from main to prep-release April 2, 2026 12:55
@JanStreffing JanStreffing force-pushed the feat/cmip7-awiesm3-veg-hr branch from 1a42875 to 5617a18 Compare April 2, 2026 13:18
@JanStreffing

Copy link
Copy Markdown
Contributor Author

I was able to run both tos and the more complex absscint with the lastest commit on this branch. We may want to work on #267, and certainly need to work on #265. But neither should block us from starting to build up more rules for the picontrol variables.

@JanStreffing

JanStreffing commented Apr 5, 2026

Copy link
Copy Markdown
Contributor Author

The single failing test (test_library_process[fesom_dev-native-dask-cmip6]) is unrelated to this PR. It failed due to a network issue — the test tried to download pi_uxarray.tar from nextcloud.awi.de but got ConnectionError: [Errno 111] Connection refused. The AWI Nextcloud server was unreachable from the GitHub Actions runner.

Something to worry about? @pgierz

@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 6, 2026
@mandresm

mandresm commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

@pgierz, will you review this or should I go for it?

@pgierz

pgierz commented Apr 7, 2026

Copy link
Copy Markdown
Member

I will look

@esm-tools esm-tools deleted a comment from github-actions Bot Apr 8, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 8, 2026
@esm-tools esm-tools deleted a comment from github-actions Bot Apr 8, 2026
return ds


def _attach_bounds_from_mesh(ds, rule, coord_names):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if generic enough for backend

return any(v.chunks is not None for v in ds.data_vars.values())


def _encoding_from_dask_chunks(ds, rule):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred for later review


if table_id is None:
# Fallback to user-provided
table_id = self.rule_dict.get("table_id", None)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be checked. Why deleted?

"""
return self.rule_dict.get("Conventions", "CF-1.11")

# ========================================================================

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a class. Do we need it?

return "hdl:21.14100/" + str(uuid.uuid4())
"""Generate a unique tracking ID (prefix overridable via rule_dict).

The CMIP7 tracking_id CV requires the ``hdl:21.14107/<uuid>`` prefix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean cmip6 wont work anymore?

approx_interval = drv.table_header.approx_interval
frequency_str = _frequency_from_approx_interval(approx_interval)
logger.debug(f"{approx_interval=} {frequency_str=}")
# attach the frequency_str to rule, it is referenced when creating file name
rule.frequency_str = frequency_str
time_method = _get_time_method(drv.frequency)
rule.time_method = time_method
# FESOM yearly files and concat'd hemispheric selects can yield a

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hallucination? xarray can sort itself?

Three single-rule single-year YAMLs that exercise the code paths the
cli69 round had to fix. Mirrors the existing HR seaice smoke pattern
(local dask cluster, qc_enabled, qc_repack) so they run in a couple of
minutes each and produce a clean QC sidecar when the fixes are in.

- cmip7_hr_qc_smoke_lpj_yearly.yaml exercises E1 (8-digit
  YYYYMMDD-YYYYMMDD filename for yr frequency) and E2 (year-start
  bnds + midpoint realign). Source: HR LPJ-GUESS baresoilFrac yearly.
- cmip7_hr_qc_smoke_fesom_ocean_mon.yaml exercises E3 (calendar to
  proleptic_gregorian), E4 (units canonicalised to days since YYYY-MM-DD,
  no fractional seconds), and E6 (time dtype float64). Source: HR FESOM
  sst monthly. The wcrp_cmip7 plugin gets the FESOM int64 / standard
  paths that DefaultPipeline rules would otherwise miss.
- cmip7_hr_qc_smoke_lpj_daily.yaml exercises E5 (strip CF-inheritable
  attrs from source-shipped time_bnds). Source: HR OIFS atmos_day_land
  via the temporal_diff_pipeline used by dcw_day.

All three reuse the canonical HR identifiers (source_id, grid_label,
DARS2 mesh path) so the wcrp_cmip7 ATTR004 checks land on real CV
terms, not placeholders.
Three small fixes turned up by smoke runs of the cli69 LPJ-daily and
FESOM ocean-monthly fix targets:

- _force_canonical_time_encoding now sets the parent time coord's
  standard_name='time', long_name='time', axis='T' if absent. FESOM
  monthly via timeavg rebuilds the time coord and drops these, which
  trips wcrp ATTR001 and cf §3.3 / §5.1.
- Same helper now also sets encoding['_FillValue']=None on both the
  time coord and time_bnds. xarray's default CF encoder emits a NaN
  fill on every float variable; cf §7.1 explicitly forbids _FillValue
  on bnds.
- dimension_mapping.apply_mapping renames the matching `{src}_bnds`
  aux variable when it renames a dim X→Y, and fixes the parent coord's
  `bounds` attr pointer. LPJ-GUESS daily output uses `time_counter`,
  which lands a stray `time_counter_bnds` and no `time` variable; wcrp
  TIME003 then reports "Missing 'time' variable". The `time_counter`
  name is added to the time regex so apply_mapping recognises it.
get_encoding_with_chunks() iterates only ds.data_vars, so it skips
coord variables — including bounds aux like time_bnds. The companion
_encoding_from_dask_chunks() suppresses _FillValue on every coord
after the data_vars loop, but the trigger_compute-eager path (after
the pipeline materialises) routes through this chunking helper
instead, and time_bnds ends up written with the default NaN fill.
cf §7.1 then flags "Boundary variables 'time_bnds' should not have
the attributes: ['_FillValue']".
CMIP convention is that the on-disk time-axis variable is ALWAYS
named "time"; the numeric suffix on time1/time2/time3 only drives
the cell_methods string ("time: point" vs "time: mean") inside CMOR,
not a separate dim name. When the data request says axis=time1 (e.g.
land.cLitterLut.tpt-u-hxy-multi.yr.glb for LUT-style instantaneous
yearly snapshots), apply_mapping previously rewrote the file dim to
"time1" and shipped time1 + time1_bnds. wcrp_cmip7 TIME003 ("Missing
'time' variable.") and ATTR001 hardcode the name "time" and trip.

Collapse the mapping target before the rename: any source dim mapped
to time1/2/3 becomes "time" in the file. The matching bnds aux is
already renamed by the loop further down, and the parent coord's
`bounds` attr is repointed there too.
extra_land/cmip7_awiesm3-veg-hr_extra_land.yaml mapped the LPJ-GUESS
monthly source (lai_monthly.out) to compound .day.glb. The data is
genuinely monthly (one stamp per month), but the compound told the
pipeline to write a day-frequency file with day-frequency filename
token. wcrp_cmip7 TIME001 then reported "expected 1.5, got 30" — the
gap between time[0] and time[1] is 30 days, not 1 day.

Switch to land.lai.tavg-u-hxy-lnd.mon.glb. core_land already covers
the .mon variant from OpenIFS land output; this rule provides the
LPJ-GUESS variant of the same compound.
Three fixes for the residual HIGHs / MEDIUMs that cli71 surfaced
after the LUT axis-id collapse and lai_mon recipe correction:

1. set_time_bounds: auto-detect cell_methods 'time: point' and treat
   the rule as instantaneous. For yearly + instantaneous (tpt yearly
   LUT compounds like land.cLitterLut.tpt-u-hxy-multi.yr.glb), build
   year-snap bnds (so the bnds span the period) but pin time to the
   bnds start, not the midpoint. wcrp TIME001 with use_midpoint=False
   then sees time = filename_start (≈ -182 in days since year_mid
   epoch) and matches. Clears 15× cLitterLut TIME001.

2. _save_dataset_impl: re-run set_time_bounds on the dataset after
   the DataArray→Dataset conversion when time_bnds is still missing.
   The std_lib wrapper drops the bnds aux when the upstream payload
   is a DataArray, and the existing resample-group path re-attaches
   them per group — but the native-timespan path doesn't. Which
   path fires depends on a non-deterministic
   pd.Timestamp.now()+YS-now comparison; today (June) it goes
   native, in November it goes resample. Run set_time_bounds once
   before the branch so both paths inherit a bnds-carrying dataset.
   Clears 14× FESOM yearly difmxylo TIME003 ("time coverage
   (1851,7)-(1851,7) does not cover filename 18510101-18511231").

3. DataRequestVariable.attrs (CMIP7): keep cell_measures='' through
   the empty-string filter. siextent / sivol / siarea / sisnmass and
   other scalar sea-ice compounds ship cell_measures='' in the data
   request as the canonical "no spatial measure" value, and wcrp
   ATTR001 requires the attribute to exist on the variable. The
   filter dropped any "" field; restricting the exemption to
   cell_measures preserves the safety net for accidental blank
   standard_name / long_name. Clears 24× ATTR001 cell_measures
   missing on sea-ice scalars.

Skipped this round: the 10× cf §7.1 lon-outside-lon_bnds on FESOM
3D elem-dim files. lat / lon and bnds are already float64 (cli67
fdf444f promotion confirmed on cli71 output), and mesh-bnds-prefer
doesn't fire because mesh.nc carries node-dim bnds only — the 3D
fields are on elem-dim, ~2× larger. ~0.03% of triangle bboxes don't
contain their centroid (likely quantization or dateline-crossing).
Real fix needs geometry work (either elem-dim mesh bnds or a
post-hoc bbox-expand step) — out of scope for this iteration.
Four recipe + helper fixes for residual cli71 HIGHs whose root causes
are unit-string mismatch or missing CF metadata, not pipeline bugs:

- variable_attributes psu rewrite uses ``1E-03`` (CMIP7 registry form)
  instead of ``1e-3``. wcrp ATTR004 does literal string compare against
  the registry; ``1e-3`` and ``1E-03`` are the same scaling factor to
  UDUNITS but trip the registry check. Clears 1× sob HIGH.

- compute_hfbasin_tripyview / compute_sltbasin_tripyview attach
  ``standard_name=region`` + ``long_name=Region Selection`` to the
  basin sector axis at construction. set_coordinates only knows about
  spatiotemporal axes, so the basin coord otherwise landed bare and
  tripped wcrp ATTR001. Clears 1× sltbasin HIGH.

- cfc11_mon / cfc12_mon / ch4_mon / n2o_mon: drop the
  scale_factor=1e-12/1e-9 multiplication and label files as ``1E-12`` /
  ``1E-09`` (the CMIP7 registry form for scaled volume-mixing ratios).
  Previous behaviour scaled source-ppt → mol/mol and labelled ``mol mol-1``;
  the registry expects values to STAY in ppt/ppb with the scaling
  factor declared via units. scale_factor=1.0 keeps the pipeline's
  scale_by_constant step happy as a no-op. Clears 4× ATTR004 HIGH.

- vsfcorr: source is m s-1 psu (velocity × per-mille mass fraction);
  registry wants kg m-2 s-1 (mass flux). Conversion: m/s × g/kg × ρ₀
  (1025 kg/m³) = 1.025 kg/m²/s; the 1e-3 prefix on psu cancels the
  1000 g/kg in seawater density. New nan_to_zero_scale_pipeline runs
  scale_by_constant after nan_to_zero; vsfcorr rule sets
  scale_factor=1.025 + scaled_units=kg m-2 s-1. The old rule's
  ``model_unit: kg m-2 s-1`` was a metadata-only claim that the source
  data was already in CMIP units, which was wrong. Clears 1× ATTR004
  + 1× §3.1 UDUNITS HIGH.

Defers: 4× FILE004d uncompressed chunk size (cmip7repack tooling
issue — runs but doesn't grow chunks enough on FESOM high-res
unstructured) and 1× ``No geophysical variable detected`` on the
basin fx file (wcrp upstream check, int32 flag variable not
recognised as geophysical).
wcrp FILE004d (HIGH) requires each data-variable chunk to be at
least 4 MiB uncompressed (CMIP7 storage convention). The dask-
aligned path in ``_encoding_from_dask_chunks`` mirrors whatever
dask chunks the pipeline produced; on FESOM unstructured HR fields
(sfx: 12 × 3146761 nodes, dask chunks (12, 18724)) that lands at
1.8 MiB per netCDF chunk and trips the check. cmip7repack is the
documented post-hoc fix but for these 2D high-res cases it
re-uses the same shape and the file stays under threshold.

Add a per-variable floor: if dask-aligned chunks are below 4 MiB,
grow the trailing (rightmost, typically horizontal) dim's chunk
until product × wordsize crosses the threshold, capped at the dim
size. Leaves the time-axis chunking pycmor already picked alone so
streaming writes stay aligned with dask.

The non-dask path (``calculate_chunks_simple``) already targets
100 MB so it doesn't need the floor.

Clears 4× FILE004d HIGH (sfx, somint, phcint, absscint) on the
cli71 production batch.
Two cli72 residual HIGH fixes whose root causes were in pycmor:

- time_bounds: extend the period-start pin (previously yearly-only)
  to every rule wcrp_cmip7 TIME001 treats as instantaneous. wcrp's
  use_midpoint=False fires for (a) any rule with cell_methods
  "time: point" OR (b) any frequency NOT in its AVG_CORRECTION_FREQ
  set {day, mon, monPt, yr, yrPt, 1hrCM, sem}. So monthly tpt
  (sistressave, sistressmax, fracLut) and decadal (masso, thkcello,
  tauvo, so, volo, thetao, tauuo) need time = bnds[:, 0], not the
  bnds midpoint. Clears 10x TIME001.

- DRS region token: stop lower-casing parts[4] of the compound when
  building the filename and the directory path. CMIP7's region CV
  carries lowercase for simple codes (glb, nh, sh) but uppercase for
  latitude-band tokens (30S-90S, 30N-90N, ...). Forcing lowercase
  trips wcrp FILE001 (DRS directory + filename) and ATTR004 (region
  CV) for the 1hr/3hr south-30 hemispheric files. Preserves whatever
  case the compound carries; the data-request corpus is the source
  of truth. Clears 36x FILE001 + 18x ATTR004 region.
Source files from OpenIFS-XIOS / NEMO-XIOS / FESOM ship ``time_counter``
as the time dim. Pycmor only renamed it to ``time`` when each rule set
``time_dimname`` explicitly; otherwise time_counter survived load and
the downstream behaviour was inconsistent across paths:

- The primary loader (core.gather_inputs.load_mfdataset) skipped its
  year filter because ``"time" in dims`` was False.
- The secondary-input loader (custom_steps._load_secondary_mf*) ran a
  drop_vars block that REMOVED time_counter from coords without first
  renaming it. The secondary returned without any time coord.
- Custom compute_X steps then did primary × secondary arithmetic where
  the primary still carried time_counter and the secondary carried no
  time at all. xarray broadcasting collapsed the result to a single
  time stamp at best, an empty time dim at worst.

cli72 surfaced this on ``sfcWind_1hr_south30``: output file shipped
``time: 0`` (zero-length unlimited dim) with no time variable, tripping
8 HIGH (ATTR004 coordinates, DIM002, cf §5, cf §5.1 each twice).

Add auto-detect for time_counter / time_centered in both loaders so
the rename happens without each rule having to opt in. Explicit
``time_dimname`` still wins. Companion bnds variables (time_counter_bounds,
time_counter_bnds) get renamed alongside the dim.
Two rules sharing cmor_variable + branding_suffix + frequency but
differing in region (rlds_1hr_south30 vs rlds_1hr glb, rsds_1hr_south30
vs rsds_1hr glb, ...) used the same _rule_files prefix, so each rule's
qc step picked up the other rule's files. On 1hr / 3hr / 6hr regional
rules this raced against the glb rule's concurrent cmip7repack: the
south30 rule's strip cleared _QuantizeBitGroom* on the glb file, then
the glb rule rewrote that file (re-introducing the attr transiently),
then the south30 cchecker read the stale state and the sidecar recorded
a §2.3 _QuantizeBitGroom* finding that wasn't actually on disk.

Anchor _rule_files on <var>_<branding>_<freq>_<region>_ so each rule
only sees its own files. The DRS filename layout
<var>_<branding>_<freq>_<region>_<grid>_... makes the region token a
clean prefix boundary.

Clears the 1× §2.3 finding on cli72 (rlds_1hr_south30) and removes the
class of stale-finding races on every south30 / hemispheric rule.
cmip7repack writes ``<file>.nc_cmip7repack``, then atomically renames
it over the original. If the wrapping SLURM job times out mid-repack
(3 h walltime, daily 137-level 3D atm fields like cl_day / pfull_day
each take well over an hour), cmip7repack gets killed between
"successfully created" and the rename. The partial intermediate stays
on disk forever — corrupted (HDF error on open) and taking GB per file.

cli72 left 30 GB of zombies (cl_day + pfull_day at 14.8 GB and 15.0 GB
each). The next batch would inherit them and the disk accounting would
keep climbing every cli iteration.

Add ``_clean_cmip7repack_orphans`` and invoke it before AND after every
``_run_cmip7repack`` pass so:
- before: a previous job's killed-rename zombies are removed before this
  job's repack starts (otherwise a half-baked intermediate could shadow
  the in-progress repack on Lustre's metadata cache).
- after: any zombies left by THIS job (timeouts, OOM, signal) are
  removed at the end of the qc_repack step. No more 30 GB silent
  accumulation per batch.
Investigation of the 1hr / 3hr / 6hr atm files in cli72 found three
distinct issues, all addressed here.

1. timeaverage._get_time_method only recognised CMIP6's "Pt" suffix on
   the frequency string. CMIP7 dropped the suffix and moved the
   tavg-vs-tpt signal into cell_methods ("time: point" vs "time:
   mean"). Every CMIP7 tpt sub-daily / monthly rule was treated as
   MEAN: timeavg ran .mean() + the midpoint shift on already-instant
   data, so stamps moved +3h (6hr) / +1.5h (3hr) / +0.5h (1hr) and the
   on-disk cell_methods still said "time: point". The psl_6hr output
   in cli72 had stamps at 15:00, 21:00, 03:00, 09:00 instead of 06,
   12, 18, 00 — exactly the +3h shift, plus boundary trim.

   Add an inline cell_methods check that overrides MEAN -> INSTANTANEOUS
   when the data request says "time: point". The CMIP6 Pt path stays
   intact; this only kicks in for CMIP7 rules.

2. ps_1hr (cap7_atm) was branded ``atmos.ps.tpt-u-hxy-u.1hr.glb`` but
   used source ``atmos_1h_sfc_sp`` which the OIFS XIOS file_def
   declares as operation="average" (time_centered stamps at HH:30).
   CMIP7 has both ``atmos.ps.tavg-u-hxy-u.1hr.glb`` and the tpt form;
   producing tpt from averaged data is wrong. Switch to the tavg
   compound. The 3hr / 6hr ps rules already use the matching ``_pt_``
   instant sources (atmos_3h_pt_sp, atmos_6h_pt_sp), so no change there.

3. ps_1hr_south30 (extra_atm) and ts_3hr (extra_atm) hit the same
   source mismatch but with no tavg compound available in the CMIP7
   registry for that region/frequency. Disabled with TODO comments
   pointing at the OIFS XIOS config that would need to add the
   ``atmos_1h_pt_sp`` / ``atmos_3h_pt_ts`` instant fields. Re-enable
   once the source data exists.
Both are CMIP7 HIGH-priority variables but the OIFS XIOS file_def in
this production run only emits averaged sources where CMIP7 asks for
``time: point``:

- atmos.ts.tpt-u-hxy-u.3hr.glb wants instants to capture diurnal-
  cycle peaks; we only have ``atmos_1h_ts_ts`` (averaged at HH:30).
- atmos.ps.tpt-u-hxy-u.1hr.30S-90S wants instants to capture sub-
  hourly Southern-Ocean pressure-wave variability; we only have
  ``atmos_1h_sfc_sp`` (averaged at HH:30).

Sampling the averages and labeling them as instants would ship the
wrong data type — the variability the variables are meant to capture
isn't in the source. Production is already underway, so adding the
XIOS fields would require a model restart from an earlier checkpoint.

Skip them honestly. variable_coverage.md is updated with the gap
explanation; YAML rules stay commented out with an inline note.
Two MEDIUM cleanups for cli73:

- variable_attributes ``is_temperature_sn`` now accepts compound CF
  standard names that don't strictly end with ``temperature`` (e.g.
  ``sea_water_potential_temperature_at_sea_floor`` on tob,
  ``sea_water_conservative_temperature``, ``sea_surface_temperature``)
  via an ``_temperature`` substring test. Also accepts ``degC`` /
  ``Celsius`` as absolute-temperature units. Clears CF §3.1.2 ``tob``
  units_metadata MEDIUM.

- When the data-request ``cell_measures`` is the CMIP7 ``--MODEL``
  placeholder, substitute the realm default rather than dropping it:
  ``area: areacello`` for ocean / seaIce / landIce / ocnbgchem,
  ``area: areacella`` for atmos / aerosol / land / atmosChem. wcrp
  ATTR001 requires the attribute to be PRESENT, and the existing
  placeholder-drop left it absent on siu / siv / sidmasstranx /
  sistrxdtop and similar variables whose data-request entry says
  ``--MODEL``. Clears 8x ATTR001 cell_measures MEDIUM.

§2.4 dim-order MEDIUMs on unstructured-grid files (mrsll, zg, hur,
ua_6hr, tsl, mrsol, ...) NOT touched. The CF check classifies single-
horizontal-dim variables as T,Z,(A) and reports they're not in
T,Z,Y,X order. Same shape affects ROMS curvilinear in compliance-
checker's own test corpus; upstream limitation, not real non-
conformance. Leave as advisory MEDIUM.
FESOM unstructured triangles crossing the dateline ship vertices on
both branches (e.g. (179°, -179°, -180°)) while FESOM's element
centroid stores ``lon`` in one branch only. cf §7.1 then sees the
centroid outside the [min, max] vertex bbox, reporting "N points lie
outside the bounding box". cli73 cleared most of the FESOM yearlies
but still tripped on 1814 elements per file for difmxylo / tauuo /
tauvo / sistressave (~0.03 % of cells, all on the dateline).

This isn't a precision issue — both ``lon`` and ``lon_bnds`` are
already double on disk (cli67 fdf444f promotion was symmetric across
lat and lon). lat passes because it doesn't wrap. The real fix is
geometric: shift each vertex into the 360° window centred on the
centroid, identity on the sphere, no change to the represented
geometry. After the shift the bbox contains the centroid by
construction.

Implemented at the tail of ``_ensure_lat_lon_bounds_impl`` so it
applies to every path that lands lon_bnds in the dataset (XIOS
rename, _recover_bounds_from_inputs, _attach_bounds_from_mesh,
add_bounds_from_coords). Vectorised — the 6.2M-element 3D files don't
churn Python.

Verified two ways:
- Direct math on cli73 difmxylo on-disk file: 1814 → 0 outliers.
- Isolated test through _ensure_lat_lon_bounds_impl on a 5-cell
  synthetic dataset with two dateline crossings: 2 → 0 outliers,
  non-dateline cells (and all of lat_bnds) untouched, log line
  emits the expected "shifted N rows" count.
…ine drop

- files.py: sub-daily yearly-split files emit 6-digit YYYYMM-YYYYMM token;
  sidesteps cc-plugin-wcrp \d{6}|\d{8} regex (PR #46). Yearly path
  already used 8-digit for the same reason; sub-daily clamps DOWN to 8
  was the natural symmetric choice but I picked 6 here to match the
  minimum-distortion encoding given we always split yearly.

- time_bounds.py: detect bnds straddling time stamps
  (bnds = (t-dt/2, t+dt/2), the OIFS-XIOS sub-daily tavg convention)
  and shift back by dt/2 so bnds = (t-dt, t) lands on the canonical
  CMIP period-aligned layout. Triggers only for freq in {1hr,3hr,6hr}
  with cell_methods != time:point. Unblocks TIME001 once wcrp#51 lands.

- timeaverage.py: xarray's DataArrayResample.first() does not accept
  the flox engine kwarg. Drop it from the INSTANTANEOUS branch.
  Latent bug exposed by 6c0a3c5's CMIP7 tpt cell_methods override:
  before that commit every CMIP7 tpt rule fell through to MEAN because
  CMIP6's Pt suffix was dropped, masking the TypeError.
…pattern

Group A (4 shards, ValueError on reset_coords for indexed time dim):
- time_bounds.py: short-circuit on freq in (fx, ofx); time-invariant
  vars should not get bnds built. Without this, fx fell through the
  wcrp_treats_as_instantaneous branch and built (12,2) monthly bnds
  against the source's repeated monthly LSM samples.
- files.py: fx save branch now squeezes the time dim unconditionally
  (was guarded on time.size <= 1, which missed the 12-sample case)
  and uses drop_vars on the time/time_bnds coords instead of
  reset_coords. reset_coords refuses indexed dim coords; drop_vars
  removes both the variable and its index in one step.

Group C (thetao_ga compound mismatch, 1 shard):
- Bump CMIP7_DReq_metadata from v1.2.2.3 to v1.2.2.4 across all 17
  recipe yamls. Between those versions the ocean global-average
  compounds were renamed tavg-ol-hm-sea -> tavg-u-hm-sea (thetao,
  bigthetao, so) and atmos surface-air-temperature 1hr files moved
  from realm "land" to "atmos".
- extra_land + cap7_land: rename
  land.tas.tavg-h2m-hxy-u.1hr.{glb,30S-90S} ->
  atmos.tas.tavg-h2m-hxy-u.1hr.{glb,30S-90S}.

Group E (extra_atm script:// hashing, 1 shard, watchdog killed):
- pipeline.py: detect steps whose __module__ is "script" (the
  synthetic name from core.utils.get_function_from_script) and use
  NO_CACHE for that pipeline. Prefect's INPUTS cache policy pickles
  every task arg (Rule + bound steps), and pickle fails on the
  @lru_cache wrappers defined in custom_steps.py because "script"
  isn't a re-importable module name. Falling back to NO_CACHE costs
  nothing — pycmor runs each pipeline once per process.

Group D (sisnhc_day MemoryError on 1.06 PiB allocation):
- veg_seaice yaml: tighten regex pattern from m_snow.fesom..*\.nc to
  m_snow\.fesom\.\d{4}\.nc, same for a_ice. The loose form matched
  both gn and gr-variant files (m_snow.fesom.1851.nc AND
  m_snow.fesom.gr.1851.nc), and xr.open_mfdataset tried to merge
  them via combine_by_coords, which triggered dask.tokenize on a
  degenerate-shape metadata array — hence the 1.06 PiB "allocation"
  was actually a tokenize trying to hash an impossible reshape.
cli78 round 2 of Group A. 3bd1684 stopped reset_coords from failing on
the indexed time dim but left ``extra_kwargs["unlimited_dims"] = ['time']``
in place (set earlier in _save_dataset_impl when the rule has
``xarray_time_unlimited: True``). xr.save_mfdataset then rejects the kwarg
against a dataset that no longer has a 'time' dim. Drop the kwarg in the
fx branch right after the coord drop.

Verified mrfso open question from the handoff: only the genuinely-fx
rules hit the branch (mrsofc, sftgif, rootd, sftlf, plus core_land and
extra_land LPJ fx). mrfso runs through lpjg_monthly_pipeline and saves
cleanly — the earlier handoff misidentified it.
cli79 still surfaced 2 HIGH '[ATTR004] sob units' findings. Root cause:
the DReq path skips setting units (skip_setting_unit_attr=True in this
profile), so source-inherited units pass through to the file. FESOM salt
ships units='1e-3' from XIOS, and the wcrp registry literal-compares
against '1E-03'. The earlier psu->'1E-03' substitution at line 101-109
only runs against the DReq attrs dict, never against the source-inherited
da.attrs.

Add a second normalisation pass after da.attrs.update(attrs) that
canonicalises whatever ended up on the array: 'psu' -> '1E-03' (defence
in depth, in case DReq didn't ship it) and '1[eE]-0?\d' -> uppercase
two-digit-exponent form. Tested against {1e-3, 1E-3, 1e-03, 1E-03, psu,
m s-1 psu, kg m-2, kg m-2 s-1}; the four equivalent forms all collapse
to '1E-03', the others are untouched.

Clears the 2 sob HIGH from QC_REPORT_cli79.md.
Closes most of QC_REPORT_cli79.md's actionable items.

msftm cluster (~13 HIGH):
- coordinate_metadata.yaml: add 'lev' entry (same shape as olevel) so the
  set_coordinates step recognises the depth dim that compute_msftmz emits
  as 'lev'.
- bounds.py: add 'rho' to the vertical_coord_names default list so
  add_vertical_bounds builds rho_bnds for msftm density variables.
- lrcs_ocean recipe: add add_vertical_bounds + set_coordinates to the 4
  msftm pipelines (msftmz, msftm_density, msftmmpa_depth,
  msftmmpa_density). They were emitting rho/basin/lev coord vars with no
  CF attrs at all because set_coordinates was never called.

§7.2 cell_measures (~67 MEDIUM):
- variable_attributes.py: when DReq supplies cell_measures='' (canonical
  for hm/scalar quantities), drop the attribute from both attrs and
  da.attrs. cf-checker §7.2 regex
  ``^(?:area|volume):\s+\w+$`` rejects empty strings; WCRP registry has
  no value for cell_measures on these variables either. Absence passes
  both checks.
- variable_attributes.py: when cell_measures is the combined
  ``area: areacello volume: volcello`` form (DReq emits this for ocean
  3D), trim to just ``area: areacello``. The same cf-checker regex
  doesn't match the combined form even though CF §7.2 itself allows it.

§7.1 soil-layer bounds (3 MEDIUM):
- custom_steps.py: load_lpjguess_monthly_depth constructed sdepth_bnds
  with attrs={'long_name': 'depth layer boundaries', 'units': 'm'}.
  CF §7.1 requires bounds variables to inherit boundary-related attrs
  from the parent coord, not carry their own. Set attrs={} on
  sdepth_bnds, same shape as the time_bnds construction in time_bounds.py.

Affects mrsll_mon, tsl_mon, mrsol_mon (all three use the same loader).
Closes the portability gap external collaborators reported. blosc-encoded
files cannot be opened without hdf5plugin / a matching libhdf5 plugin
build, and we have already paid that cost in-house: HANDOFF_memory_pressure.md
documents that neither NCO 5.0.6 (spack) nor NCO 5.3.3 (conda) on
Levante can read our cli output even with HDF5_PLUGIN_PATH set; we wrote
pyconcat specifically as a workaround.

Drop the 'netcdf_compression_codec: blosc_zstd' + 'netcdf_compression_level: 3'
overrides from the inherit blocks of all 17 production tier yamls plus
3 example yamls. The pycmor fallback at files.py:1147 is already 'zlib'
and the config default at config.py:481 is level 1, matching CMIP6
archive convention.

BitGroom-5 quantization is independent of codec and stays on (default in
files.py:1152, no recipe override), so lossy compression is unchanged.
The byte-level wrapper around the already-quantized data is what
changes.

Perf: bench_hr_ua_6hr_results.md / OPTIMIZATION_PLAN.md concluded
'compression is innocent' — disabling compression made wall slower, not
faster, because page cache scales with file size. Switching codec is
perf-neutral at this scale.

Expected size inflation per HANDOFF_netcdf_compression_codec.md: ~10-30%
vs blosc_zstd-3 on HR climate data. Bracket on real data with
'nccopy -d 1 -s' before relaunching.

The 18 remaining cmip7_bench_hr_ua_6hr_*.yaml fixtures keep their
per-rule blosc_zstd overrides untouched: they are historical A/B
benches for the memory-pressure investigation, changing them would
invalidate the documented baselines.
Closes the two genuinely fixable HIGH findings from cli81:

VAR004 Bounds for 'rho' (msftm cluster, 1 HIGH):
  Pipeline order in the five vertical-bnds pipelines (4 msftm + the
  existing ocean_vertical_integration) had add_vertical_bounds BEFORE
  timeavg. xarray's Dataset.resample(time=...).mean() broadcasts every
  data variable that lacks the time dim onto a fresh time axis, so
  rho_bnds(rho, bnds) emerged from timeavg as
  rho_bnds(time, rho, bnds) — the broadcast is what the cli81 finding
  ('Found (12, 89, 2)') flagged. Same shape bug applies to lev_bnds in
  the ocean-level variant.

  Fix: reorder to timeavg → set_time_bounds → add_vertical_bounds.
  Add the vertical bnds AFTER the time axis has been resampled, so they
  inherit the final shape (89, 2) instead of getting picked up by the
  resample.

  Also: bounds.py:calculate_bounds_1d was writing
  attrs={'long_name': '<coord> bounds'} on the bnds DataArray. CF §7.1
  forbids bnds vars from carrying boundary-related attrs; drop to
  attrs={}.

§3.3 Standard Name on time_bnds (1 HIGH):
  cf-checker §3.3 fires on aux-coord variables that carry neither
  long_name nor standard_name. The previous CF §7.1 fix stripped both
  from time_bnds. The two checks have opposite-looking demands but
  reconcile if bnds attrs MATCH the parent: §7.1 wants no mismatch,
  §3.3 wants a name present.

  Fix in time_bounds.py: instead of attrs={}, copy long_name from the
  parent time coord onto time_bnds. Both freshly-built (line 281) and
  existing-bnds (line 205) paths now do this. §3.3 passes (long_name
  present), §7.1 passes (matches parent).

Counterintuitive findings from the cli80 vs cli81 diff:
  - §7.1 soil-layer (3 MEDIUM in cli79) already cleared between cli79
    and cli80; the 126f4d7 sdepth_bnds attrs={} change was already
    effective in cli80 (no regression in cli81 vs cli80).
  - §7.2 cell_measures fix from 126f4d7 swapped the 24 cf §7.2
    findings into 24 wcrp ATTR001 'attribute missing' findings on
    siarea/siextent/sisnmass/sivol. Net MEDIUM count unchanged. The
    three checks (cf §7.2 format, wcrp ATTR001 existence, wcrp ATTR004
    registry value) have mutually-exclusive demands for variables where
    the registry has no cell_measures value; clearing requires upstream
    coordination, not a pycmor-side fix.
…llow-up)

cli90 verified that the cc-plugin-wcrp #52 upgrade is live (TIME001 now
reports midpoint=True), but the 57 sub-daily TIME001 HIGH did not clear.
Mismatch is at the same value (0.020833 expected vs 0.041666 got), the
file structure unchanged from cli82.

Root cause: _bnds_centered_on_time and _shift_bnds_half_step_backward
only handled np.datetime64 and cftime-object bounds. FESOM/XIOS
time_bnds ship without a units attribute, so xarray leaves them as
float 'days since ...'. The float path fell into the cftime branch and
hit Exception on float.total_seconds(), silently returning False. The
log line 'shifted time_bnds back by dt/2' never printed; the bnds and
midpoint stayed straddled around the timestamp.

Add a float branch:
  - _bnds_centered_on_time: detect float dtype, compare halves in days,
    1 minute tolerance = 1/1440 day.
  - _shift_bnds_half_step_backward: same vectorised path as datetime64
    for any floating dtype.

Smoke test on cli90 1hr tas data:
  centered? True
  shifted bnds: [[-5e-09  0.04166], [0.04166, 0.08333], ...]
  midpoint of shifted: [0.02083, 0.0625, 0.10416] = 00:30, 01:30, 02:30
That is the canonical CMIP layout TIME001 (midpoint=True) expects.

cli91 should drop the 57 sub-daily TIME001 HIGH cleanly. Remaining HIGH:
1 basin (#49) + 2 msftm lev (our code follow-up).
cli82 STATE doc identified the msftm 'ol' (ocean-level) file as the
last fixable HIGH cluster (2 findings on
msftm_tavg-ol-hyb-sea_mon_glb_g130_*.nc):

  [VAR005] Coord monotonicity for 'lev'
  [VAR012] Bounds value consistency for 'lev'

Root cause: compute_msftmz and compute_msftmmpa_depth pull the vertical
axis from FESOM's mesh.zlev, which stores depths as negative-down
(0, -5, -10, ..., -6250 m). The file then declares lev:positive='down'
and lev:standard_name='depth', which together demand POSITIVE values
increasing with depth. wcrp's VAR005 (monotonicity) and VAR012
(bounds-value consistency) both flag the sign mismatch.

Fix: negate at both sites (compute_msftmz line 4467,
compute_msftmmpa_depth line 5146). On-disk coord becomes
0, 5, 10, ..., 6250 — monotonic increasing under positive=down, matches
lev_bnds direction, satisfies both checks.

Expected cli91 vs cli90 HIGH delta (combined with 22ba44a OIFS-XIOS
float-dtype fix):

  TIME001 sub-daily:  59 -> 0  (22ba44a)
  basin geophysical:   1 -> 1  (upstream cc-plugin-wcrp#49)
  VAR005 lev msftm:    1 -> 0  (this commit)
  VAR012 lev msftm:    1 -> 0  (this commit)
  TOTAL HIGH:         62 -> 1  (only basin remains, upstream-blocked)
Root cause of every cli66-cli91 sub-daily TIME001 failure traced to
src/pycmor/std_lib/time_bounds.py lines 734-737 (Paul Gierz 02d89e6,
2026-04-01). The 'default: consecutive time points as bounds' branch
built bnds[i] = (t[i], t[i+1]) — straddling the source timestamp rather
than centered on it. For input at HH:30 (the canonical CMIP midpoint),
that yielded bnds = (HH:30, HH+1:30); the downstream
``new_time = midpoint(bnds)`` then shifted time forward to HH+1:00.

Source OIFS-XIOS files are already canonical:
  time_counter      = 00:30, 01:30, 02:30, ...   (midpoints)
  time_counter_bnds = (00:00, 01:00), (01:00, 02:00), ...

Pycmor was breaking the canonical layout. Output had:
  time     = 01:00, 02:00, 03:00, ...
  time_bnds = (00:30, 01:30), (01:30, 02:30), ...

Fix: build bnds centered on the input timestamp:
  bnds[i] = (t[i] - dt/2, t[i] + dt/2)
For input at HH:30 with dt=1h:
  bnds[0] = (00:00, 01:00) — canonical, midpoint = HH:30 = input.

The earlier 3e09122/22ba44a2 attempt to fix this lived in the
existing-bnds branch and only fired when source bnds had no units attr;
it was also based on a misdiagnosis (assumed XIOS shipped centered
bnds), so removed entirely along with the now-unused helpers
_bnds_centered_on_time / _shift_bnds_half_step_backward.

Smoke test:
  input = [00:30, 01:30, 02:30, 03:30]
  bnds[0] = (00:00, 01:00)
  midpoint = [00:30, 01:30, 02:30, 03:30] == input ✓

Expected cli92 vs cli91:
  TIME001 sub-daily: 59 -> 0
  basin geophysical:  1 -> 1 (still cc-plugin-wcrp#49)
  TOTAL HIGH:        60 -> 1
Hardens against the Levante AssocMaxJobsLimit-induced cancellations
that cost us one shard in cli91 (lrcs_land_1 went CANCELLED+ after
pending too long under the 20-job-per-association cap).

submit_hr_year_shards.sh:
  - Add ARRAY_MAX_CONCURRENT env knob (default 4). Each per-tier sbatch
    array gets ``--array=1-N%${ARRAY_MAX_CONCURRENT}`` so no single
    array can monopolise all 20 association slots and starve the
    smaller arrays into the cancel-after-pending zone. Single-shard
    arrays skip the %cap (keeps log noise down).

resubmit_cancelled_shards.sh:
  - New companion script. Reads the submit log next to the workdir,
    walks each tier's sbatch jobid via sacct, identifies any task in
    CANCELLED* state, and resubmits each as its own size-1 array
    pointing at the same shard yaml. Run after a sweep finishes to
    catch any losers.

Usage:
  resubmit_cancelled_shards.sh /scratch/$USER/pycmor_hr/cli92_full 1851
cli93 regressed badly (119 HIGH vs 60 in cli91) because the centered-bnds
fix from 3976a28 interacted with the wcrp_treats_as_instantaneous
branch in set_time_bounds. The pycmor-side AVG list was the pre-#52
version (no 1hr/3hr/6hr), so sub-daily tavg files were classified as
instantaneous and got time = bnds[:, 0] (period_start) instead of
time = midpoint(bnds).

Combined with the centered bnds = (t-dt/2, t+dt/2) build, the
period_start assignment shifts time backward by dt/2. For source at
1851-01-01 00:30 that puts the new time at 1851-01-01 00:00, then
the FIRST stamp's bnds[0] = (1850-12-31 23:30, 1851-01-01 00:30)
straddles the year boundary — file-split logic then peels the first
stamp into a 1850-named orphan file (1 stamp in
clt_..._18500101-18501231.nc, 8759 stamps in the 1851 file).

cc-plugin-wcrp#52 (merged 2026-06-25 to master, beb00ce) extended
AVERAGE_CORRECTION_FREQ to include 1hr/3hr/6hr. With the wcrp plugin
upgrade live since the cli90 turn, the pycmor mirror has been stale.
Sync it: add 1hr/3hr/6hr to _WCRP_AVG_FREQS. Sub-daily tavg files now
take the "mean" path, time = midpoint(centered bnds) = input time
(zero shift), file-split keeps everything in 1851.

Expected cli94 vs cli93:
  TIME001 sub-daily:  87 -> 0
  TIME003 (filename coverage):  31 -> 0
  basin geophysical:   1 -> 1
  TOTAL HIGH:        119 -> 1
Fixes the pycmor-side issues Martin Schupfner flagged in his review of
the cli94 files. Each item is either a direct spec-compliance fix or a
defensive cleanup matched to published CMIP7 guidance.

Global attributes (global_attributes.py):
  Drop CMIP7-eliminated attrs from required_global_attributes per
  Appendix 2 of the CMIP7 Global Attributes guidance:
    - further_info_url (link doesn't resolve; obsolete es-doc reference)
    - grid (free-text; source_id + grid_label cover the intent)
    - source_type (AOGCM-style label; no CMIP7 role)
    - sub_experiment (always "none" for our runs; noise)
    - sub_experiment_id (kept for internal DRS path use, not written)
    - table_id (CMIP6-style "A1hr"; CMIP7 uses "atmos"/etc., not checked)
    - source (required in CMIP6, optional in CMIP7; our value was
      "<source_id> <realm>" which duplicated source_id)
  Also add _sanitize_attr_value to translate em-dash (U+2014), en-dash
  (U+2013), figure-dash (U+2012) to ASCII hyphen. Older libhdf5 builds
  can mis-encode these in attribute strings across writer/reader locale
  boundaries.

Filename time tokens (files.py):
  Emit CMIP7 Appendix 1-mandated precisions:
    yr/yrPt/dec         YYYY-YYYY
    mon/monC/monPt      YYYYMM-YYYYMM
    day                 YYYYMMDD-YYYYMMDD
    6hr/3hr/1hr/*hrPt   YYYYMMDDhhmm-YYYYMMDDhhmm
    subhr/subhrPt       YYYYMMDDhhmmss-YYYYMMDDhhmmss
  Previous revs clamped everything to 6/8-digit to fit the pre-#46
  wcrp TIME003 regex. That was spec-violating. cc-plugin-wcrp#46 must
  land before sub-daily/subhourly files pass the checker.

Time bounds (time_bounds.py):
  A. Skip time_bnds creation for time_method="instantaneous" (tpt).
     CMIP7 output requirements (matching CMIP6 practice) forbid bounds
     on instantaneous variables. Also drop any source-supplied bnds and
     clear the parent time coord's ``bounds`` attribute.
  B. Stop emitting the int64 bnds(bnds) index helper on new
     bnds vars. It was added to give cf §3.3 a metadata target but is
     unusual for CMIP output and adds no value.

Coordinates attr (coordinate_attributes.py):
  Only list AUXILIARY (non-dim) coordinates in ``coordinates`` on data
  variables. Dim coords are implicit per CF. When no aux coord exists,
  drop the attribute entirely rather than emit a stale value inherited
  from source.
cli95 vs cli94: HIGH 46 → 100, MEDIUM 224 → 763. Two of the three
Schupfner-review items in f7cbcda backfired. Rolling them back with
better implementations.

Item 2 (filename token: 57 TIME003 findings cleared)

f7cbcda built the filename token from ``time[0]`` / ``time[-1]``,
which for tavg sub-daily land on midpoint stamps (HH:30). The CMIP7
spec says the token describes COVERAGE, not the timestamps. Fix:
prefer ``time_bnds[0, 0]`` / ``time_bnds[-1, 1]`` (period edges) when
bounds exist, fall back to ``time[0]`` / ``time[-1]`` for tpt files
that carry no bnds. Token now reads e.g. ``185101010000-185201010000``
instead of ``185101010030-185112312330``.

Still needs cc-plugin-wcrp#46 to land for the 12-digit form to clear
pre-#46 regex, but the semantic is now spec-correct regardless of
checker version.

Item 3 (tpt time_bnds: 42 TIME001 findings cleared for LUT yearlies)

f7cbcda skipped set_time_bounds entirely whenever
``time_method=="instantaneous"``. That also skipped the time
realignment (``time = bnds[:, 0] = period_start``) that the wcrp_treats_
as_instantaneous branch does, so LPJ-GUESS LUT yearly tpt files
(cLitterLut, cSoilLut) ended up with time stamps at mid-year instead of
Jan 1. TIME001 then flagged 42 files with ``expected -181.000000, got
0.000000`` because the checker's theoretical time (from filename_start)
disagreed with the mid-year stamp.

Fix: keep the full flow (so time gets realigned), then at the END drop
time_bnds only if the CMIP7 axis for this variable's temporal_shape has
``must_have_bounds="no"``. Only ``time-point`` (→ time1 axis) maps to
"no"; everything else keeps bnds.

Vendored ``CMIP7_coordinate.json`` from WCRP-CMIP/cmip7-cmor-tables
main (cmor_version 3.15, table_date 2026-07-01) at
``src/pycmor/data/cmip7/CMIP7_coordinate.json`` so lookups don't hit
the network. Bump the vendored copy when the upstream table date
changes.

Projected cli96 vs cli95 with both fixes:
  TIME003 filename: 57 → 0 (post-#46; unchanged pre-#46)
  TIME001 LUT:       42 → 0
  TOTAL HIGH:       100 → 1 (basin, still #49)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants