Skip to main content

nowcast-bench

Evaluation framework for precipitation nowcasting — radar extrapolation, NWP, and blends of the two — built around one rule:

A skill score is not a result until you know how many samples it came from.

Why this exists

Blending radar extrapolation with NWP is supposed to beat either source alone. In practice it is strongly case-dependent. On three real cases the ranking changed with the case, and with lead time inside a case: ensemble blending beat every alternative at all six lead hours on a fast-reorganising supercell, lost badly at +1h on a slow stratiform system before recovering from +3h, and on a flash-flood case lost to both the simple blend and plain extrapolation at the threshold that mattered.

A rule proposed after the first two cases — blending wins early when the storm is growing and spatially concentrated — predicted that it should win from the start on the third. It did not. That third case was a weaker test than the other two (verification only at 0/3/6h, and the NWP field degraded to block-constant snapshots), so it is not a clean refutation of the rule. What it is, is not enough evidence to encode the rule and act on it. So this package does not encode it, or anything like it:

  • There is no recommend_method(case_description) and there never will be. The only way to learn which method wins is evaluate(methods, cases) — an actual measured run.
  • Every metric returns its sample support alongside its value. A CSI of 0.83 computed from 11 hit pixels is not the same object as a CSI of 0.83 from 40 000, and the API refuses to let you conflate them by returning a bare float.
  • Aggregating across cases requires an explicit choice of pooling ("pool" vs "mean"), because those two answer different questions and quietly disagree on skewed case sets.

Status

Early. nowcast_bench.metrics (categorical scores + FSS), nowcast_bench.protocol (cases, methods, evaluate/aggregate/rank), nowcast_bench.blend (fixed and lead-time-dependent blends, plus weight fitting that refuses to be scored on its own fitting cases), nowcast_bench.uncertainty (bootstrap intervals over cases, and rankings that say when their own top two places are not distinguishable) and nowcast_bench.report (Markdown rendering, no score without its support) are implemented. See CONTRIBUTING.md for the rules any change has to follow.

Install

pip install nowcast-bench

The only runtime dependency is NumPy. For development, clone the repository and install the test tools:

pip install -e ".[dev]"

Usage

Categorical scores at a threshold. A point is an event when its value is >= threshold; NaN points (and points where an optional boolean mask is True) are excluded from every count rather than being treated as misses. ±inf is not missing data — it is compared against the threshold like any other value.

import numpy as np
from nowcast_bench.metrics import categorical_scores, fss

obs = np.array([[0.0, 2.0, 3.0, 0.0],
                [0.0, 5.0, 4.0, 0.0],
                [0.0, 0.0, 0.0, 0.0],
                [0.0, 0.0, 0.0, np.nan]])   # bottom-right corner is missing
fct = np.array([[0.0, 0.0, 2.0, 1.0],
                [0.0, 6.0, 0.0, 3.0],
                [0.0, 0.0, 2.0, 0.0],
                [0.0, 0.0, 0.0, 0.0]])

scores = categorical_scores(obs, fct, threshold=1.0)
csi = scores["CSI"]
print(f"CSI = {csi.value:.3f}  (hits={csi.hits}, misses={csi.misses}, "
      f"false_alarms={csi.false_alarms}, of {csi.n_valid} valid points)")
# CSI = 0.286  (hits=2, misses=2, false_alarms=3, of 15 valid points)

There is no way to get the 0.286 without the counts next to it: every score is a frozen dataclass carrying its contingency table.

An undefined score says so instead of returning a number:

dry = categorical_scores(np.zeros((4, 4)), fct, threshold=1.0)
print(dry["POD"].value)        # Undefined(reason='no observed events: hits + misses == 0')
print(dry["POD"].is_defined)   # False
print(dry["POD"].false_alarms) # 5 -- the counts that made it undefined are still there

The Fractions Skill Score takes a square neighbourhood in grid points and reports how many windows it was computed over, under which boundary convention, and whether it met missing data:

f = fss(obs, fct, threshold=1.0, neighborhood=3)
print(f"FSS = {f.value:.3f} over {f.n_windows} windows of 3x3")
# FSS = 0.964 over 4 windows of 3x3
print(f.boundary, f.had_missing)                     # valid True

padded = fss(obs, fct, threshold=1.0, neighborhood=3, boundary="pad")
print(f"{padded.value:.3f} over {padded.n_windows} windows")
# 0.909 over 15 windows

print(f"{f.coverage_fraction:.1%} of the valid domain evaluated "
      f"({f.n_evaluated_points} of {f.n_valid_points} points)")
# 26.7% of the valid domain evaluated (4 of 15 points)
print(f"{padded.n_padded_windows} of {padded.n_windows} windows drew on padding")
# 11 of 15 windows drew on padding

boundary is optional and defaults to "valid", which uses only the windows that fit entirely inside the grid — (rows - n + 1) * (cols - n + 1) of them. boundary="pad" puts one window on every grid point instead, padding the grid with valid non-event points, which is the zero-filled uniform filter pysteps.verification.spatialscores.fss applies; pass it when you need numbers comparable with pysteps or with published values.

The two are not two roundings of one number. On the 4x4 grid above they are 0.964 and 0.909, and against pysteps on a 40x40 field the "valid" convention runs +0.0028 high at neighbourhood 3 and still +0.0003 at 15, while "pad" agrees to within 1e-12 absolute (tests/test_pysteps_crosscheck.py asserts exactly that — 1e-12 is the tolerance in the assertion, not a stand-in for "bit for bit" — and skips when pysteps is not installed, since it is not a dependency). The default is "valid", and that is a decision rather than a leftover: treating the area outside the domain as "no rain" is a fabricated observation, and the edge bias it introduces is invisible in the value. Whichever you pick, the choice is recorded on the result and printed in every report, because an FSS number does not identify its own convention.

What each convention costs

Neither convention is free, and neither price is in the number. "valid" never centres a window closer than neighborhood // 2 to an edge, so that frame of the domain is never verified; "pad" covers the whole domain instead, by completing the windows on that same frame with points nobody observed. They are the same frame, so the two costs are complements of one another. Measured on a 60x60 domain:

neighbourhood "valid" evaluates "pad" windows drawing on padding
3 93% of the domain 7%
9 75% 25%
15 59% 41%
31 25% 75%

At neighbourhood 31 three quarters of that domain takes no part in the FSS, and n_windows says only 900; that division is the one the result now does for you, which is why coverage_fraction sits on it and why every rendering of it — str(score), and every report cell — prints the share beside the window count. On the same fields the two conventions' values differ by at most 0.0018. The difference between the conventions is not in the number, it is in the area the number describes, which is why coverage_fraction (over the valid points, so masked and NaN points leave the denominator rather than counting as unevaluated domain), padded_fraction, and the flags coverage_is_low / padding_is_heavy sit on every FractionsScore. Both flags are taken against one constant, MAX_BOUNDARY_FRACTION (10%), because they are one quantity; when either fires, the result's boundary_caveat and the report's FSS boundary cost section say so with the counts. A low coverage does not make the FSS wrong — it makes it incomparable with an FSS measured over a domain of a different size, which is the part a value alone cannot tell you.

had_missing is the same idea one level down. A window's event fraction is its event count over that window's valid points, not over neighborhood ** 2, so masked and NaN points are never counted as non-events. With nothing missing the two denominators are the same number; when something was missing, the flag says so and the report adds a line about it.

Comparing methods across cases

evaluate runs every method on every case, aggregate combines the per-case results under a strategy you have to name, and rank orders the methods while handing you the evidence for and against the ordering. A "method" is just a name plus a function of the case's input fields, so persistence, NWP and a blend are all the same kind of object to the protocol.

import numpy as np
from nowcast_bench.protocol import Case, Method, aggregate, evaluate, rank

# Two cases of very different size, and two methods that each return one of the inputs.
small_obs = np.array([[5.0, 5.0, 5.0, 0.0],
                      [0.0, 0.0, 0.0, 0.0]])       # 3 observed event points out of 8
small_b = np.array([[0.0, 0.0, 0.0, 5.0],
                    [5.0, 5.0, 0.0, 0.0]])         # 3 forecast events, none of them right

big_obs = np.zeros((10, 10))
big_obs[:4] = 5.0                                  # 40 observed event points out of 100
big_a = np.zeros((10, 10))
big_a[:2] = 5.0                                    # 20 hits, 20 misses,  0 false alarms
big_b = np.zeros((10, 10))
big_b[:6] = 5.0                                    # 40 hits,  0 misses, 20 false alarms

cases = [Case("small", small_obs, {"a": small_obs, "b": small_b}),
         Case("big", big_obs, {"a": big_a, "b": big_b})]
methods = [Method("a", lambda case: case.inputs["a"]),
           Method("b", lambda case: case.inputs["b"])]

result = evaluate(methods, cases, threshold=1.0)

print("pool winner:", rank(result, "pool", "CSI").winner)
print("mean winner:", rank(result, "mean", "CSI").winner)
# pool winner: b
# mean winner: a

The two strategies name different winners, and that is why aggregate(result, strategy) has no default. "pool" adds the contingency tables together (23/43 for a against 40/66 for b), so the 100-point case outvotes the 8-point one; "mean" averages the per-case scores (0.75 for a against 0.33 for b), giving each case one vote. Neither is the "real" answer — they answer different questions, and picking one for you would be picking your conclusion.

A ranking never lets the headline stand on its own:

ranking = rank(result, "mean", "CSI")
print(ranking.describe())
# CSI (higher is better), strategy='mean', n_cases=2
#   1. a: 0.7500  (2 cases used, 0 undefined, 0 failed)
#   2. b: 0.3333  (2 cases used, 0 undefined, 0 failed)
#   NOT unanimous: a loses on ['big']
#     case big: won by b
print(ranking.is_unanimous)        # False
print(dict(ranking.dissenting_cases))   # {'big': ('b',)}

Aggregated scores carry their case counts the way single scores carry their contingency table, and a ranking carries the same counts per method, so two methods on neighbouring lines cannot hide that one of them was scored on fewer cases:

pod = aggregate(result, "mean").score("a", "POD")
print(pod.n_cases_used, pod.n_cases_undefined, pod.n_cases_failed)   # 2 0 0
print(ranking.support["b"].n_cases_used)                             # 2

A method that raises on a case is recorded as a failure, not skipped — result.failures, aggregate(...).has_failures and ranking.failures all carry it, so a method cannot look good by having quietly been scored on fewer cases than its rival.

There is no function that takes a description of a case and tells you which method to use. That is not an omission to be filled in later: on the cases this package was built on, the ranking changed from case to case and nothing knowable in advance predicted it.

Blending, and where the weights come from

A blend is just another Method: a function of several input fields. nowcast_bench.blend only builds them — static_blend for fixed weights, lead_time_blend for weights that hand over from radar extrapolation to NWP as the lead time grows.

import numpy as np
from nowcast_bench.blend import LeakageError, fit_static_weights, lead_time_blend
from nowcast_bench.protocol import Case, Method, evaluate, rank

def field(*points):
    """A 2x2 grid holding 2.0 at the given (row, col) points and 0.0 elsewhere."""
    grid = np.zeros((2, 2))
    for row, col in points:
        grid[row, col] = 2.0
    return grid

handover = lead_time_blend("radar", "nwp", half_life=30.0)
at_30 = Case("t+30", field((0, 0)), {"radar": field((0, 0)), "nwp": field((0, 1))},
             lead_time=30.0)
print("lead 30 min:", handover.fn(at_30).tolist())
# lead 30 min: [[1.0, 1.0], [0.0, 0.0]]

At one half-life the radar weight is 0.5 ** 1 = 0.5 and NWP takes the rest, so both fields enter at half strength. A case without a lead_time makes this method raise: the lead time is a physical parameter of the blend, not a case feature for choosing between methods.

Fixed weights have to come from somewhere. fit_static_weights sweeps them on cases you name, and the result remembers which cases those were:

fit_cases = [Case("f1", field((0, 0), (0, 1)), {"radar": field((0, 0)), "nwp": field((0, 1))}),
             Case("f2", field((1, 0), (1, 1)), {"radar": field((1, 0)), "nwp": field((1, 1))})]

fitted = fit_static_weights(fit_cases, ("radar", "nwp"),
                            threshold=1.0, strategy="mean", score="CSI", n_steps=5)

print("weights:", dict(fitted.weights))
print("fitted on:", sorted(fitted.fitted_on))
for point in fitted.trace:
    print(f"  w(radar)={point.weights['radar']:.2f}  CSI={point.score.value:.4f}")
# weights: {'radar': 0.5, 'nwp': 0.5}
# fitted on: ['f1', 'f2']
#   w(radar)=0.00  CSI=0.5000
#   w(radar)=0.25  CSI=0.5000
#   w(radar)=0.50  CSI=1.0000
#   w(radar)=0.75  CSI=0.5000
#   w(radar)=1.00  CSI=0.5000

The whole sweep comes back, not just its argmax, because a flat curve and a sharp peak mean very different things: here the optimum is a single point, so the data really did choose the weights. fitted.training_score is the CSI those weights reached on the fitting cases. It is in-sample and systematically optimistic, and it is not this blend's performance.

For that you have to evaluate on other cases — and the package makes you:

eval_cases = [
    Case("e1", field((0, 0), (0, 1)), {"radar": field((0, 0)), "nwp": field((0, 1))}),
    Case("e2", field((0, 0), (0, 1)), {"radar": field((0, 0), (0, 1)), "nwp": field((1, 1))}),
]

blend = fitted.method("blend", eval_cases)     # checked here, before anything is scored
methods = [Method("radar", lambda case: case.inputs["radar"]),
           Method("nwp", lambda case: case.inputs["nwp"]),
           blend]

ranking = rank(evaluate(methods, eval_cases, threshold=1.0), "mean", "CSI")
print(ranking.describe())
# CSI (higher is better), strategy='mean', n_cases=2
#   1. blend: 0.8333  (2 cases used, 0 undefined, 0 failed)
#   2. radar: 0.7500  (2 cases used, 0 undefined, 0 failed)
#   3. nwp: 0.2500  (2 cases used, 0 undefined, 0 failed)
#   NOT unanimous: blend loses on ['e2']
#     case e2: won by radar

The blend wins on average and still loses e2, where radar alone was already perfect and the blend inherited NWP's false alarm — which is why the per-method case counts and the dissenting case travel with the ordering.

Handing the fitting cases to method() instead is not a slightly worse experiment, it is a different number entirely, so it is refused where it happens rather than recorded as a result:

try:
    fitted.method("blend", fit_cases)
except LeakageError as exc:
    print("refused:", exc)
# refused: weights fitted on ['f1', 'f2'] would be evaluated on the same case(s) ['f1', 'f2'];
# evaluate a fitted blend only on cases it was not fitted on

The check is at construction time on purpose. evaluate records an exception raised by a method as a Failure, so a leak caught only at run time would show up as a quiet "that method failed" line inside an otherwise green evaluation instead of stopping you.

One known limitation, stated rather than corrected: a linear blend damps extremes. Two strong echo cores a few grid points apart average into two weaker ones, so a blended field has a flatter intensity distribution than either ingredient. This package does not silently apply probability matching to hide that, because a correction stacked on the blend would make "did blending help?" unanswerable. Apply one yourself if you want it, and evaluate it as its own method.

Scale decomposition, and why it is not the blend this package recommends

static_blend and lead_time_blend weight the whole field at once. nowcast_bench.cascade splits both ingredients into scale bands in wavenumber space first, gives each band its own half-life, and adds the bands back up — the reasoning being that small structures lose their predictability before large ones do, so one weight for the whole spectrum is the wrong shape.

This package does not hold the scale-decomposed blend to be better than the linear one, and nothing here should be read as saying it is. On the real cases this package was built on, a scale-decomposed ensemble blend was clearly behind plain extrapolation at +1 h on a slow-moving stratiform system — the ensemble mean smeared out a sharp, intense echo core that extrapolation had kept — and only overtook it at +3 h. "It decomposes the field, so it must score better" is exactly the assumption that result refutes. Which method wins on your cases is an empirical question, and evaluate is the only thing here that answers it.

What the module does guarantee is that the decomposition itself is not quietly lossy: the filters are a partition of unity in log2 wavenumber, summing to exactly 1 at every wavenumber, so the bands add back up to the field they came from.

import numpy as np
from nowcast_bench import cascade_blend, decompose, intensity_ratio, recompose
from nowcast_bench.protocol import Case

cas_field = np.arange(64.0).reshape(8, 8) % 7
cas_parts = decompose(cas_field, 3)
print("levels:", cas_parts.n_levels, " shape:", cas_parts.shape)
print("perfect reconstruction:", bool(np.allclose(recompose(cas_parts), cas_field)))
# levels: 3  shape: (8, 8)
# perfect reconstruction: True

That invariant is not decoration. If the filters summed to 0.98 somewhere, then even a blend that gave one ingredient all of the weight would hand back a quietly reshaped copy of it — the forecast would have been filtered before any blending happened, with nothing in the output to say so.

half_lives has no default and no "recommended" value: one half-life per band, largest scale first, chosen by you. Small scales are usually held to decay faster, but that is a physical assumption about your radar, your domain and your weather, and this package will not make it on your behalf. The level count and the half-lives go into the method's name, so the convention shows up in every table the blend appears in. Write them in any ordered container — a list, a tuple or a numpy array; a set is refused, because the order is what decides which half-life acts on which band.

def cas_core(col):
    """An 8x8 field of zeros with one 2x2 block of 10.0 starting at that column."""
    grid = np.zeros((8, 8))
    grid[3:5, col:col + 2] = 10.0
    return grid

cas_fast, cas_slow = cas_core(1), cas_core(4)      # two cores, three columns apart
cas_case = Case("t+30", np.zeros((8, 8)),
                {"radar": cas_fast, "nwp": cas_slow}, lead_time=30.0)

cas_even = cascade_blend("radar", "nwp", n_levels=3, half_lives=(30.0, 30.0, 30.0))
cas_ratios = intensity_ratio(cas_even.fn(cas_case), cas_fast, quantiles=(1.0,))
print("method:", cas_even.name)
print(f"peak of blend / peak of radar: {cas_ratios.at(1.0).ratio:.2f}"
      f" over {cas_ratios.n_valid} points")
# method: cascade_blend(n_levels=3, half_lives=(30.0, 30.0, 30.0))
# peak of blend / peak of radar: 0.50 over 64 points

At one half-life on every band the weights are all 0.5 ** 1 = 0.5, the decomposition cancels out, and this is the linear blend — the cascade only differs from it when the half-lives do. The ratio is intensity_ratio, which exists because "blending damps extremes" is otherwise just a sentence: two 10.0 cores that do not overlap average into two 5.0 cores, so the peak of the blend is half the peak of either ingredient. It is a diagnostic, not a score, and nothing here corrects it — probability matching stacked on the blend would make "did blending help?" unanswerable, as in the linear case.

Two costs come with the transform, and neither is visible in the output:

  • An FFT assumes the field is periodic, and precipitation is not. The left edge of the domain is treated as the neighbour of the right edge, so a structure at one edge leaks into the opposite one. It is worst where nowcasting cares most: a strong echo core near a boundary puts a ghost of itself on the far side of the domain. The module does not window or taper the field to soften this, because a taper would alter the values over an unstated fraction of the domain and would be a second, unevaluated method stacked on the blend. Decompose a domain larger than the area you verify, and verify the interior.
  • An FFT cannot be taken over missing data, so missing data is refused rather than filled. Zero, the field mean, an interpolation — every filling writes down an observation nobody made. A field with NaNs and a case carrying a mask are both turned away, for the same reason "pad" is not the default FSS boundary convention.
cas_gappy = cas_fast.copy()
cas_gappy[0, 0] = np.nan
try:
    decompose(cas_gappy, 3)
except ValueError as exc:
    print("refused:", str(exc).split(":")[0])
# refused: field has 1 missing point(s) (NaN) of 64

What to do about those points is a modelling decision that belongs to you and has to be evaluated as part of your method — not made silently inside a filter bank.

Values the blend invented, and why nothing here clips them

The scale decomposition above was built first and measured afterwards, and measuring it turned up a property nobody had designed for: a per-scale blend produces values that neither of its inputs holds, negative precipitation included. Three constructions, all at n_levels=3, half_lives=(120.0, 30.0, 7.5), lead_time=30.0:

construction input range equal half-lives: max unequal: max / min
two offset strong centres 0 - 40 40.000 47.262 / -5.426
random field + a translation 0.005 - 16.846 9.799 12.840 / -0.773
large block + a nested core 0 - 50 30.000 31.816 / -0.170

It is arithmetic rather than a bug. The output is sum_i [w_i * fast_i + (1 - w_i) * slow_i]. With every w_i equal it collapses to w * fast + (1 - w) * slow, a pointwise convex combination, which cannot leave the range of its two inputs — and that is precisely the case in which the cascade degenerates into the linear blend. With the w_i different, which is the only reason to decompose at all, it is not a convex combination and it overshoots on both sides. The property shows up only in the regime the method exists for, which is why running the method was what found it.

What makes it worth a section of its own is not the overshoot but the silence after it. metrics compares every point against the threshold, and a negative rain rate is below every positive threshold — so it is counted as no event forecast, exactly like a dry point. The contingency table changes, the score changes, and nothing in the value or in its support says the field held values that cannot exist.

Two diagnostics say it instead. range_excursion measures a field against a range you name — this package assumes none, because "precipitation is not negative" is a statement about precipitation and not about every field it will be handed — and ingredient_excursion measures a blend against the pointwise range of its own ingredients, which is the property above, directly:

from nowcast_bench import ROUNDOFF_TOLERANCE, cascade_blend, ingredient_excursion, range_excursion

exc_uneven = cascade_blend("radar", "nwp", n_levels=3, half_lives=(120.0, 30.0, 7.5))
exc_field = exc_uneven.fn(cas_case)          # the two-core case from the section above
print(f"inputs: 0.000 to 10.000, blend: {exc_field.min():.3f} to {exc_field.max():.3f}")
floor = ROUNDOFF_TOLERANCE * 10.0             # one round-off floor at the inputs' scale (0-10)
print(range_excursion(exc_field, low=-floor).describe())
# inputs: 0.000 to 10.000, blend: -1.274 to 6.193
# 20 of 64 valid grid point(s) (31.25%) lie outside low=-1e-08, high=unchecked; 20 below, the
#   most extreme -1.27369 at (row 5, col 5), 1.27369 past the bound -1e-08

exc_even = cascade_blend("radar", "nwp", n_levels=3, half_lives=(30.0, 30.0, 30.0))
print("equal half-lives leave the ingredients' range:",
      not ingredient_excursion(exc_even.fn(cas_case), (cas_fast, cas_slow)).has_excursion)
# equal half-lives leave the ingredients' range: True

The lower bound sits one round-off floor below zero rather than at zero. range_excursion compares exactly, so at low=0.0 it also counts the points the two FFTs left within about 1e-16 of zero on the negative side, and how many of those there are depends on the platform's floating-point arithmetic. The 20 points counted here are at least 0.086 below zero on every platform; a count taken at low=0.0 would not be reproducible.

cascade_blend takes an excursion= policy: "allow" (the default) hands the field back as it is, and "raise" refuses it with the most extreme value and its position in the message. The default is "allow" because all three constructions above went out of range — a default that refused would leave the method unusable on the fields it exists for. What the default buys is visibility, not silence.

There is no excursion="clip", and there will not be one. Clipping is a correction applied after the blend and invisible in every score computed afterwards: a gain could then come from the blending or from the clipping, and "did blending help?" stops having an answer. It is the same refusal blend makes for probability matching. Clip the returned array yourself if you want it clipped — then the correction is part of your method, written where you can see it, and evaluated with it.

Finally, evaluate(methods, cases, threshold=..., valid_range=(0.0, None)) runs range_excursion on every forecast field, hangs the result on the (case, method) it belongs to, and evaluation_report prints a section naming each affected pair, how many points went outside, how far, and how those points reached the contingency table. The excursion is counted over exactly the points the table was counted over — a masked point, or one with no observation, is excluded from both — so a point the score never saw is never reported as one that moved it. An out-of-range point that was excluded is counted apart, as n_excluded_outside, and the report names it in a sentence of its own: it moved no score, and a field whose impossible values all sit under the mask is still not a clean field. Without valid_range nothing is checked and nothing changes — including the report, which then does not claim a clean field it never looked at.

Reports

nowcast_bench.report renders those same result objects as Markdown and returns the text. It writes no files: the core carries no I/O dependency, so where the report goes is your business.

Every score in a report goes through one internal formatter, so there is no rendering path that prints a value without the support behind it, and an undefined score is spelled undefined with the counts that made it undefined — never blanked out, dashed, or turned into 0.00.

from nowcast_bench import evaluation_report, fitted_weights_report, ranking_report

print(evaluation_report(result))   # `result` is the small/big evaluation from above
# # Evaluation report
#
# - Threshold: 1.0 (a grid point is an event when its value is >= threshold)
# - Scores: POD, FAR, CSI, BIAS, ETS
# - Neighborhood: not set, so no FSS was computed
# - Cases (2): small, big
# - Methods (2): a, b
# - Pairs: 4 of 4 scored, 0 failed
# ...
# | case | method | POD | FAR | CSI | BIAS | ETS |
# | --- | --- | --- | --- | --- | --- | --- |
# | small | a | 1.0000 [H3 M0 F0 C5] | 0.0000 [H3 M0 F0 C5] | 1.0000 [H3 M0 F0 C5] | ...
# | small | b | 0.0000 [H0 M3 F3 C2] | 1.0000 [H0 M3 F3 C2] | 0.0000 [H0 M3 F3 C2] | ...
# ...
# ## Failures
#
# None: every (case, method) pair ran to completion.

The threshold is in the header because a precipitation score report that does not state it is unusable, and the failures get a section of their own because a method quietly missing from the table is exactly how a comparison gets made to look good on fewer samples than its rival.

A ranking report carries the per-method case counts on every line and puts the per-case winners in their own section, shouting when they contradict the aggregate:

print(ranking_report(ranking, threshold=1.0))   # `ranking` is the blend ranking from above
# # Ranking report
#
# - Score: CSI (higher is better)
# - Strategy: 'mean'
# - Threshold: 1.0
# - Cases ranked: 2
# ...
# | rank | method | CSI | note |
# | --- | --- | --- | --- |
# | 1 | blend | 0.8333 [2 cases used, 0 undefined, 0 failed] |  |
# | 2 | radar | 0.7500 [2 cases used, 0 undefined, 0 failed] |  |
# | 3 | nwp | 0.2500 [2 cases used, 0 undefined, 0 failed] |  |
# ...
# **NOT unanimous.** The aggregate winner `blend` loses on 1 of 2 ranked case(s). ...
#
# | dissenting case | won there by |
# | --- | --- |
# | e2 | radar |

A method backed by a single case gets a **(single case)** mark on its line, and a Ranking whose support is missing raises ValueError instead of degrading to a bare list of places: an ordering with no case counts behind it is not something this package will print.

Ties are reported, not broken. Two methods that reach exactly the same score did not come first and second, so the table numbers them with competition places — 1, 1, 3, never a 2 between two equal values — the header carries a bold Tied at the top: line, and the per-case winners section refuses to print the usual Aggregate winner ... **x** sentence at all:

tied = rank(evaluate([Method("radar", lambda c: c.inputs["radar"]),
                      Method("nwp", lambda c: c.inputs["nwp"])],
                     [Case("t1", field((0, 0), (0, 1)),
                           {"radar": field((0, 0)), "nwp": field((0, 1))})],
                     threshold=1.0), "pool", "CSI")
print(tied.is_tied_at_top, tied.winners)   # True ('nwp', 'radar')
print(tied.places)          # ((1, 'nwp', 0.5), (1, 'radar', 0.5))

ordering still puts equal values in name order, but only so that two runs give the same list; it says nothing about which is better. Read is_tied_at_top and winners before winner, which is documented as the first line of the ordering rather than the winner of anything.

Equality here is exact, not within a tolerance: the values being compared come out of one code path over one set of cases, so bit-identical means "the same measurement", while a tolerance would be a second convention — of a width nobody could justify — that merges methods which really did differ. The price is that a gap of 1e-15 is ranked as a win; that gap is not evidence of anything either, which is what the per-case winners and the per-method case counts beside every line are for. When such a gap is too small to show in the table — two places whose cells print the same four decimals — the ranking report adds a line under the table saying so, because two identical-looking rows numbered 1 and 2 read as "the first one won" just as loudly as a mis-numbered tie would.

The fitted-weights report keeps the in-sample number out of every table of evaluation scores, and states how flat the sweep was — a flat curve means the data did not choose the weights:

print(fitted_weights_report(fitted))
# # Fitted weights report
#
# - Fitted on (2 case(s)): f1, f2
# - Score swept: CSI (maximised)
# - Aggregation strategy: 'mean'
# - Threshold: 1.0
# - Fitted weights: radar=0.5000, nwp=0.5000
# ...
# ## Training score
#
# CSI of the fitted weights on the fitting cases: 1.0000 [2 cases used, 0 undefined, 0 failed]
#   -- **in-sample on the fitting cases**: the weights were chosen to maximise this very
#   number, so it is systematically optimistic and is **not** this blend's performance.
# ...
# ### Did the sweep determine the weights?
#
# - Best CSI: 1.0000 [2 cases used, 0 undefined, 0 failed]
# - Runner-up: 0.5000 [2 cases used, 0 undefined, 0 failed]
# - Margin between them: 0.5000 (a difference of two scores, not a score itself)
# - Candidates as good as the best (within the band stated below): 1 of 5 defined (5 swept)
# - That band is 0.0100, taken as 1% of the best score itself: a candidate counts as within
#   when its score differs from the best by at most that much. The band is a difference of two
#   scores, not a score itself.
# - Sharp optimum: no other candidate comes inside that band, so the fitting cases really did
#   pick this weighting out. Read that against the band above, which is 1% of a number this
#   sweep itself produced: when every candidate crowds into a narrow range the band narrows
#   with them, and this line can then call an optimum sharp that the margin above shows to be
#   one hair wide.

Had every candidate scored the same, that last line would instead read "Flat sweep: the data did not choose these weights" — which is the honest reading of an argmax over a plateau.

The band is always stated, and the count line above it names no yardstick of its own, because there are two: the band is the wider of 1% of the best score and 1% of the range the defined candidates span, whichever that turns out to be on the sweep at hand. best == 0.0 — the normal state of a FAR sweep, which is minimised — is only the case where the range must win, 1% of nothing being nothing; a minimised score of 0.3 over widely spread candidates takes the range as well. The report says which one it took, and the "sharp"/"flat" line is only as trustworthy as that band: both yardsticks are 1% of a number the sweep itself produced, so a sweep whose candidates all crowd together gets a band that crowds with them, and a difference of a single false alarm can still be reported as a sharp optimum. The margin line is what to read against it.

The common public names are re-exported at the top level (from nowcast_bench import evaluate, rank, ranking_report, ...); nowcast_bench.__all__ lists them, and each one is the very object its submodule exports.

How sure is that ranking?

Everything above returns point estimates, and a point estimate ranked 1 against a point estimate ranked 2 reads like a finding whether or not the case set could tell them apart. nowcast_bench.uncertainty bootstraps cases — never grid points, which are so strongly spatially autocorrelated that resampling them treats one storm as hundreds of independent observations and hands back a confidently narrow interval — and attaches the result to the ranking.

seed is required and has no default, for the same reason strategy has none: an interval is the output of a random procedure, and one that cannot say which draw produced it is neither reproducible nor comparable. The seed is recorded on the result and printed in the report.

Here is what three cases actually buy you. Method a is perfect on two of them and hopeless on the third; b is the other way round but weaker:

import numpy as np
from nowcast_bench import Case, Method, evaluate, rank_with_intervals, ranking_report

obs_u = np.array([[5.0, 5.0], [0.0, 0.0]])        # two observed event points
perfect = obs_u                                    # CSI 1.0
half = np.array([[5.0, 0.0], [0.0, 0.0]])          # 1 hit, 1 miss  -> CSI 0.5
wrong = np.array([[0.0, 0.0], [5.0, 5.0]])         # 0 hits, 2 misses, 2 false alarms -> CSI 0.0

u_cases = [Case("u1", obs_u, {"a": perfect, "b": wrong}),
           Case("u2", obs_u, {"a": wrong,   "b": half}),
           Case("u3", obs_u, {"a": perfect, "b": wrong})]
u_methods = [Method("a", lambda case: case.inputs["a"]),
             Method("b", lambda case: case.inputs["b"])]

u_ranking = rank_with_intervals(evaluate(u_methods, u_cases, threshold=1.0),
                                "mean", "CSI", n_resamples=2000, seed=20240601)

print("a:", u_ranking.intervals["a"].low, u_ranking.intervals["a"].high)
print("b:", u_ranking.intervals["b"].low, u_ranking.intervals["b"].high)
print("overlap:", u_ranking.top_intervals_overlap)
print("informative:", u_ranking.intervals["a"].is_informative)
# a: 0.0 1.0
# b: 0.0 0.5
# overlap: True
# informative: False

Method a aggregates to a mean CSI of 0.6667 and its 95% interval is [0.0000, 1.0000] — the entire range the score can take. That is not a badly chosen example, it is what three cases support: resample them and you can draw {u2, u2, u2}, where a scores 0, or {u1, u1, u3}, where it scores 1. Both are ordinary draws.

So the object says two separate things about it, and both are numbers rather than hedging:

print(u_ranking.intervals["a"].caveat)
# 3 cases admit only 10 distinct resamples, so the endpoints are order statistics over at most
# 10 different values and cannot resolve a 2.50% tail; fewer than 5 cases is therefore not
# called informative here

is_informative goes False below MIN_INFORMATIVE_CASES (5) because a bootstrap of n cases can only ever draw C(2n-1, n) different multisets — 3 of them at n=2, 10 at n=3, 35 at n=4 — and a 95% interval needs ~40 to place a 2.5% tail anywhere but on the most extreme draw. The interval is still computed and still returned: refusing to compute it would only push you back to the bare 0.6667, which claims more, not less.

The ranking then answers the question the places cannot:

print(u_ranking.describe().splitlines()[3])
# ORDER NOT RESOLVED at level 95%: ...
u_text = ranking_report(u_ranking, threshold=1.0)
print("names a winner:", "Aggregate winner" in u_text)
# names a winner: False

When the top two intervals overlap the report does not print its usual Aggregate winner ... **a** sentence at all; it says the order was not resolved at that level, and the table marks both cells **NOT INFORMATIVE** so they cannot be read as the same kind of object as an interval from forty cases.

Overlap is a conservative verdict and only in one direction. Intervals that do not overlap imply the difference is real at that level; intervals that do overlap imply nothing — the difference may still be real, because the interval of a difference between two positively correlated estimates is narrower than the gap between their two separate intervals. Nothing in this package words overlap as "the two methods are the same", and nothing here computes a p-value: no t-test, no permutation test, no scipy.

Resamples in which the score has no definition — POD over a draw of cases that all happen to have no observed events — are counted in n_resamples_undefined and excluded from the percentile rather than dropped in silence, and when every resample is undefined both endpoints are Undefined with the reason attached, not nan and not (0.0, 0.0).

License

MIT

Metadata

Release files for nowcast-bench 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nowcast-bench 0.1.0
File Size Uploaded
nowcast_bench-0.1.0.tar.gz 218.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nowcast-bench 0.1.0
File Interpreter ABI Platform
nowcast_bench-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 350.6 kB

Release files / nowcast_bench-0.1.0.tar.gz

Download URL nowcast_bench-0.1.0.tar.gz
Size 218.5 kB
Tags Source
SHA-256 checksum
How to use checksums
20c64b628077eca0bdb06d0264a640a0dfb27ca405b146ffab3f0fdb343d3712
BLAKE2b-256 checksum
How to use checksums
6c13acdd5ac0fe6c31d2874bc461e1da0148bd4bf036b948eeb2658335019433
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release files / nowcast_bench-0.1.0-py3-none-any.whl

Download URL nowcast_bench-0.1.0-py3-none-any.whl
Size 132.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
177246473518477067151f3d7a420c56f562d9120e9714b6c05a26ddbdd52e2c
BLAKE2b-256 checksum
How to use checksums
44ff5f769b4918bc767abe4d28c7a318c183ac4c94a8cc20b50ffee3b9e489e1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 18, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page