Skip to content
Open
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
14 changes: 14 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@
- The run-title caption wraps long tokens (e.g. commit hashes) and renders
newlines as line breaks

### Bug fixes

- `aggregate_var` now implements the `:monthly` and `:daily` levels that
`available_levels` advertises for sub-monthly-native data (previously it
threw "Unknown aggregation level: monthly", which crashed the server during
precompute for e.g. a daily `pr` output with a registered observation)
- Coupled-mode `precompute_dashboard_cache` now uses the component's own
`SimDir` (pruned to monthly, read from `output_active`) instead of
re-scanning the raw component folder — it no longer trips over stale loose
files ("Time dimension is not in non-decreasing order") or precomputes
entries the UI never serves, and its fingerprints match runtime lookups
- Precompute failures on a single cache entry are warned and skipped instead
of aborting server startup

#### ClimaCoupler (multi-component) outputs
- `dashboard(path; coupled = true)` reads ClimaCoupler output directories
(`clima_atmos/`, `clima_land/`, `clima_ocean/`, `clima_seaice/` subfolders)
Expand Down
6 changes: 6 additions & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ default_inversion_obs
default_gpcp_obs
```

## Display units

```@docs
to_display_units
```

## Index

```@index
Expand Down
18 changes: 18 additions & 0 deletions src/benchmark.jl
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,10 @@ function aggregation_label(var, level)
return "Seasonal"
elseif level == :annual
return "Annual"
elseif level == :monthly
return "Monthly"
elseif level == :daily
return "Daily"
else
return string(level)
end
Expand Down Expand Up @@ -431,6 +435,8 @@ averaging is NaN-aware. `level == :native` returns `var` unchanged.
new time coordinate).
- `:seasonal`: one frame per (year, season) bin, in chronological order. Season
is defined by month: DJF, MAM, JJA, SON. DJF is keyed to the year of January.
- `:monthly`: one frame per (year, month) bin (for daily/hourly-native data).
- `:daily`: one frame per calendar day (for hourly-native data).
"""
function aggregate_var(var, level::Symbol)
level == :native && return var
Expand All @@ -453,6 +459,18 @@ function aggregate_var(var, level::Symbol)
groups = [findall(==(k), keys_per_date) for k in unique_keys]
new_times = [times[g[(length(g)+1)÷2]] for g in groups]
return _aggregate_by_groups(var, groups, new_times)
elseif level == :monthly
keys_per_date = map(d -> (Dates.year(d), Dates.month(d)), dates)
unique_keys = sort!(unique(keys_per_date))
groups = [findall(==(k), keys_per_date) for k in unique_keys]
new_times = [times[g[(length(g)+1)÷2]] for g in groups]
return _aggregate_by_groups(var, groups, new_times)
elseif level == :daily
keys_per_date = map(d -> Dates.Date(d), dates)
unique_keys = sort!(unique(keys_per_date))
groups = [findall(==(k), keys_per_date) for k in unique_keys]
new_times = [times[g[(length(g)+1)÷2]] for g in groups]
return _aggregate_by_groups(var, groups, new_times)
else
error("Unknown aggregation level: $level")
end
Expand Down
39 changes: 29 additions & 10 deletions src/cache.jl
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ end
# ─── Batch precompute ─────────────────────────────────────────────────────────

"""
precompute_dashboard_cache(path; obs = default_era5_obs(), verbose = true, mask_kind = :land)
precompute_dashboard_cache(path; obs = default_obs(), verbose = true, mask_kind = :land, simdir = ClimaAnalysis.SimDir(path))

Warm the persistent benchmark cache for every (variable, reduction, period,
aggregation level) in `path` that has a registered observation. Already-cached,
Expand All @@ -244,14 +244,26 @@ re-run — e.g. after regenerating the longrun, or on each server restart.
`:land` (ocean-masked, the default), `:globe` (atmos components of coupled
runs) or `:ocean`. It must match what the dashboard will use for this output.

Pass `simdir` when the dashboard will serve a filtered/relocated view of
`path` (e.g. a coupled component pruned to monthly diagnostics and read from
`output_active`, see `discover_components`) — precomputing from a different
`SimDir` than the dashboard uses would both cover entries the UI never shows
and produce fingerprints that never match at lookup time. The cache still
lives at `<path>/.climaviz_cache`.

Run this once after producing the simulation output (or in CI). The dashboard
reads whatever is present and falls back to live computation on a miss, so
precomputing is purely a responsiveness optimization.

Returns the `BenchmarkCache`.
"""
function precompute_dashboard_cache(path; obs = default_obs(), verbose = true, mask_kind::Symbol = :land)
simdir = ClimaAnalysis.SimDir(path)
function precompute_dashboard_cache(
path;
obs = default_obs(),
verbose = true,
mask_kind::Symbol = :land,
simdir = ClimaAnalysis.SimDir(path),
)
cache = BenchmarkCache(path; enabled = true)
if isnothing(cache.dir)
@warn "ClimaViz: cache directory unavailable; nothing precomputed" path
Expand Down Expand Up @@ -282,13 +294,20 @@ function precompute_dashboard_cache(path; obs = default_obs(), verbose = true, m
verbose && @info " skip (up-to-date)" var = short_name reduction period level
continue
end
obs_agg = aggregate_obs(o, var, level)
isnothing(obs_agg) && continue
sim_agg = aggregate_var(var, level)
entry = compute_benchmark_entry(sim_agg, obs_agg, fingerprint; mask = mask_spec(mask_kind).mask)
put_cached_entry!(cache, key, entry)
n_done += 1
verbose && @info " cached" var = short_name reduction period level n = entry.n
# Precompute is an optimization: one bad entry must never
# take the server down (the dashboard falls back to live
# computation on a miss), so failures are warned and skipped.
try
obs_agg = aggregate_obs(o, var, level)
isnothing(obs_agg) && continue
sim_agg = aggregate_var(var, level)
entry = compute_benchmark_entry(sim_agg, obs_agg, fingerprint; mask = mask_spec(mask_kind).mask)
put_cached_entry!(cache, key, entry)
n_done += 1
verbose && @info " cached" var = short_name reduction period level n = entry.n
catch e
@warn "ClimaViz: skip $short_name/$reduction/$period/$level (compute failed)" exception = (e, catch_backtrace())
end
end
end
end
Expand Down
5 changes: 4 additions & 1 deletion src/dashboard.jl
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ function dashboard(path; HPC = false, obs = default_obs(), cache = true, precomp
if precompute && cache
for c in components_found
kind = component_mask_kind(c.label)
precompute_dashboard_cache(c.path; obs = obs, mask_kind = kind)
# Pass the component's SimDir (pruned, read from output_active)
# so precompute covers exactly what the UI serves and its
# fingerprints match the runtime lookups.
precompute_dashboard_cache(c.path; obs = obs, mask_kind = kind, simdir = c.simdir)
precompute_summary!(
c.simdir, components[c.label].bench_cache, obs;
fallback_start_date = coupled_fallback_start_date, mask_kind = kind,
Expand Down
38 changes: 38 additions & 0 deletions test/test_benchmark.jl
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,42 @@
@test haskey(obs, name)
end
end

@testset "aggregate_var monthly/daily (sub-monthly native data)" begin
# Daily var spanning a month boundary: Jan 30, Jan 31, Feb 1, Feb 2.
daily = ClimaAnalysis.OutputVar(
Dict{String, Any}(
"short_name" => "pr", "units" => "mm day^-1",
"start_date" => "2000-01-30T00:00:00",
),
Dict("time" => [0.0, 1.0, 2.0, 3.0] .* 86400.0),
Dict{String, Dict}(),
[1.0, 2.0, 3.0, 4.0],
)
monthly = ClimaViz.aggregate_var(daily, :monthly)
@test length(monthly.dims["time"]) == 2
@test vec(monthly.data) ≈ [1.5, 3.5]

# Hourly var spanning a day boundary: 23:00 Jan 1, then 00:00/01:00 Jan 2.
hourly = ClimaAnalysis.OutputVar(
Dict{String, Any}(
"short_name" => "pr", "units" => "mm day^-1",
"start_date" => "2000-01-01T23:00:00",
),
Dict("time" => [0.0, 1.0, 2.0] .* 3600.0),
Dict{String, Dict}(),
[1.0, 3.0, 5.0],
)
by_day = ClimaViz.aggregate_var(hourly, :daily)
@test length(by_day.dims["time"]) == 2
@test vec(by_day.data) ≈ [1.0, 4.0]

# Regression: every advertised level must aggregate without throwing
# (a daily var advertises :monthly, which used to error and could take
# the whole precompute down).
for level in ClimaViz.available_levels(daily)
agg = ClimaViz.aggregate_var(daily, level)
@test agg isa ClimaAnalysis.OutputVar
end
end
end
Loading