Classify each county by area-weighted Köppen class shares: a county is predominantly its top class when that class covers at least 50% of its land and leads the runner-up by at least 5 percentage points; otherwise it is Mixed (133 of 3,143 counties in the 50 states and DC). - Add build_county_koppen_metric.py (writes data/metrics/koppen.csv) and apply_koppen_metric_to_climate_data.py (writes koppenZone plus koppenPrimaryClass/koppenSecondaryClass for Mixed counties). - Move shared helpers into scripts/common/ (county loading, Köppen legend, area-weighted raster shares); fix the 180th-meridian raster window for Aleutians West. - Add check_climate_data.py to validate the app CSV. - Draw Mixed counties in app.js as diagonal stripes of their top two classes, fixed to the ground and following the map at every zoom, with a crossfade only when the stripe size changes. Filtering a class also matches Mixed counties where it is primary or secondary. - Document the rule, display, and pipeline plan in docs/ and update the README and data-source notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
"""Shared county zonal statistics for raster-based metrics.
|
|
|
|
Area weighting estimates how much of each raster cell lies inside a county by
|
|
rasterizing the county on a finer grid of sub-cells, then scales each cell by
|
|
the cosine of its latitude so cells count by their true surface area. Counties
|
|
that cross the 180th meridian are split so each side is read from its own small
|
|
raster window.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Dict, List
|
|
|
|
import numpy as np
|
|
import shapely
|
|
from affine import Affine
|
|
from rasterio.features import rasterize
|
|
from rasterio.windows import Window, from_bounds
|
|
from shapely.affinity import translate
|
|
from shapely.geometry import box, mapping
|
|
from shapely.geometry.base import BaseGeometry
|
|
from shapely.ops import unary_union
|
|
|
|
DEFAULT_SUBCELLS = 16
|
|
# Largest sub-cell grid rasterized for one county piece; bigger pieces use a coarser grid.
|
|
SUBCELL_BUDGET = 80_000_000
|
|
|
|
|
|
def split_at_antimeridian(geometry: BaseGeometry) -> List[BaseGeometry]:
|
|
"""Split a lon/lat geometry into pieces that each stay on one side of 180 degrees.
|
|
|
|
A county such as Aleutians West, AK has islands at both +179 and -179
|
|
degrees longitude. Its bounding box then spans nearly the whole globe, so
|
|
each side is returned as its own piece.
|
|
"""
|
|
minx, _, maxx, _ = geometry.bounds
|
|
if maxx - minx <= 180.0:
|
|
return [geometry]
|
|
|
|
positive: List[BaseGeometry] = []
|
|
negative: List[BaseGeometry] = []
|
|
for part in getattr(geometry, "geoms", [geometry]):
|
|
part_minx, _, part_maxx, _ = part.bounds
|
|
if part_maxx - part_minx > 180.0:
|
|
# One outline crosses the line: unwrap to 0..360, cut at 180, rewrap.
|
|
unwrapped = shapely.transform(
|
|
part,
|
|
lambda xy: np.column_stack((np.where(xy[:, 0] < 0, xy[:, 0] + 360.0, xy[:, 0]), xy[:, 1])),
|
|
)
|
|
positive.append(unwrapped.intersection(box(0.0, -90.0, 180.0, 90.0)))
|
|
negative.append(translate(unwrapped.intersection(box(180.0, -90.0, 360.0, 90.0)), xoff=-360.0))
|
|
elif part_minx >= 0:
|
|
positive.append(part)
|
|
else:
|
|
negative.append(part)
|
|
|
|
pieces = [unary_union(group) for group in (positive, negative) if group]
|
|
return [piece for piece in pieces if not piece.is_empty]
|
|
|
|
|
|
def geometry_window(source, geometry: BaseGeometry) -> Window:
|
|
"""Return the raster window covering a geometry, padded by one cell on each side."""
|
|
window = from_bounds(*geometry.bounds, transform=source.transform)
|
|
col_start = math.floor(window.col_off) - 1
|
|
row_start = math.floor(window.row_off) - 1
|
|
col_stop = math.ceil(window.col_off + window.width) + 1
|
|
row_stop = math.ceil(window.row_off + window.height) + 1
|
|
padded = Window(col_start, row_start, col_stop - col_start, row_stop - row_start)
|
|
return padded.intersection(Window(0, 0, source.width, source.height))
|
|
|
|
|
|
def subcells_for(shape: tuple[int, int], requested: int) -> int:
|
|
"""Return the finest sub-cell count, up to the request, that fits the budget."""
|
|
rows, cols = shape
|
|
subcells = requested
|
|
while subcells > 1 and rows * cols * subcells * subcells > SUBCELL_BUDGET:
|
|
subcells //= 2
|
|
return subcells
|
|
|
|
|
|
def cell_coverage_fractions(
|
|
shape: tuple[int, int], transform: Affine, geometry: BaseGeometry, subcells: int
|
|
) -> np.ndarray:
|
|
"""Estimate the fraction of each raster cell covered by a geometry."""
|
|
rows, cols = shape
|
|
fine = rasterize(
|
|
[mapping(geometry)],
|
|
out_shape=(rows * subcells, cols * subcells),
|
|
transform=transform * Affine.scale(1.0 / subcells),
|
|
fill=0,
|
|
default_value=1,
|
|
dtype="uint8",
|
|
)
|
|
return fine.reshape(rows, subcells, cols, subcells).mean(axis=(1, 3))
|
|
|
|
|
|
def area_weighted_class_weights(
|
|
source,
|
|
geometry: BaseGeometry,
|
|
*,
|
|
geographic: bool = True,
|
|
subcells: int = DEFAULT_SUBCELLS,
|
|
) -> Dict[int, float]:
|
|
"""Return the area inside a geometry covered by each value of a categorical raster.
|
|
|
|
Weights are relative surface areas: the fraction of each cell inside the
|
|
geometry, times cos(latitude) for geographic rasters. Cells equal to 0 or
|
|
the raster's nodata value are excluded.
|
|
"""
|
|
weights: Dict[int, float] = {}
|
|
pieces = split_at_antimeridian(geometry) if geographic else [geometry]
|
|
for piece in pieces:
|
|
window = geometry_window(source, piece)
|
|
values = source.read(1, window=window, masked=True).filled(0)
|
|
if source.nodata is not None:
|
|
values = np.where(values == source.nodata, 0, values)
|
|
transform = source.window_transform(window)
|
|
cell_weights = cell_coverage_fractions(values.shape, transform, piece, subcells_for(values.shape, subcells))
|
|
if geographic:
|
|
row_lat = transform.f + (np.arange(values.shape[0]) + 0.5) * transform.e
|
|
cell_weights = cell_weights * np.cos(np.radians(row_lat))[:, None]
|
|
|
|
counted = (values != 0) & (cell_weights > 0)
|
|
for value in np.unique(values[counted]):
|
|
code = int(value)
|
|
weights[code] = weights.get(code, 0.0) + float(cell_weights[counted & (values == value)].sum())
|
|
return weights
|