From 51e6a2f2a367cc9ace079527edef9a624880198b Mon Sep 17 00:00:00 2001 From: Alexandre Alexis Renchon Date: Mon, 15 Jun 2026 17:13:59 +0000 Subject: [PATCH] Read coupled components from output_active to avoid stale-file stitch error `discover_components` built each component's `SimDir` from the component root (e.g. `clima_atmos/`). Because `ClimaAnalysis.SimDir` enumerates files with `walkdir`, a component folder that held both the current run's `output_active`/`output_NNNN` subfolder and stale loose `.nc` files left at the top level from an earlier run picked up each variable twice and tried to stitch their overlapping time axes, failing with "Time dimension is not in non-decreasing order after aggregating datasets". Add `_resolve_sim_path`, which points the `SimDir` at `output_active` (or the highest-numbered `output_NNNN`) when present and otherwise reads the folder itself (land/ocean write loose files with no `output_*` subdir). The component `path` field stays the folder root so the `.climaviz_cache` location is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- NEWS.md | 10 ++++++++++ src/coupler.jl | 33 +++++++++++++++++++++++++++++---- test/test_coupler.jl | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 076e0b9..df14b54 100644 --- a/NEWS.md +++ b/NEWS.md @@ -39,6 +39,16 @@ tick: the per-frame metrics computation runs asynchronously and only the newest result is applied (latest-wins), with results cached for revisits +### Bug Fixes + +- Coupled-mode discovery now reads each component's `SimDir` from its + current-run output folder (`output_active` / highest `output_NNNN`) when one + exists, instead of the component root. A component folder that also held stale + loose `.nc` files from an earlier run made `SimDir` (which walks the whole + tree) pick up each variable twice and fail to stitch the overlapping time axes + ("Time dimension is not in non-decreasing order"); those leftover files are + now ignored. The `.climaviz_cache` location is unchanged + ## v0.1.4 - 2025-11-11 ### New Features diff --git a/src/coupler.jl b/src/coupler.jl index b430061..6763ad1 100644 --- a/src/coupler.jl +++ b/src/coupler.jl @@ -51,15 +51,40 @@ function _prune_to_1M!(simdir) return simdir end +# Directory a component's `SimDir` should read. `SimDir` walks the tree with +# `walkdir`, so if a component folder holds both the current run's +# `output_active`/`output_NNNN` subfolder *and* stale loose `.nc` files left at +# the top level from an earlier run, it picks up each variable twice and tries +# to stitch their overlapping time axes ("Time dimension is not in +# non-decreasing order"). When an `output_active` symlink (or, failing that, the +# highest-numbered `output_NNNN`) exists, read from it so only the current run's +# files are seen; otherwise read the folder itself (components like land/ocean +# write their diagnostics as loose files with no `output_*` subdir). +function _resolve_sim_path(component_path) + active = joinpath(component_path, "output_active") + isdir(active) && return active + numbered = filter(readdir(component_path)) do entry + startswith(entry, "output_") && + !isnothing(tryparse(Int, last(split(entry, "_")))) && + isdir(joinpath(component_path, entry)) + end + isempty(numbered) && return component_path + return joinpath(component_path, maximum(numbered)) +end + """ discover_components(path) Scan a ClimaCoupler output directory for component subfolders (`clima_atmos`, `clima_land`, `clima_ocean`, `clima_seaice`) and return a `Vector` of `(label = "atmos", path = …, simdir = SimDir(…))` NamedTuples for -every component that contains at least one readable variable. The atmos -component is pruned to its monthly (`_1M_`) native-grid diagnostics (see -`_prune_to_1M!`); empty folders (e.g. an inactive ocean) are skipped. +every component that contains at least one readable variable. Each `SimDir` is +read from the component's current-run output folder (`output_active` / +`output_NNNN`) when present, ignoring stale loose `.nc` files left at the +component root (see `_resolve_sim_path`); `path` stays the component root so the +`.climaviz_cache` location is stable across runs. The atmos component is pruned +to its monthly (`_1M_`) native-grid diagnostics (see `_prune_to_1M!`); empty +folders (e.g. an inactive ocean) are skipped. """ function discover_components(path) components = NamedTuple{(:label, :path, :simdir), Tuple{String, String, Any}}[] @@ -67,7 +92,7 @@ function discover_components(path) component_path = joinpath(path, subdir) isdir(component_path) || continue simdir = try - ClimaAnalysis.SimDir(component_path) + ClimaAnalysis.SimDir(_resolve_sim_path(component_path)) catch e @warn "ClimaViz: failed to read coupler component" component_path exception = e continue diff --git a/test/test_coupler.jl b/test/test_coupler.jl index 9400d70..56ba93b 100644 --- a/test/test_coupler.jl +++ b/test/test_coupler.jl @@ -38,6 +38,44 @@ @test ClimaViz.discover_components(mktempdir()) == [] end + @testset "_resolve_sim_path" begin + # Loose files, no output_* subdir (land/ocean convention): use the + # folder itself. + loose = mktempdir() + touch(joinpath(loose, "gpp_1M_average.nc")) + @test ClimaViz._resolve_sim_path(loose) == loose + + # output_active symlink present (current-run pointer): read from it. + active = mktempdir() + mkpath(joinpath(active, "output_0000")) + symlink("output_0000", joinpath(active, "output_active")) + @test ClimaViz._resolve_sim_path(active) == joinpath(active, "output_active") + + # No output_active, but numbered run folders: take the highest. + numbered = mktempdir() + mkpath.(joinpath.(numbered, ("output_0000", "output_0002", "output_0001"))) + touch(joinpath(numbered, "output_unrelated")) # non-numbered, ignored + @test ClimaViz._resolve_sim_path(numbered) == joinpath(numbered, "output_0002") + end + + @testset "discover_components ignores stale top-level files" begin + # An atmos folder holding the current run under output_active *and* a + # stale variable left loose at the top level: only the current run's + # variables should appear (this is what previously caused the + # "Time dimension is not in non-decreasing order" stitch error). + dir = mktempdir() + atmos = joinpath(dir, "clima_atmos") + mkpath(joinpath(atmos, "output_0000")) + symlink("output_0000", joinpath(atmos, "output_active")) + touch(joinpath(atmos, "output_0000", "pr_1M_average.nc")) + touch(joinpath(atmos, "stale_1M_average.nc")) # leftover, must be ignored + + comps = ClimaViz.discover_components(dir) + @test [c.label for c in comps] == ["atmos"] + @test comps[1].path == atmos # cache root stays the component folder + @test sort(collect(keys(comps[1].simdir.vars))) == ["pr"] + end + @testset "_prune_to_1M! prunes variable_paths too" begin dir = make_fake_coupler_dir() comps = ClimaViz.discover_components(dir)