Add GenTS as an option for making time series files - #467
Conversation
The CESM project ADF supports is adopting GenTS (https://github.com/AgentOxygen/GenTS) as its time series tool. This adds a 'ts_tool' switch in 'diag_basic_info' so a run can build its time series with GenTS instead of ADF's built-in ncrcat path, and makes ADF able to read a time series tree that GenTS produced elsewhere. The integration is small because GenTS already writes $case.$stream.$variable.$dates.nc, which is what ADF globs for. Pointing HFCollection at 'cam_hist_loc' collapses its output path template, so files land flat in 'cam_ts_loc' with ADF-identical names and nothing downstream has to know which back end produced them. - lib/adf_gents.py: the interface. GenTS is imported inside the function so it stays an optional dependency, with an actionable error when missing. - lib/adf_diag.py: dispatch on 'ts_tool', plus get_ts_case_config() extracted so both back ends read the config from one place and cannot drift apart. - lib/adf_utils.py: find_ts_files() searches the flat layout first and falls back to a recursive search, so a GenTS <component>/proc/tseries/<freq>/ archive works without callers knowing the frequency sub-directory. - Route the core discovery sites through it (adf_dataset, adf_derive, adf_info, amwg_table). get_climo_yrs_from_ts() also loses its hard-coded '*h0*' in favour of the case's configured stream, and now raises instead of tripping over an unbound variable when nothing is found. Two behavioural notes, both documented in the config file: GenTS treats any non-time-varying variable as "secondary" and copies it into every file, so hyam/hybm/hyai/hybi ride along and vertical regridding is unaffected. PS is time-varying, so GenTS gives it its own file rather than packing it into each 3-D variable. ADF can already pick PS up from a separate file, but only when PS is in 'diag_var_list', so adf_gents checks for that up front rather than letting 3-D variables silently drop out later. GenTS needs numpy>=2.0 while numba (reached via xarray/flox during the climatology step) needs numpy<=2.2, so a plain 'pip install gents' on top of the ADF environment makes every climatology fail. Install the overlap: 'pip install gents "numpy>=2.0,<2.3"'. Verified end to end on b.e23_alpha17f.BLT1850.ne30_t232.098 vs .093: both back ends produce 17 files with identical names, byte-identical T data and time values, and 14 identical plots. Ingesting a pre-made GenTS tree with 'cam_ts_done: true' and blank years skips generation and discovers years 10-11. All 21 unit tests pass, including 4 new ones for find_ts_files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes that came out of exercising the GenTS back end for real, plus the issue this unblocks. The 'gents_options' block did not work. ADF's config reader allows only one level of nesting (adf_config.py:111), so a nested block under 'diag_basic_info' is rejected outright. The earlier testing never caught this because every option was commented out. These are now flat entries -- 'gents_all_vars', 'gents_nested_layout', 'gents_slice_years', 'gents_compression' and 'gents_compression_level' -- and a missing compression level is now reported up front instead of failing part-way through generation. Setting 'gents_slice_years' then exposed NCAR#161: amwg_table.py carried a "#TEMPORARY" guard that skipped any variable with more than one time series file, even though utils.load_dataset already handles a list. That made the AMWG tables silently empty for chunked archives -- which is what GenTS writes when slicing is on, and how CMIP-style time series are laid out. Replace the guard with utils.ts_files_overlap(), which reads the date ranges out of the file names. Consecutive chunks are combined; overlapping sets (the NCAR#433 case, where a run is extended and re-processed over a longer period) are still skipped, because combining them would put duplicates on the time axis. Unreadable names are treated as overlapping, so nothing is combined on a guess. Removing the guard revealed why it was there: with several files utils.load_dataset uses open_mfdataset, so the arrays are dask-backed, and _get_row_vals called .data.item() on them, which dask arrays do not have. The mean was also being handed to a numeric format spec while still lazy, which would have formatted an object rather than a number. Reduce those statistics to plain floats up front. The reductions themselves are unchanged, so the single-file results are untouched. Verified on b.e23_alpha17f.BLT1850.ne30_t232.098 vs .093: - Years 10-13 with 'gents_slice_years: 1' produce 4 chunks per variable (68 files). The full run now completes and all 34 variable rows are written to the tables, with sensible values (PS 98529.831 Pa, SWCF -43.201 W/m2, sample size 4). Before this change every variable was skipped. - Re-running the single-file case reproduces all three table CSVs byte-identically to before the change, so nothing regressed. - 27 unit tests pass, including 6 new ones for ts_files_overlap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deriving a variable (e.g. RESTOM from FSNT and FLNT) failed with ValueError: When encoding chunked arrays of datetime values, both the units and dtype must be prescribed or both must be unprescribed. The constituent files are opened with open_mfdataset, so 'time_bounds' stays a dask array, and newer xarray refuses to encode a chunked datetime array whose units come without a dtype. Load those (tiny) variables before writing. Also give the derived variable its own 'long_name' instead of inheriting the first constituent's -- RESTOM was labelled "Net longwave flux at top of model" -- and correct the units of the aerosol variables, which are multiplied by dry air density and so are no longer mixing ratios. Both of these are in the shared derivation code, so they applied to the built-in ncrcat back end as well as to GenTS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tested with a derived variable (RESTOM), and found a bug that is not GenTS'sI wanted to check that the GenTS back end can handle a derived variable, so I ran The GenTS side worked. It saw that RESTOM was missing, looked up its ingredients in the But the step that actually builds RESTOM crashed: I then ran exactly the same thing with What was going wrong, in plain termsTo build RESTOM, ADF opens the The trouble is a small variable called The fix is small: read those few date values into memory before writing. They are tiny (two A second, unrelated thing I fixed while I was in thereThe derived variable was copying its description from the first ingredient it was made from. which is the description of The same copying problem also left the aerosol variables with the wrong units. Those get ResultWith those two fixes, both back ends produce RESTOM, and the numbers agree exactly:
Both files now carry Since the fix is in |
Summary. Adds a Classification: framework ( Blocking1. constit_matches = utils.find_ts_files(ts_dir, f"*.{constit}.*.nc")
if constit_matches:
constit_files.append(str(constit_matches[0])) # first chunk only
Downstream this is silent: This is a new interaction, not pre-existing debt: Non-blocking2. The 3. 4. The 10 new unit tests will be skipped in CI, not run. 5. §6.2 docstrings on new public functions. 6. Comment contradicts the code it labels. 7. Non-overlapping but non-contiguous files are combined silently. 8. 9. 10. PR description undercounts the tests. It says "All 21 unit tests pass, including 4 new ones"; there are 10 new tests and 27 total. Presumably written before the second commit. Optional / follow-up (pre-existing, not this PR's job)
Verified
Not verified
Finding #1 is the one to fix before merge. #4 (tests skipping in CI) is the one most likely to matter six months from now. |
Two bugs in derive_variable, both in the constituent-file lookup. 1. Only the first chunk was used. find_ts_files returns every match, but the code took constit_matches[0], so with 'gents_slice_years' set (or a CMIP-style archive) a derived variable was built from the first chunk alone. RESTOM over years 1-20 came out covering years 1-10, named for that shorter span, and the AMWG table then reported it alongside full-length variables with no warning. All of a constituent's files are now opened together and the derived variable is written as a single file spanning them, which is what the built-in back end produces. Constituents whose own files overlap are refused, reusing ts_files_overlap - the same guard amwg_table uses. 2. load_dataset was handed a Path where it needs a list. It starts with len(fils), and len(PosixPath) raises TypeError. This worked before the switch to find_ts_files only because glob.glob()[0] returned a string. It fires for any variable that is both derived and in 'aerosol_zonal_list' - SO4, SOA, BC, POM and DUST all qualify - which no existing test configuration exercises. PMID and T now take the whole list. Besides fixing the crash, this stops a single-chunk PMID from silently NaN-ing a full-span product through time-axis alignment. Fixing 2 makes the 'if not ds_pmid' / 'if not ds_t' guards reachable for the first time; they printed and then fell through to indexing None, so they now return instead. Adds ts_file_span() for naming the combined file, factored with ts_files_overlap over a shared _ts_file_spans() parser. With one file per constituent the span is that file's own dates, so names and contents are unchanged for every existing config and the whole default ts_tool: adf path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull_metadata is the one GenTS call in the module not reassigned, because it mutates in place and returns None; say so rather than leaving it looking like a dropped result. _restrict_to_vars indexes 'primary_var', a key of GenTS's internal order dictionaries rather than part of its documented surface; note that a rename surfaces as a KeyError there. Comments only, no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md 6.2 asks new public functions for Parameters and Returns sections in the NumPy style adf_utils and plotting_functions already use. get_ts_case_config and create_time_series_gents had prose-only docstrings; create_time_series_gents also now lists the adfobj attributes it touches and the conditions under which it raises AdfError. find_ts_files had its return description on the type line. Docstrings only, no behavior change. pylint on adf_diag.py and adf_info.py stays at 9.81 (threshold 9.5). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The constituent, PMID and T patterns carried no case name, and find_ts_files falls back to a recursive search when the flat glob misses. A cam_ts_loc pointing at a tree that holds more than one case could therefore build one case's derived variable out of another's data. Demonstrated with two cases under one tree covering adjacent, and so non-overlapping, periods - which the overlap guard cannot catch: before: acase/acase.cam.h0a.RESTOM.000101-004012.nc 480 times after: mycase/mycase.cam.h0a.RESTOM.000101-002012.nc 240 times mycase's RESTOM was built from both cases' data and written under the other case's directory under the other case's name, with no warning. _find_constit tries the case-anchored pattern first and falls back to the looser pattern ADF has always used, so time series directories whose names do not lead with the case name keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
find_ts_files falls back to a recursive search whenever the flat glob finds nothing, and a missing variable is a normal condition, not an error. get_climo_yrs_from_ts tried len(hist_strs)+1 patterns for every variable in diag_var_list until one hit, so pointing cam_ts_loc at a deep archive could trigger that many full tree walks before the first useful result. find_ts_files takes a recursive keyword, defaulting to today's behavior. get_climo_yrs_from_ts now sweeps every variable flat first and only then makes a single recursive pass, so a nested (GenTS-style) layout is still found but is no longer re-walked per pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit tests added for find_ts_files and ts_files_overlap were guarded by skipUnless and never ran: the ADF unit test workflow installs only PyYAML and pytest, while adf_utils imports numpy, xarray, pandas and geocat at module level. CI reported green on ten tests it had skipped. Verified in a venv holding only pyyaml and pytest, which is what ADF_unit_tests.yaml builds: before: 4 failed, 13 passed, 10 skipped after: 33 passed (the 4 failures are an artifact of extracting only lib/ for the comparison - config_cam_baseline_example.yaml lives at the repo root) The three helpers move to lib/adf_file_utils.py, which imports nothing but pathlib. adf_utils re-exports them, so utils.find_ts_files(...) is unchanged for adf_dataset, adf_derive, adf_info and amwg_table. No logic changes - a move plus a re-export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_climo_yrs_from_ts had its control flow restructured into a flat sweep followed by one recursive sweep, and nothing covered it directly - only find_ts_files was tested, in isolation. Eleven tests over the layouts it has to handle: flat, nested (GenTS-style), missing first variable, no configured stream, a list of streams, chunked files, and both error paths. Ten of the eleven pass unchanged against the previous one-phase search, which is the point: the restructuring is behavior-preserving for every ordinary case. The eleventh pins down where it is not. When the first variable in diag_var_list exists only nested while a later variable sits flat, the flat sweep now reaches the later variable first and the years come from that file; searching one variable at a time returned the first variable's nested file. Both are legitimate - the method documents that it assumes every variable covers the same dates - and preferring a flat match is what find_ts_files already does (test_find_ts_files_prefers_flat). The test records the choice so a future change to the search order is noticed. These import adf_info, so they pull in xarray and skip in CI, which installs only PyYAML and pytest. Noted in the module docstring; moving the search into adf_file_utils would make it CI-visible but is a larger change than these tests were written to cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Derivation runs once per configured history stream, but derive_variable was never told which one. The constituent search therefore matched every stream's copy of a constituent, and two streams hold files whose dates are identical. For an ordinary derived variable the overlap guard then rejected the constituent and the run reported "Not all constituent files present", so a case with hist_str: [cam.h0a, cam.h0b] - a documented, supported configuration - lost every derived variable. This was a regression: the previous code took the first match and produced a file, stream-blind but present. On the aerosol path it was worse. PMID and T from two streams went to open_mfdataset together, which cannot order datasets with identical time coordinates, and the resulting ValueError propagated out of create_time_series and ended the run with a traceback - which AGENTS.md 4.5 rules out for framework code. hist_str now reaches derive_variable from both back ends and is the first pattern _find_constit tries, ahead of the case-anchored and unanchored fallbacks. Also guards the dry air density calculation. PMID and T are looked up with a fallback that can reach another case's files, and xarray aligns on time with an outer join, so a disjoint pair does not raise - it writes an all-NaN field that looks like a real one. Verified writing SO4 with nan_frac 1.0 before this change; now refused with a message naming the variable. Adds eight tests. Five pass against the previous commit; the three that fail are exactly the cases above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flat-then-recursive sweep checked every pattern flat, for every
variable, before recursing at all. That let the loose "any h0 stream"
fallback outrank the stream the user configured: a stray flat file from
another stream beat the configured stream's file whenever the latter sat
in a nested archive. The comment two lines above says "Try the
configured stream(s) first", and it no longer did.
Sweeping by pattern rank instead - every variable against the configured
stream, flat then recursive, before any variable is tried against the
fallback - restores that precedence and keeps the reason for the original
change, which was to avoid one full tree walk per pattern per variable.
Compared old and new over 5100 combinations of 8 candidate file layouts,
4 variable lists and 5 hist_str settings:
identical result 4428
found vs not-found differs 0
new picks a non-configured stream where the
old picked a configured one 0
differ only in which *variable* was chosen 672
The remaining 672 are the deliberate consequence already recorded in
test_flat_sweep_precedes_recursive_sweep_across_variables: a flat match
for a later variable beats a nested match for an earlier one. The method
documents that it assumes every variable covers the same dates, so which
variable supplies them is not meaningful, whereas which stream does is.
Also stops reporting every variable as missing while walking a nested
archive; only variables genuinely passed over are logged now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The derived file name came from a plain string replace of the constituent's name, so a case name that contains it was rewritten too: case 'bFSNTtest' deriving RESTOM from FSNT produced 'bRESTOMtest.cam.h0a.RESTOM...', which AdfData.get_timeseries_file searches for by case name and can never find. Substituting whole dot-separated tokens instead leaves the case name alone. Pre-existing, but this line was already being rewritten. Two comments claimed chunked CMIP-style archives were supported. They are not: the date token is read as the last dot-separated part of the stem, and a CMIP name has no interior dots, so the whole basename becomes the token and cannot be parsed -- _ts_file_spans(['tas_Amon_CESM2_historical_r1i1p1f1_gn_185001-201412.nc']) -> None -> treated as overlapping -> skipped Refusing is the right behavior; claiming support for it was not. Both comments now say only ADF/GenTS names have readable dates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adf_file_utils is a new framework module and scores 10.00/10 under lib/test/pylintrc, so add it to the hard-coded testable_files set the linting workflow checks; nothing was watching it before. Single backticks are emphasis in reST, not literals (AGENTS.md 6.2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both the config comment and the ImportError message told users to pin numpy below 2.3, stating as fact that numba rejects anything newer. That was true of the numba installed when this branch was written, but the cap moves with each numba release: numba 0.65.0 declares numpy<2.5,>=1.22. Verified by running the full pipeline three times - model vs model on both back ends, and model vs obs - on numba 0.65.0 with numpy 2.4.3, gents 1.2.0 and netCDF4 1.7.4. Every climatology succeeded, and "pip install gents" needed no dependency changes at all on that stack. The old instruction would have forced a pointless numpy downgrade. Reworded to describe the real constraint - numba caps numpy, the cap depends on the numba version, and a rejected numpy makes every climatology fail while ADF still reports success - and to say check the pair after installing rather than pin a specific bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review notesReviewed against This branch is based on Checks on the merged state
Verified specifically
Follow-up items — neither blocks this PR
|
What this does
ADF turns model history files into time series files (one file per variable). Today it does that itself, by calling
ncrcat. The CESM project is moving to a separate tool called GenTS for this same job.This pull request adds a switch so ADF can hand that step to GenTS instead. It also lets ADF read time series files that GenTS already made somewhere else, so you don't have to make them again.
Nothing changes unless you ask for it. If you leave the new setting out of your config file, ADF behaves exactly as it does today.
How to use it
In your config file, under
diag_basic_info:Use
adf, or leave the line out entirely, to keep the current behavior.There are a few optional settings, all of which can be skipped:
These are separate entries rather than one indented block, because ADF's config
reader only allows one level of indentation.
Why the change is small
I expected this to be a big job and it wasn't, for two reasons.
First, GenTS already names its files the same way ADF does, so nothing downstream needed to be taught a new naming scheme.
Second, if you point GenTS at the folder holding the history files, its output lands in one flat folder, just like ADF's own output. The nested folder layout only shows up if you ask for it.
I also worried that GenTS would leave out the extra pieces ADF needs to work with model levels (
hyam,hybm, and friends). It doesn't. GenTS copies anything that doesn't change over time into every file, so those come along automatically.One thing to watch out for
There is one real difference in behavior, and it's worth understanding.
When ADF makes the time series itself, it tucks a copy of surface pressure (
PS) into every 3-D variable's file. GenTS doesn't do that. Because surface pressure changes over time, GenTS treats it as a variable in its own right and gives it its own file.ADF can already find surface pressure in a separate file, but only if you have listed
PSindiag_var_list. So if you use GenTS, addPSto your variable list. If you forget, ADF now stops with a clear message explaining why, instead of quietly dropping all your 3-D variables later on.Installing GenTS
GenTS is optional and is deliberately not added to ADF's conda environment file. It needs newer versions of some packages than ADF currently pins, and changing those pins for everyone is a much bigger decision than this pull request.
Install it yourself, and please pin numpy:
The pin matters. GenTS wants numpy 2.0 or newer. But
numba, which gets pulled in while ADF computes climatologies, refuses anything newer than 2.2. If you just runpip install gents, you get numpy 2.5 and every climatology fails, while ADF still prints that it finished successfully. I hit this during testing and it took a while to spot.If you install into a virtual environment on top of a conda environment, you will also need to set
PROJ_DATAandESMFMKFILEyourself, because virtual environments don't pick those up automatically.A known problem you will see when testing this
While testing I found that every variable fails to be regridded, on this branch and on
mainalike.The cause is a file-naming mismatch that has nothing to do with GenTS. The climatology step writes files named like
mycase_cam.h0a_T_climo.nc, but the regridding step looks formycase_T_*.nc. Those never match, so regridding quietly finds nothing. The run still says "completed successfully", because that step only prints a warning, which is why it's easy to miss.This is already fixed in #435, which rewrites the regridding step to look up climatology files through the shared data-access code instead of matching filenames by hand.
I did not fix it here, to keep this pull request focused. I branched off
main, which is why the problem shows up in my test runs.Related issues
This addresses, or helps with, a few open issues:
ncrcat, so withts_tool: gentsan ADF install no longer needs NCO for this step. The default path still usesncrcat, so the requirement isn't gone, just avoidable.gents_compressioncompresses the time series files when GenTS is doing the work. Climatology and regridded files, and the default path, are unaffected.Two related issues I looked at and deliberately did not fold in:
There is also #440 (make the time series directory instead of aborting), which suggests a change to the same function I touched. I'd push back on that one gently. That code only runs when
cam_ts_doneis true, which is the user saying "my time series already exist". If the directory isn't there, something is wrong with the configuration, and creating an empty directory just moves the failure somewhere less obvious. I think the clear error is the right behavior, so I left it alone — happy to be told otherwise.Suggested order for merging
adf_dataset.py,adf_info.py,adf_utils.py), but the edits are in different parts of those files, so any conflicts should be small and mechanical. Merging it after Taylor Diagram with observations and multi-case #435 also means the GenTS option can finally be checked against working regridding, which is exactly where the surface pressure difference described above matters.Testing done
Run end to end on Casper, comparing cases
b.e23_alpha17f.BLT1850.ne30_t232.098and.093(years 10-11).ts_tool: adf) to confirm nothing broke. Finished successfully.ts_tool: gents). Finished successfully.hyam,hybm, and so on) really are inside the GenTS files, and that the "cannot be regridded" message never appears in either run.PS.cam_ts_done: trueand no years given. ADF skipped making files and correctly worked out the years were 10 to 11.Two things turned up while testing and are fixed here:
gents_optionsblock. That silently doesn't work, because ADF's config reader only allows one level of indentation. They are now separate entries.gents_slice_yearsproduced four files per variable and revealed that the AMWG table script skipped every variable in that situation (AMWG Tables for more than one time series file per variable #161 above). Fixing that in turn exposed that the table statistics assumed data loaded from a single file. Both are fixed, and re-running the single-file case reproduces the old tables exactly, character for character.One caveat on the comparison: because regridding is broken on
main(see above), the matching plot counts only cover the plots that don't need regridding. The map plots weren't exercised for either option. That leg is worth redoing once #435 is in.Review round (added after the first review)
An AGENTS.md-driven review of this branch is in the comments.
It found one blocker; a second, worse one turned up while fixing it. Both were in
derive_variable, and both are fixed here.Blocker 1 - chunked constituents were truncated.
derive_variabletookconstit_matches[0], the first chunk only. Withgents_slice_years: 10over years1-20, RESTOM came out covering years 1-10, named for that shorter span, and the AMWG
table then reported it alongside full-length variables with no warning. All of a
constituent's files are now opened together and the derived variable is written as a
single file spanning them, matching what the built-in back end produces. Constituents
whose own files overlap are refused, reusing
ts_files_overlap.Blocker 2 -
TypeErroron the aerosol path.load_datasetwas handed aPathwhere it needs a list; it starts with
len(fils), andlen(PosixPath)raises. Thisonly worked before the switch to
find_ts_filesbecauseglob.glob()[0]returned astring. It fires for any variable that is both derived and in
aerosol_zonal_list-SO4, SOA, BC, POM and DUST all qualify - which is why my test case (which only derives
RESTOM) never hit it. PMID and T now take the whole list, which also stops a
single-chunk PMID from silently NaN-ing a full-span product through time-axis
alignment.
Also addressed from the review:
name, and
find_ts_filesfalls back to a recursive search. With two cases under onetree covering adjacent (so non-overlapping) periods,
mycase's RESTOM was built fromboth cases' data and written under the other case's directory under the other
case's name:
acase/acase.cam.h0a.RESTOM.000101-004012.nc, 480 times, instead ofmycase/mycase.cam.h0a.RESTOM.000101-002012.nc, 240 times. Now anchored to the casename, falling back to the looser pattern so existing directories keep working.
adf_utilsimports numpy/xarray/pandas/geocat at module level, and the unit test workflow installs only PyYAML and pytest,
so all ten tests were skipped and CI reported green. The three helpers moved to
lib/adf_file_utils.py, which imports nothing but pathlib;adf_utilsre-exportsthem so every caller is unchanged. Checked in a venv holding only pyyaml and pytest:
10 skipped before, 33 passing and 0 skipped now.
get_climo_yrs_from_tscould trigger a full recursive walk per pattern per variable.find_ts_filesgained arecursivekeyword (default unchanged); the year search nowsweeps flat first and makes a single recursive pass afterwards.
Parameters/Returnsonget_ts_case_configandcreate_time_series_gents(AGENTS.md 6.2), and comments on the two GenTS assumptionsthat read like bugs (
pull_metadatamutating in place,primary_varbeing a GenTSinternal).
Not changed here, deliberately. The review also flagged that
amwg_table._get_row_valscomputes the standard error asstd / nrather thanstd / sqrt(n), and usesdata.std()(ddof=0) where a sample standard deviation iswanted. Both predate this PR. They change published table numbers, so they belong in
their own PR rather than riding along here.
Single-file constituents produce byte-identical filenames and contents, so the default
ts_tool: adfpath is unchanged. pylint onadf_diag.pyandadf_info.pyis 9.81against the 9.5 CI threshold (
mainis exactly 9.50).Second review round
The fixes above were themselves reviewed, by an agent given only the diff and
AGENTS.mdand none of the reasoning behind them. That was worth doing: it foundtwo blocking bugs that the first round introduced or left, both verified before
fixing.
Derived variables were lost for cases with several history streams. Derivation runs
once per configured stream, but
derive_variablewas never told which one, so theconstituent search matched every stream's copy — and two streams hold files with
identical dates. The overlap guard then rejected the constituent, and a case with
hist_str: [cam.h0a, cam.h0b]lost every derived variable. On the aerosol path PMID andT from two streams went into
open_mfdatasettogether, which cannot order datasets withidentical time coordinates, and the
ValueErrorpropagated out ofcreate_time_seriesand ended the run with a traceback (AGENTS.md §4.5 rules that out for framework code).
Verified against the round-one code and against this PR as originally submitted:
TypeErrorValueErrorhist_strnow reachesderive_variablefrom both back ends and is the first patterntried. Note the middle column: round one was a real regression on the first row.
The configured history stream could lose to the loose fallback. The flat-then-recursive
sweep in
get_climo_yrs_from_tschecked every pattern flat, for every variable, beforerecursing at all — so a stray flat file from another stream beat the configured stream's
file whenever that sat in a nested archive, contradicting the comment two lines above it.
Sweeping by pattern rank restores the precedence and keeps the performance win. Compared
old against new over 5100 combinations of 8 file layouts × 4 variable lists × 5
hist_strsettings:
Those 672 are a deliberate consequence, recorded in a test: a flat match for a later
variable now beats a nested match for an earlier one. The method documents that it assumes
every variable covers the same dates, so which variable supplies them is not meaningful,
whereas which stream does is.
Also from this round:
reach another case's files, and xarray aligns on time with an outer join — so a disjoint
pair does not raise, it writes an all-NaN field that looks real. Reproduced writing SO4
with
nan_frac = 1.0; now refused with a message naming the variable.constituent, so case
bFSNTtestderiving RESTOM from FSNT producedbRESTOMtest.cam.h0a.RESTOM..., whichAdfData.get_timeseries_filesearches for by casename and can never find. Whole dot-separated tokens are substituted now.
work. They do not — the date token is the last dot-separated part of the stem, and a CMIP
name has no interior dots, so it cannot be parsed and the files are refused. Refusing is
right; claiming support was not. The comments now say only ADF/GenTS names have readable
dates.
lib/adf_file_utils.pyis added to the linting workflow'stestable_filesset (it scores10.00/10), and no longer reports every variable as missing while walking a nested archive.
Tests. 38 new, up from the 16 after round one:
test_adf_file_utils.py(13, and theserun in CI),
test_adf_derive.py(9) andtest_adf_info_climo_yrs.py(13). The latter twoimport xarray so they skip in CI, which installs only PyYAML and pytest — noted in their
module docstrings, with moving the searches into
adf_file_utilsas the follow-up thatwould make them CI-visible. Each set was run against the preceding commit to confirm it
fails there: of the 8 original
test_adf_derive.pycases, 5 pass against round one and the3 that fail are exactly the bugs above.
pylint on
adf_diag.pyandadf_info.pyis 9.81 against the 9.5 threshold.A note on speed
GenTS groups all the variables from one set of history files into a single task, so for a typical ADF run it works through them one at a time. In my test, 17 variables took about two minutes, where ADF's own approach spreads variables across processors. It's not wrong, just slower for this shape of job. Worth measuring before recommending GenTS for very large runs.
End-to-end runs on real data
Run on Casper against two deliberately dissimilar cases — different stream naming,
different era, different vertical grid:
b.e30_alpha09e_m.B1850C_MTso_Gris_Marbl.ne30_t233_wgx3.389,cam.h0a, 93 levels, years 11–15ceresmip_amip02,cam.h0, 32 levels, years 2000–2004Variables
PS, TS, ICEFRAC, RESTOM, PRECT, Q— covering a derived variable, a 3-Dfield needing vertical interpolation, and two fields with no obs entry.
ts_tool: adfts_tool: gentsThe two back ends are equivalent. Same eight variables, identical file names,
identical time axes, and
np.array_equaltrue on every variable's data — includingQat(60, 93, 192, 288). The AMWG tables match character for character. The onlydifferences are the two documented ones: GenTS puts
hyam/hybm/hyai/hybiinevery file, and the built-in back end embeds
PSin the 3-D file where GenTS gives itits own.
RESTOMderived correctly under both. The obs run correctly plotted only the fourvariables that have obs entries and skipped
ICEFRACandRESTOM, which have none.Zonal and polar plots were checked by eye, not just counted: Arctic
ICEFRACwithcorrect coastlines and more ice in the 1850 control than in AMIP;
Qzonal showing thetropical surface humidity maximum with Antarctic topography masked; obs
TSagainstERAI at 288.55 vs 288.30 K, RMSE 1.34 K.
These runs needed #435. Two blockers, both in the regridding step and both fixed
there rather than here — see the merge order below, which they confirm:
mainthe test-case climo lookup never matches, so all three configs producedzero plots while reporting success. Fixed by Taylor Diagram with observations and multi-case #435.
_determine_vertical_coord_type, which inspected the dataset'sdimensions rather than the variable's, so 2-D fields were sent into hybrid
interpolation and the run died with
KeyError: 'lev'. Fixed in#435; it is that PR's function and this
branch only exposes it.
Correction: the numpy pin above is out of date
The install guidance said to pin
numpy>=2.0,<2.3because numba rejects anythingnewer. That was true of the numba installed when this was written, but the cap moves
with each numba release — numba 0.65.0 declares
numpy<2.5,>=1.22. On the stack theseruns used (numba 0.65.0, numpy 2.4.3, gents 1.2.0, netCDF4 1.7.4)
pip install gentsneeded no dependency changes at all, and every climatology succeeded. The comment
and the ImportError message now describe the real constraint and say to check numpy
against numba after installing, rather than pinning a bound that may be wrong for your
environment.