File size: 4,544 Bytes
5b5c6a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd075dd
 
 
 
5b5c6a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"""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"
        )
    }