diff --git a/NEWS.md b/NEWS.md index a6dea9a..1c0c032 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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) diff --git a/docs/src/api.md b/docs/src/api.md index 2939ba1..9f46025 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -20,6 +20,12 @@ default_inversion_obs default_gpcp_obs ``` +## Display units + +```@docs +to_display_units +``` + ## Index ```@index diff --git a/src/benchmark.jl b/src/benchmark.jl index 7a8689b..008227c 100644 --- a/src/benchmark.jl +++ b/src/benchmark.jl @@ -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 @@ -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 @@ -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 diff --git a/src/cache.jl b/src/cache.jl index a99b9f5..87c34e1 100644 --- a/src/cache.jl +++ b/src/cache.jl @@ -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, @@ -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 `/.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 @@ -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 diff --git a/src/dashboard.jl b/src/dashboard.jl index 6449df4..ab6fd67 100644 --- a/src/dashboard.jl +++ b/src/dashboard.jl @@ -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, diff --git a/test/test_benchmark.jl b/test/test_benchmark.jl index dd9644f..bae8999 100644 --- a/test/test_benchmark.jl +++ b/test/test_benchmark.jl @@ -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