Download utils/diagnostics.py from charlie-becker/CAMulator: direct link, hf CLI and curl.
- Browser
- Download file 22 kB
-
https://huggingface.co/charlie-becker/CAMulator/resolve/main/utils/diagnostics.py
- Command line
-
hf download hf://charlie-becker/CAMulator/utils/diagnostics.py
-
curl -L -o diagnostics.py https://huggingface.co/charlie-becker/CAMulator/resolve/main/utils/diagnostics.py
22 kB
| """Diagnostics for the CAMulator timescales tutorial: plain functions on xarray objects. | |
| Conventions: latitude/longitude coordinates named 'latitude' and 'longitude' (0-360 in the data). A region is a dict | |
| {'lat': (lat_min, lat_max), 'lon': (lon_min, lon_max)} with longitudes in degrees east (negative = west, e.g. -20 for | |
| 20°W); lon_min > lon_max means the region crosses the date line. The older tuple form (lat_min, lat_max, lon_min, | |
| lon_max) in 0-360 is also accepted. | |
| """ | |
| import cftime | |
| import numpy as np | |
| import pandas as pd | |
| import xarray as xr | |
| from scipy.ndimage import minimum_filter | |
| LAT, LON = "latitude", "longitude" | |
| G = 9.80665 | |
| MM_PER_DAY = 4000.0 # metres per 6-h step -> mm/day | |
| KM_PER_DEG = 111.2 | |
| # --- averaging ------------------------------------------------------------------------------------------------------- | |
| def area_weights(lat): | |
| """Relative cell area ∝ |Δ sin(lat)|, computed as CREDIT's conservation fixers do (cells are equal in longitude).""" | |
| lat = np.asarray(lat) | |
| return xr.DataArray(np.abs(np.gradient(np.sin(np.deg2rad(lat)), edge_order=2)), coords={LAT: lat}, dims=LAT) | |
| def select(da, region=None): | |
| """Subset to a region (see the module docstring); a region without 'lon' keeps all longitudes.""" | |
| if region is None: | |
| return da | |
| if isinstance(region, dict): | |
| la0, la1 = region["lat"] | |
| lo0, lo1 = region.get("lon", (0, 360)) | |
| if lo1 - lo0 >= 360: # all the way round: keep every longitude | |
| return da.sel({LAT: slice(la0, la1)}) | |
| lo0, lo1 = lo0 % 360, lo1 % 360 | |
| else: | |
| la0, la1, lo0, lo1 = region | |
| da = da.sel({LAT: slice(la0, la1)}) | |
| if lo0 <= lo1: | |
| return da.sel({LON: slice(lo0, lo1)}) | |
| return xr.concat([da.sel({LON: slice(lo0, 360)}), da.sel({LON: slice(0, lo1)})], dim=LON) | |
| def area_mean(da, region=None, mask=None): | |
| """Area-weighted mean over latitude and longitude (optionally in a region and/or where a boolean mask is True).""" | |
| da = select(da, region) | |
| w = area_weights(da[LAT].values) | |
| if mask is not None: | |
| w = w * select(mask, region).astype(float) | |
| return da.weighted(w).mean([LAT, LON]) | |
| def rms(da, region=None): | |
| """Area-weighted root-mean-square over latitude and longitude.""" | |
| return np.sqrt(area_mean(da**2, region)) | |
| def zonal_mean(da): | |
| return da.mean(LON) | |
| def band_mean(zm, lat_min, lat_max): | |
| """Area-weighted mean of a zonal-mean field over a latitude band.""" | |
| sub = zm.sel({LAT: slice(lat_min, lat_max)}) | |
| return sub.weighted(area_weights(sub[LAT].values)).mean(LAT) | |
| def noleap(when): | |
| """'1981-03-03T06' -> cftime no-leap datetime (the calendar of CAMulator's data and output).""" | |
| t = pd.Timestamp(when) | |
| return cftime.DatetimeNoLeap(t.year, t.month, t.day, t.hour) | |
| def days_since(times, ref): | |
| """Days from cftime `ref` to each of `times`.""" | |
| return np.array([(t - ref).total_seconds() for t in np.asarray(times)]) / 86400.0 | |
| # --- vertical integrals on the hybrid-sigma grid --------------------------------------------------------------------- | |
| def layer_thickness(ds, hyai, hybi, like="Qtot", lev="level"): | |
| """Pressure thickness Δp_k (Pa) of each model layer from the interfaces p = hyai + hybi * PS (hyai in Pa), float32, | |
| with the dims ordered like ds[like].""" | |
| ilev = hyai.dims[0] | |
| def per_layer(c): | |
| d = c.diff(ilev).rename({ilev: lev}).drop_vars(lev, errors="ignore") | |
| return d.assign_coords({lev: ds[lev].values}).astype("float32") | |
| return (per_layer(hyai) + per_layer(hybi) * ds["PS"].astype("float32")).transpose(*ds[like].dims) | |
| def precipitable_water(ds, hyai, hybi): | |
| """Column water PW = (1/g) Σ Qtot_k Δp_k (kg/m² = mm). Stays lazy if Qtot is lazy.""" | |
| return (ds["Qtot"].astype("float32") * layer_thickness(ds, hyai, hybi)).sum("level") / G | |
| # --- sea-level pressure and cyclone tracking --------------------------------------------------------------------------- | |
| def mslp(ps, trefht, phis): | |
| """Mean sea-level pressure (Pa) from PS, TREFHT and surface geopotential PHIS, using CREDIT's own Trenberth et al. | |
| (1993) formula (credit.postblock.mslp, the function behind its mslp_diagnostic postblock).""" | |
| import torch | |
| from credit.postblock.mslp import mslp_from_surface_pressure | |
| assert phis.shape == ps.shape[-2:], "PHIS must be on the same grid as PS" | |
| out = mslp_from_surface_pressure( | |
| torch.as_tensor(np.asarray(ps.values), dtype=torch.float64), | |
| torch.as_tensor(np.asarray(trefht.values), dtype=torch.float64), | |
| torch.as_tensor(np.asarray(phis.values), dtype=torch.float64), | |
| ) | |
| return xr.DataArray(out.numpy(), coords=ps.coords, dims=ps.dims, name="MSLP") | |
| def great_circle_deg(lat1, lon1, lat2, lon2): | |
| """Great-circle distance in degrees of arc.""" | |
| r = np.deg2rad | |
| c = np.sin(r(lat1)) * np.sin(r(lat2)) + np.cos(r(lat1)) * np.cos(r(lat2)) * np.cos(r(lon1) - r(lon2)) | |
| return np.rad2deg(np.arccos(np.clip(c, -1, 1))) | |
| def follow_low(slp, time, lat, lon, max_jump=6.0): | |
| """Track one low forward and backward in time from (time, lat, lon): at each step, the nearest local minimum of slp | |
| within max_jump degrees of the previous position. Returns DataFrame(time, lat, lon, p) sorted by time.""" | |
| lats, lons = slp[LAT].values, slp[LON].values | |
| times = list(slp["time"].values) | |
| def minima(k): | |
| f = slp.isel(time=k).values | |
| i, j = np.nonzero(f == minimum_filter(f, size=(5, 5), mode=("nearest", "wrap"))) | |
| return lats[i], lons[j], f[i, j] | |
| def walk(k, a, o, step): | |
| rows = [] | |
| while 0 <= k < len(times): | |
| la, lo, p = minima(k) | |
| d = great_circle_deg(a, o, la, lo) | |
| if not (d < max_jump).any(): | |
| break | |
| n = int(np.argmin(d)) | |
| a, o = la[n], lo[n] | |
| rows.append({"time": times[k], "lat": a, "lon": o, "p": p[n]}) | |
| k += step | |
| return rows | |
| k0 = times.index(time) | |
| rows = walk(k0, lat, lon, +1) | |
| rows += walk(k0 - 1, rows[0]["lat"], rows[0]["lon"], -1) if rows else [] | |
| return pd.DataFrame(rows).sort_values("time").reset_index(drop=True) | |
| def track_near(slp, ref, radius=10.0): | |
| """The storm in a forecast, located relative to a reference track: at each reference time the forecast covers, the | |
| lowest slp within `radius` degrees of the reference position. If that lowest point is on the rim of the search disk | |
| there is no closed low inside, and the row is NaN. Returns DataFrame(time, lat, lon, p).""" | |
| lats, lons = slp[LAT].values, slp[LON].values | |
| LA, LO = np.meshgrid(lats, lons, indexing="ij") | |
| times = list(slp["time"].values) | |
| rows = [] | |
| for t, a, o in zip(ref["time"], ref["lat"], ref["lon"]): | |
| if t not in times: | |
| continue | |
| f = slp.isel(time=times.index(t)).values | |
| d = great_circle_deg(a, o, LA, LO) | |
| i, j = np.unravel_index(np.argmin(np.where(d <= radius, f, np.inf)), f.shape) | |
| if d[i, j] > radius - 2.0: | |
| rows.append({"time": t, "lat": np.nan, "lon": np.nan, "p": np.nan}) | |
| else: | |
| rows.append({"time": t, "lat": LA[i, j], "lon": LO[i, j], "p": f[i, j]}) | |
| return pd.DataFrame(rows, columns=["time", "lat", "lon", "p"]) | |
| def storm_summary(track, ref): | |
| """Deepest point of a forecast track, and its position error (km) at the time the reference track is deepest.""" | |
| peak = ref.loc[ref["p"].idxmin()] | |
| at_peak = track[track["time"] == peak["time"]] | |
| if track["p"].notna().any(): | |
| deepest = track.loc[track["p"].idxmin()] | |
| min_p, t_min = deepest["p"], deepest["time"] | |
| else: | |
| min_p, t_min = np.nan, None | |
| err = np.nan | |
| if len(at_peak) and np.isfinite(at_peak["lat"].iloc[0]): | |
| err = great_circle_deg(peak["lat"], peak["lon"], at_peak["lat"].iloc[0], at_peak["lon"].iloc[0]) * KM_PER_DEG | |
| return {"min_p": min_p, "time_of_min": t_min, "peak_position_error_km": err} | |
| # --- error growth ---------------------------------------------------------------------------------------------------- | |
| def doubling_time(lead, err, max_err, min_lead=1.0): | |
| """Days per doubling from a straight-line fit of log2(err) against lead, using leads >= min_lead where | |
| err <= max_err (the exponential phase, before saturation). NaN if fewer than 3 points qualify.""" | |
| lead, err = np.asarray(lead, float), np.asarray(err, float) | |
| ok = (lead >= min_lead) & (err <= max_err) & (err > 0) | |
| if ok.sum() < 3: | |
| return np.nan | |
| slope = np.polyfit(lead[ok], np.log2(err[ok]), 1)[0] | |
| return 1.0 / slope if slope > 0 else np.inf | |
| def first_lead(lead, values, threshold): | |
| """First lead at which values >= threshold (NaN if never).""" | |
| hit = np.nonzero(np.asarray(values) >= threshold)[0] | |
| return float(np.asarray(lead)[hit[0]]) if hit.size else np.nan | |
| # --- noise: blocks within a run -------------------------------------------------------------------------------------- | |
| def block_mean(da, spinup_days, block_days): | |
| """Mean over consecutive block_days-long blocks after spin-up, using da's 'lead' coordinate (days since the start). | |
| Dim 'block'; an incomplete last block is dropped.""" | |
| lead = da["lead"].values | |
| keep = np.nonzero(lead > spinup_days)[0] | |
| idx = np.floor((lead[keep] - spinup_days - 1e-9) / block_days).astype(int) | |
| counts = np.bincount(idx) | |
| out = da.isel(time=keep).drop_vars("lead").assign_coords(time=idx).rename(time="block").groupby("block").mean() | |
| return out.sel(block=np.nonzero(counts == counts.max())[0]) | |
| def block_quantile(da, q, spinup_days, block_days, region=None): | |
| """Quantile q of all values (time, lat, lon) in each block after spin-up, within a region.""" | |
| da = select(da, region) | |
| lead = da["lead"].values | |
| idx = np.floor((lead - spinup_days - 1e-9) / block_days).astype(int) | |
| blocks = [b for b in np.unique(idx[lead > spinup_days]) if (idx == b).sum() == np.bincount(idx[idx >= 0]).max()] | |
| vals = [float(np.quantile(da.isel(time=np.nonzero(idx == b)[0]).values, q)) for b in blocks] | |
| return xr.DataArray(vals, coords={"block": blocks}, dims="block") | |
| def paired(exp, ctl, noise_a, noise_b): | |
| """One-year Δ = block mean of (exp - ctl); the noise pair's own Δ (noise_b - noise_a); and the standard error of a | |
| one-year Δ from the noise pair's block differences (blocks paired by date). Returns (delta, noise_delta, se).""" | |
| noise = noise_b - noise_a | |
| se = noise.std("block", ddof=1) / np.sqrt(noise.sizes["block"]) | |
| return (exp - ctl).mean("block"), noise.mean("block"), se | |
| def itcz(zm, band=20.0): | |
| """ITCZ metrics from zonal-mean precipitation zm(..., latitude), on a 0.1° grid between band S and N: | |
| centroid (latitude splitting the area-weighted precipitation in half; Frierson & Hwang 2012), spread | |
| (precipitation-weighted standard deviation of latitude; smaller = narrower) and P_trop (band-mean precipitation).""" | |
| fine = np.arange(-band, band + 1e-6, 0.1) | |
| w = np.cos(np.deg2rad(fine)) | |
| zm = zm.sortby(LAT) | |
| flat = zm.transpose(..., LAT).values.reshape(-1, zm.sizes[LAT]) | |
| rows = [] | |
| for p in flat: | |
| pw = np.interp(fine, zm[LAT].values, p) * w | |
| cum = np.cumsum(pw) | |
| mean_lat = (pw * fine).sum() / pw.sum() | |
| rows.append((np.interp(0.5 * cum[-1], cum, fine), np.sqrt((pw * (fine - mean_lat) ** 2).sum() / pw.sum()), | |
| pw.sum() / w.sum())) | |
| rows = np.array(rows) | |
| other = [d for d in zm.dims if d != LAT] | |
| shape = [zm.sizes[d] for d in other] | |
| coords = {d: zm[d] for d in other if d in zm.coords} | |
| return xr.Dataset({k: (other, rows[:, i].reshape(shape)) for i, k in enumerate(["centroid", "spread", "P_trop"])}, | |
| coords=coords) | |
| # --- spectra and small-sample statistics ------------------------------------------------------------------------------ | |
| def zonal_spectrum(da, lat_min, lat_max): | |
| """Power at each zonal wavenumber k (number of waves around a latitude circle), from an FFT along longitude, | |
| area-weighted over a latitude band and averaged over every other dimension (e.g. time). DataArray over 'wavenumber'.""" | |
| sub = da.sel({LAT: slice(lat_min, lat_max)}) | |
| x = np.asarray(sub.transpose(..., LAT, LON).values, dtype=float) | |
| p = np.abs(np.fft.rfft(x - x.mean(axis=-1, keepdims=True), axis=-1)) ** 2 | |
| w = area_weights(sub[LAT].values).values | |
| p = (p * w[:, None]).sum(axis=-2) / w.sum() | |
| p = p.reshape(-1, p.shape[-1]).mean(axis=0) | |
| return xr.DataArray(p, coords={"wavenumber": np.arange(p.size)}, dims="wavenumber") | |
| def t95(n): | |
| """Half-width of a 95% range in standard errors for a mean of n samples (Student's t, n - 1 degrees of freedom): | |
| 4.30 for 3 samples, 2.23 for 11, approaching 1.96 for many.""" | |
| from scipy.stats import t | |
| return float(t.ppf(0.975, n - 1)) | |
| def log_binned(spectrum, n_bins=18): | |
| """Average a spectrum (over 'wavenumber') in log-spaced wavenumber bins from 1 to the largest wavenumber, which | |
| smooths the noisy small scales. Returns (bin centres, binned power) as numpy arrays.""" | |
| kmax = int(spectrum["wavenumber"].max()) | |
| edges = np.unique(np.round(np.logspace(0, np.log10(kmax + 1), n_bins + 1)).astype(int)) | |
| power = np.array([float(spectrum.sel(wavenumber=slice(lo, hi - 1)).mean()) for lo, hi in zip(edges[:-1], edges[1:])]) | |
| return np.sqrt(edges[:-1] * np.maximum(edges[1:] - 1, edges[:-1])), power | |
| # --- helpers that keep the notebook short ------------------------------------------------------------------------------ | |
| def east_west(da): | |
| """Longitudes as -180..180 (negative = west), sorted west to east.""" | |
| return da.assign_coords({LON: (da[LON] + 180) % 360 - 180}).sortby(LON) | |
| def after_spinup(ds, days): | |
| """Drop the first `days` of a run (uses the 'lead' coordinate).""" | |
| return ds.isel(time=np.nonzero(ds["lead"].values > days)[0]) | |
| def mean_and_range(values, dim): | |
| """Mean over `dim` and the half-width of its 95 % range (Student's t, for few samples).""" | |
| n = values.sizes[dim] | |
| return values.mean(dim), t95(n) * values.std(dim, ddof=1) / np.sqrt(n) | |
| # Section 1: the butterfly experiment and the wave diagrams. | |
| def plateau(drift, last_days=3): | |
| """The level a drift curve settles at: its average over the last few days.""" | |
| return float(drift.where(drift["lead"] > float(drift["lead"].max()) - last_days, drop=True).mean()) | |
| def doubling_days(drift, unrelated): | |
| """Days per doubling while the difference is still small (below a quarter of `unrelated`).""" | |
| return doubling_time(drift["lead"], drift, 0.25 * unrelated) | |
| def time_to_reach(drift, level): | |
| """First lead (days) at which the drift reaches `level`.""" | |
| return first_lead(drift["lead"].values, drift.values, level) | |
| def equivalent_noise(model_error, copy_drift, copy_noise, day=2): | |
| """Starting noise (K) that would give a copy as large a difference as the model error, at a given day. Early on, | |
| differences scale with the starting noise, so this is the copy's noise times the ratio of the two curves.""" | |
| k = int(np.argmin(abs(model_error["lead"].values - day))) | |
| return copy_noise * float(model_error[k]) / float(copy_drift[k]) | |
| def wave_diagram(v, region): | |
| """Time-longitude diagram: wind averaged over the region's latitudes, longitudes -180..180.""" | |
| return east_west(select(v, region).mean(LAT)).load() | |
| def wave_slice(field, lon): | |
| """One column of a wave diagram: the wind over time at the longitude nearest `lon`.""" | |
| return field.sel({LON: lon}, method="nearest") | |
| def wave_peak(series, when, window_days=1.25): | |
| """Strongest northward wind in a wave_slice within +-window_days of a date (waves pass every ~3 days, so this | |
| finds the one wave near `when`).""" | |
| t = noleap(when) | |
| series = series.sel(time=slice(t - pd.Timedelta(f"{window_days}D"), t + pd.Timedelta(f"{window_days}D"))) | |
| k = int(series.argmax("time")) | |
| return {"time": series["time"].values[k], "wind": float(series[k])} | |
| def hours_between(later, earlier): | |
| return (later - earlier).total_seconds() / 3600 | |
| def weekly_correlation(a, b, start): | |
| """Correlation between two wave diagrams, week by week from the start date.""" | |
| days = days_since(a["time"].values, noleap(start)) | |
| out = [] | |
| for week in range(int(np.ceil(days.max() / 7))): | |
| sel = np.nonzero((days > 7 * week) & (days <= 7 * (week + 1)))[0] | |
| out.append(float(np.corrcoef(a.isel(time=sel).values.ravel(), b.isel(time=sel).values.ravel())[0, 1])) | |
| return out | |
| # Section 2: spectra. | |
| def spectrum_ratio(forecast, truth, lead, lats, n_bins=18): | |
| """Forecast power / truth power at each (binned) zonal wavenumber, over latitudes `lats` and the forecast times in a | |
| window around `lead` (just the first step for lead < 1; +-1 day otherwise). Returns (wavenumbers, ratio) or None.""" | |
| lo, hi = (0, 0.5) if lead < 1 else (lead - 1, lead + 1) | |
| truth_times = set(truth["time"].values) | |
| keep = [i for i, (ld, t) in enumerate(zip(forecast["lead"].values, forecast["time"].values)) | |
| if lo < ld <= hi and t in truth_times] | |
| if not keep: | |
| return None | |
| f = forecast.isel(time=keep) | |
| k, pf = log_binned(zonal_spectrum(f, *lats), n_bins) | |
| _, pt = log_binned(zonal_spectrum(truth.sel(time=f["time"]), *lats), n_bins) | |
| return k, pf / pt | |
| def effective_resolution(k, ratio, min_k=10): | |
| """The wavenumber (>= min_k) beyond which the forecast has less than half of the truth's variation for good, | |
| interpolated (in log wavenumber) between the last bin at or above one half and the next one. NaN if never.""" | |
| k, ratio = np.asarray(k, dtype=float), np.asarray(ratio, dtype=float) | |
| above = np.nonzero((k >= min_k) & (ratio >= 0.5))[0] | |
| if len(above) == 0 or above[-1] == len(k) - 1: | |
| return np.nan | |
| i = above[-1] | |
| fraction = (ratio[i] - 0.5) / (ratio[i] - ratio[i + 1]) | |
| return float(np.exp(np.log(k[i]) + fraction * (np.log(k[i + 1]) - np.log(k[i])))) | |
| # Section 3: ensembles. | |
| def weekly_difference(experiment, control, variable, region): | |
| """Experiment minus control, member by member, as weekly means over a region: DataArray (member, week).""" | |
| diffs = [] | |
| for exp_run, ctl_run in zip(experiment, control): | |
| e = block_mean(area_mean(exp_run[variable], region), 0, 7) | |
| c = block_mean(area_mean(ctl_run[variable], region), 0, 7) | |
| diffs.append((e - c).load()) | |
| d = xr.concat(diffs, "member") | |
| return d.assign_coords(block=d["block"] + 1).rename(block="week") | |
| def noise_band(control, variable, region, from_week=3): | |
| """How big a difference between two runs' weekly means gets from weather noise alone: the largest |control i - | |
| control j| over all pairs of control members and all weeks from `from_week` on (by then the members' weather is | |
| unrelated, section 1). Falls back to the last week if the run is shorter.""" | |
| runs = [block_mean(area_mean(run[variable], region), 0, 7).load() for run in control] | |
| pairs = [abs(runs[i] - runs[j]) for i in range(len(runs)) for j in range(i + 1, len(runs))] | |
| weekly = xr.concat(pairs, "pair") | |
| weeks = weekly["block"] + 1 | |
| return float(weekly.where(weeks >= min(from_week, int(weeks.max()))).max()) | |
| def outside_noise(weekly, band): | |
| """Number of members whose difference is bigger than the noise band, each week. DataArray (week).""" | |
| return (abs(weekly) > band).sum("member") | |
| def map_difference(run, other, variable, days): | |
| """Map of run minus other, averaged over the lead days (first, last].""" | |
| def average(r): | |
| lead = r["lead"].values | |
| return r[variable].isel(time=np.nonzero((lead > days[0]) & (lead <= days[1]))[0]).mean("time") | |
| return (average(run) - average(other)).load() | |
| # Section 4: one year. | |
| SEASON_MONTHS = {"DJF": [12, 1, 2], "MAM": [3, 4, 5], "JJA": [6, 7, 8], "SON": [9, 10, 11]} | |
| def clausius_clapeyron_rate(temperature): | |
| """Clausius-Clapeyron: % increase in saturation vapour pressure per K, L / (R_v T^2), at temperature T (K).""" | |
| return 100 * 2.501e6 / (461.5 * temperature**2) | |
| def monthly(da): | |
| """Calendar-month means (one year of output gives each month once).""" | |
| return da.groupby("time.month").mean() | |
| def percent_change_per_month(warm, control, region=None, mask=None): | |
| """Monthly % change of a field's area mean, warm vs control: DataArray over month.""" | |
| w = monthly(area_mean(warm, region, mask)) | |
| c = monthly(area_mean(control, region, mask)) | |
| return 100 * (w / c - 1), c | |
| def seasonal_change(warm, control, region, season): | |
| """% change of a region's rain in one season, its 95 % range (from the season's three months), and the control's | |
| mean in that season.""" | |
| pct, ctl = percent_change_per_month(warm, control, region) | |
| months = SEASON_MONTHS[season] | |
| if not set(months) <= set(pct["month"].values): # the run doesn't cover this season | |
| return np.nan, np.nan, np.nan | |
| mean, half = mean_and_range(pct.sel(month=months), "month") | |
| return float(mean), float(half), float(ctl.sel(month=months).mean()) | |
| def days_per_tenfold(drift, useful_until_days): | |
| """How many days of useful forecast each 10x smaller starting error buys, from the copies' 'useful until' days. | |
| drift: {noise_K: drift curve}; useful_until_days: {noise_K: days}.""" | |
| noises = sorted(useful_until_days) | |
| gains = [] | |
| for small, large in zip(noises[:-1], noises[1:]): | |
| if np.isfinite(useful_until_days[small]) and np.isfinite(useful_until_days[large]): | |
| gains.append((useful_until_days[small] - useful_until_days[large]) / np.log10(large / small)) | |
| return float(np.mean(gains)) if gains else np.nan | |
| def small_scale_detail(forecast, truth, lead, lats, min_k=20): | |
| """% of the truth's small-scale variation (zonal wavenumbers >= min_k) that the forecast has, around `lead`.""" | |
| result = spectrum_ratio(forecast, truth, lead, lats) | |
| if result is None: | |
| return np.nan | |
| k, ratio = result | |
| return 100 * float(ratio[k >= min_k].mean()) | |