"""Persistence on the 1 degree grid. The same forecast as the 0.25 degree baseline -- copy the analysis forward unchanged -- sampled onto the coarse grid the platform also accepts. Two jobs, neither of them "another baseline for its own sake": 1. **It exercises the 1 degree path end to end.** The contract allows 0.25 and 1.0, but until something actually runs at 1.0 the coarse path is only proven by tests. Grid shape, sampler indexing and the board's grid label are all resolution-dependent, and all three fail quietly rather than loudly: a coarse forecast sampled as though it were fine still produces numbers that look like weather. 2. **It isolates the cost of resolution alone.** Its skill differs from the 0.25 degree baseline's by exactly one thing -- the grid -- because the forecast itself is identical. Any gap between the two rows on the board is the resolution penalty at the stations, measured rather than assumed. That is a genuinely useful number to have on the board when reading a coarse model's score against a fine one's. ## Why subsampling is honest here, where regridding would not be The IC is 721x1440 at 0.25 degrees. The 1 degree grid is 181x360, and every one of its points falls EXACTLY on a 0.25 degree point -- 90 to -90 in steps of 1.0 is a strict subset of 90 to -90 in steps of 0.25, and likewise for longitude. So `[::4]` selects real analysis values and invents nothing. Verified rather than assumed: subsampling the 0.25 degree axes by 4 reproduces the 1 degree axes with a maximum error of exactly 0.0 on both. Averaging each 4x4 block instead would be defensible as a physical coarsening, but it would make this model's forecast DIFFERENT from the fine baseline's, and then the gap between the two rows would measure "subsample versus average" rather than the resolution penalty. Picking the collocated point keeps the comparison about one variable. """ from __future__ import annotations import numpy as np # The unit the returned arrays are in. # # The harness converts once, at the boundary. This returns a slice of the IC # untouched, and the IC stores 2m temperature in Kelvin -- so Kelvin is what # this says. Claiming deg_C here would score every value about 273 from the # observation, which looks like a catastrophically bad model rather than like a # units bug. OUTPUT_UNITS = {"temperature_2m": "K"} # Index of T+0 on the IC's time axis. # # The IC carries T-12h, T-6h and T+0 in that order. Persistence forecasts from # the most recent analysis, so it is the last element, not the first. Index 0 # would forecast from twelve hours before the init and silently lose that much # skill at every lead. T_ZERO = -1 # 0.25 -> 1.0 degrees. Both axes, because the grid is square in degrees even # though it is not in kilometres. STRIDE = 4 def run(inputs, declaration, init_time): """Subsample the analysis to 1 degree and hold it at every lead. Args: inputs: IC fields as numpy arrays, keyed by ERA5 name. Surface fields are (time, lat, lon) on the 0.25 degree grid. declaration: the parsed earthboi.yaml -- lead_hours, grid_shape, etc. init_time: the init as an aware datetime. Unused: persistence has no time dependence beyond the analysis it starts from. Returns: {"temperature_2m": (lead, lat, lon) float32} on the declared grid. """ analysis = np.asarray(inputs["2m_temperature"])[T_ZERO] rows, cols = declaration["grid_shape"] coarse = analysis[::STRIDE, ::STRIDE] # Checked rather than trusted. If the IC's resolution ever changes, `[::4]` # silently produces a differently-shaped array, and the failure would # otherwise surface as a harness error naming the shape but not the cause. if coarse.shape != (rows, cols): raise ValueError( f"subsampling the {analysis.shape} IC by {STRIDE} gave {coarse.shape}, " f"but the declaration says {(rows, cols)}. This model assumes a 0.25 " "degree IC exactly four times finer than its own grid." ) leads = len(declaration["lead_hours"]) # `broadcast_to` is a read-only view over one grid rather than N copies; # `np.array` then materialises the single contiguous array the harness # writes. At 1 degree that is only ~7 MB, but the idiom matches the fine # baseline so the two read the same. return { "temperature_2m": np.array( np.broadcast_to(coarse, (leads, rows, cols)), dtype="float32" ) }