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)