Skip to content

H3 aggregation of categorical_precipitation_type_surface: weights ignored, ties arbitrary, and nulls win the mode #507

Description

@JackKelly

Filed by Claude Code, at Jack's direction, from the planning of #496 + #477.

_process_chunk_for_1_lead_time_and_1_ens_member in
packages/dynamical_data/src/dynamical_data/ecmwf_ens/convert_to_polars.py aggregates the numeric
NWP variables to an H3 cell as a proportion-weighted sum, but aggregates
categorical_precipitation_type_surface with:

pl.col(categorical_vars).mode().first(ignore_nulls=True)

That is an unweighted mode. It discards the proportion column entirely, so a grid point
contributing 5% of a cell's area counts exactly as much as one contributing 90%.

The two defects

1. The weights are ignored. A cell fed by three grid points weighted 0.9 / 0.05 / 0.05 returns
the minority category whenever the two slivers happen to agree with each other and disagree with
the dominant point. The cell reports, say, snow because 10% of its area is snowy.

This matters more than the raw geometry suggests: the production H3 grid (H3 resolution 5 against
the 0.25° ECMWF grid, over the GB boundary) averages 2.93 grid points per cell, and cell weights
range down to 0.0204, so lopsided cells are ordinary rather than exotic.

2. .first() on a tie is arbitrary. mode() returns every modal value, in no documented
order, and .first() takes whichever comes out first. With ~3 points per cell, a 1-1-1 or 1-1 tie
is common, so the chosen category is effectively unspecified — and not necessarily stable across
Polars versions.

Why it was not fixed alongside #496

#496 explicitly scopes this variable out ("categorical_precipitation_type_surface is aggregated
by mode().first(ignore_nulls=True), so it already ignores nulls and needs no change"). That
claim is false
-- mode() counts null as a candidate value, so a cell whose points are mostly
null comes out null even though real values arrived. See the comment below for the demonstration
and the blast radius; it is defect 3, and it is the most consequential of the three. The weighting
defect is a separate, pre-existing problem that was noticed while confirming the #496 claim.

Sketch of a fix

An area-weighted mode: group the cell's points by category, sum proportion within each category,
and take the category with the largest total weight. Ties then need a deterministic rule (lowest
category index is the obvious one) rather than whatever mode() happens to return.

Worth deciding as part of the issue whether a weighted mode is even the right aggregation for a
categorical precipitation type, or whether the dominant-by-area point's value is a better answer.

Verification

convert_to_polars.py carries a "Convention-sensitive to real ECMWF ENS data" comment, so after
changing it run the network-gated test manually:

uv run pytest --run-network -m network

test_convert_categorical_precipitation_type in
packages/dynamical_data/tests/test_convert_to_polars.py is the existing coverage; it uses five
equally-weighted points, so it cannot see either defect and will need a lopsided-weights sibling.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions