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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 29 additions & 4 deletions src/coupler.jl
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,48 @@ 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}}[]
for (label, subdir) in COUPLER_COMPONENTS
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
Expand Down
38 changes: 38 additions & 0 deletions test/test_coupler.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading