Complete Köppen-Geiger filter review with Mixed climate class

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>
This commit is contained in:
2026-09-14 02:54:25 -04:00
co-authored by Claude Opus 5
parent 92fbbfb2e9
commit 4d2b3e3d44
20 changed files with 6400 additions and 3450 deletions
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.transform import from_origin
from shapely.geometry import MultiPolygon, Polygon, box
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from build_county_climate_data import _touched_raster_values, _zonal_majority_class # noqa: E402
from common.county_zonal_stats import geometry_window, split_at_antimeridian # noqa: E402
from common.koppen_legend import DEFAULT_KOPPEN_CODE_MAP # noqa: E402
def islands_on_both_sides() -> MultiPolygon:
"""A county shaped like Aleutians West: islands at +178..+180 and -180..-179."""
return MultiPolygon([box(178.2, 51.2, 179.8, 51.8), box(-179.8, 51.2, -179.2, 51.8)])
class SplitAtAntimeridianTests(unittest.TestCase):
def test_geometry_on_one_side_is_unchanged(self) -> None:
geometry = box(-100.0, 30.0, -90.0, 40.0)
pieces = split_at_antimeridian(geometry)
self.assertEqual(len(pieces), 1)
self.assertTrue(pieces[0].equals(geometry))
def test_islands_are_grouped_by_side(self) -> None:
geometry = MultiPolygon(
[box(172.0, 51.0, 173.0, 52.0), box(179.0, 51.0, 179.5, 52.0), box(-179.0, 51.0, -178.0, 52.0)]
)
pieces = split_at_antimeridian(geometry)
self.assertEqual(len(pieces), 2)
bounds = sorted(piece.bounds for piece in pieces)
self.assertEqual(bounds[0], (-179.0, 51.0, -178.0, 52.0))
self.assertEqual(bounds[1], (172.0, 51.0, 179.5, 52.0))
self.assertAlmostEqual(sum(piece.area for piece in pieces), geometry.area)
def test_single_outline_crossing_the_line_is_cut(self) -> None:
# Encoded in -180..180 coordinates, this 2-degree-wide ring looks 358 degrees wide.
crossing = Polygon([(179.0, 50.0), (-179.0, 50.0), (-179.0, 51.0), (179.0, 51.0)])
pieces = split_at_antimeridian(crossing)
bounds = sorted(piece.bounds for piece in pieces)
self.assertEqual(bounds, [(-180.0, 50.0, -179.0, 51.0), (179.0, 50.0, 180.0, 51.0)])
self.assertAlmostEqual(sum(piece.area for piece in pieces), 2.0)
class WindowedKoppenTests(unittest.TestCase):
def setUp(self) -> None:
self._temp_dir = TemporaryDirectory()
self.raster_path = Path(self._temp_dir.name) / "koppen.tif"
# Global 1-degree raster; row 38 covers 51..52 N.
data = np.zeros((180, 360), dtype=np.uint8)
data[38, 358] = 29 # ET at 178..179 E
data[38, 359] = 29 # ET at 179..180 E
data[38, 0] = 15 # Cfb at 180..179 W
with rasterio.open(
self.raster_path,
"w",
driver="GTiff",
height=180,
width=360,
count=1,
dtype="uint8",
crs="EPSG:4326",
transform=from_origin(-180.0, 90.0, 1.0, 1.0),
nodata=0,
) as destination:
destination.write(data, 1)
def tearDown(self) -> None:
self._temp_dir.cleanup()
def test_each_side_is_read_from_a_small_window(self) -> None:
with rasterio.open(self.raster_path) as source:
windows = [geometry_window(source, piece) for piece in split_at_antimeridian(islands_on_both_sides())]
values = _touched_raster_values(source, islands_on_both_sides(), split_antimeridian=True)
self.assertEqual(len(windows), 2)
self.assertTrue(all(window.width <= 5 for window in windows))
self.assertEqual(sorted(values[values != 0].tolist()), [15, 29, 29])
def test_majority_class_counts_islands_on_both_sides(self) -> None:
counties = gpd.GeoDataFrame(geometry=[islands_on_both_sides(), box(-100.0, 30.0, -99.0, 31.0)], crs="EPSG:4326")
classes = _zonal_majority_class(self.raster_path, counties, DEFAULT_KOPPEN_CODE_MAP)
self.assertEqual(classes, ["ET", "Cfa"])
if __name__ == "__main__":
unittest.main()