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:
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from check_climate_data import ( # noqa: E402
|
||||
CHECK_BLANKS,
|
||||
CHECK_COLUMNS,
|
||||
CHECK_COUNTIES,
|
||||
CHECK_CROSS,
|
||||
CHECK_FORMAT,
|
||||
CHECK_GEOMETRY,
|
||||
CHECK_SOURCES,
|
||||
CHECK_VALUES,
|
||||
DEFAULT_CLIMATE_CSV,
|
||||
DEFAULT_COUNTIES_GEOJSON,
|
||||
DEFAULT_METRIC_SOURCES,
|
||||
EXPECTED_COLUMNS,
|
||||
run_checks,
|
||||
)
|
||||
|
||||
NOAA_GRID_COLUMNS = (
|
||||
"avgTempF",
|
||||
"avgDiurnalTempRangeF",
|
||||
"annualPrecipIn",
|
||||
"seasonalityIndex",
|
||||
"wettestPrecipMonth",
|
||||
"driestPrecipMonth",
|
||||
"absoluteExtremeDays",
|
||||
"avgSummerSpecificHumidityGKg",
|
||||
"humidHeatDays",
|
||||
"humidHeatSourceFips",
|
||||
)
|
||||
|
||||
|
||||
def make_row(fips: str, state: str, **overrides: str) -> dict[str, str]:
|
||||
row = {
|
||||
"countyFips": fips,
|
||||
"countyName": "Test",
|
||||
"state": state,
|
||||
"koppenZone": "Cfa",
|
||||
"koppenPrimaryClass": "",
|
||||
"koppenSecondaryClass": "",
|
||||
"avgTempF": "60.0",
|
||||
"avgDiurnalTempRangeF": "20.00",
|
||||
"annualPrecipIn": "40.0",
|
||||
"seasonalityIndex": "20",
|
||||
"wettestPrecipMonth": "May",
|
||||
"driestPrecipMonth": "October",
|
||||
"absoluteExtremeDays": "10.0",
|
||||
"meanDailyGlobalHorizontalRadiationKwhM2Day": "4.5",
|
||||
"clearSkyGhiReductionIndex": "0.25",
|
||||
"avgSummerSpecificHumidityGKg": "12.0",
|
||||
"humidHeatDays": "30.0",
|
||||
"humidHeatSourceFips": fips,
|
||||
"humidHeatFipsAdjustment": "",
|
||||
"source": "test",
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def valid_rows() -> list[dict[str, str]]:
|
||||
alaska = make_row("02013", "AK", koppenZone="Dfc", **{column: "" for column in NOAA_GRID_COLUMNS})
|
||||
return [make_row("01001", "AL"), alaska]
|
||||
|
||||
|
||||
class CheckClimateDataTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temp_dir = TemporaryDirectory()
|
||||
self.base = Path(self._temp_dir.name)
|
||||
self.csv_path = self.base / "climate-data.csv"
|
||||
self.geojson_path = self.base / "counties.json"
|
||||
self.sources_path = self.base / "metric_sources.json"
|
||||
self.write_geojson(["01001", "02013"])
|
||||
self.sources_path.write_text(json.dumps({"schemaVersion": 1, "metrics": {}}), encoding="utf-8")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temp_dir.cleanup()
|
||||
|
||||
def write_csv(self, rows: list[dict[str, str]], fieldnames: list[str] | None = None, encoding: str = "utf-8") -> None:
|
||||
with self.csv_path.open("w", encoding=encoding, newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames or list(EXPECTED_COLUMNS))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
def write_geojson(self, fips_codes: list[str]) -> None:
|
||||
features = [
|
||||
{"type": "Feature", "properties": {"id": fips, "STATE": fips[:2]}, "geometry": None}
|
||||
for fips in fips_codes
|
||||
]
|
||||
self.geojson_path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8")
|
||||
|
||||
def run_report(self):
|
||||
return run_checks(self.csv_path, self.geojson_path, self.sources_path)
|
||||
|
||||
def test_valid_file_passes_with_allowed_alaska_blanks(self) -> None:
|
||||
self.write_csv(valid_rows())
|
||||
|
||||
report = self.run_report()
|
||||
|
||||
self.assertTrue(report.ok, report.checks)
|
||||
|
||||
def test_mixed_koppen_county_with_stripe_classes_passes(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0].update(koppenZone="Mixed", koppenPrimaryClass="Csb", koppenSecondaryClass="Dsb")
|
||||
self.write_csv(rows)
|
||||
|
||||
self.assertTrue(self.run_report().ok)
|
||||
|
||||
def test_mixed_county_without_stripe_classes_is_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0]["koppenZone"] = "Mixed"
|
||||
self.write_csv(rows)
|
||||
|
||||
problems = self.run_report().checks[CHECK_CROSS]
|
||||
|
||||
self.assertEqual(problems, ["01001: Mixed Koppen county needs koppenPrimaryClass and koppenSecondaryClass"])
|
||||
|
||||
def test_stripe_classes_on_predominant_county_are_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0].update(koppenPrimaryClass="Csb", koppenSecondaryClass="Dsb")
|
||||
self.write_csv(rows)
|
||||
|
||||
problems = self.run_report().checks[CHECK_CROSS]
|
||||
|
||||
self.assertEqual(problems, ["01001: Koppen stripe classes are set but koppenZone is Cfa, not Mixed"])
|
||||
|
||||
def test_invalid_or_identical_stripe_classes_are_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0].update(koppenZone="Mixed", koppenPrimaryClass="Csb", koppenSecondaryClass="Csb")
|
||||
rows[1].update(koppenZone="Mixed", koppenPrimaryClass="Xyz", koppenSecondaryClass="Dfc")
|
||||
self.write_csv(rows)
|
||||
|
||||
problems = self.run_report().checks[CHECK_CROSS]
|
||||
|
||||
self.assertEqual(
|
||||
problems,
|
||||
["01001: Koppen stripe classes are both Csb", "02013: invalid Koppen stripe classes 'Xyz'/'Dfc'"],
|
||||
)
|
||||
|
||||
def test_bad_values_and_categories_are_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0].update(avgTempF="120", koppenZone="cfa", wettestPrecipMonth="Febuary", seasonalityIndex="12.5")
|
||||
self.write_csv(rows)
|
||||
|
||||
problems = self.run_report().checks[CHECK_VALUES]
|
||||
|
||||
self.assertEqual(len(problems), 4, problems)
|
||||
self.assertTrue(any("avgTempF=120 outside" in problem for problem in problems))
|
||||
self.assertTrue(any("'cfa' is not an allowed category" in problem for problem in problems))
|
||||
|
||||
def test_blank_outside_allowed_states_is_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[0]["avgTempF"] = ""
|
||||
self.write_csv(rows)
|
||||
|
||||
problems = self.run_report().checks[CHECK_BLANKS]
|
||||
|
||||
self.assertEqual(problems, ["01001 (AL): avgTempF is blank"])
|
||||
|
||||
def test_county_missing_from_map_is_reported(self) -> None:
|
||||
self.write_csv(valid_rows())
|
||||
self.write_geojson(["01001"])
|
||||
|
||||
problems = self.run_report().checks[CHECK_GEOMETRY]
|
||||
|
||||
self.assertEqual(problems, ["02013 is in the CSV but has no map polygon"])
|
||||
|
||||
def test_duplicate_fips_and_unexpected_column_are_reported(self) -> None:
|
||||
rows = valid_rows()
|
||||
rows[1] = make_row("01001", "AL", extraMetric="1")
|
||||
self.write_csv(rows, fieldnames=list(EXPECTED_COLUMNS) + ["extraMetric"])
|
||||
|
||||
report = self.run_report()
|
||||
|
||||
self.assertIn("duplicate countyFips 01001", report.checks[CHECK_COUNTIES])
|
||||
self.assertTrue(any("unexpected column 'extraMetric'" in problem for problem in report.checks[CHECK_COLUMNS]))
|
||||
|
||||
def test_byte_order_mark_is_reported(self) -> None:
|
||||
self.write_csv(valid_rows(), encoding="utf-8-sig")
|
||||
|
||||
report = self.run_report()
|
||||
|
||||
self.assertEqual(len(report.checks[CHECK_FORMAT]), 1)
|
||||
self.assertEqual(report.checks[CHECK_COLUMNS], [])
|
||||
|
||||
def test_unknown_metric_in_sources_file_is_reported(self) -> None:
|
||||
self.write_csv(valid_rows())
|
||||
self.sources_path.write_text(json.dumps({"metrics": {"notAMetric": {}}}), encoding="utf-8")
|
||||
|
||||
problems = self.run_report().checks[CHECK_SOURCES]
|
||||
|
||||
self.assertEqual(problems, ["metric_sources.json describes unknown metric 'notAMetric'"])
|
||||
|
||||
@unittest.skipUnless(DEFAULT_CLIMATE_CSV.exists(), "project climate CSV not present")
|
||||
def test_project_climate_csv_passes(self) -> None:
|
||||
report = run_checks(DEFAULT_CLIMATE_CSV, DEFAULT_COUNTIES_GEOJSON, DEFAULT_METRIC_SOURCES)
|
||||
|
||||
self.assertTrue(report.ok, report.checks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
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, box
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from apply_koppen_metric_to_climate_data import apply_koppen_metric # noqa: E402
|
||||
from build_county_koppen_metric import ( # noqa: E402
|
||||
MIXED_CLASS,
|
||||
build_koppen_records,
|
||||
classify,
|
||||
rank_class_shares,
|
||||
)
|
||||
from common.county_zonal_stats import area_weighted_class_weights # noqa: E402
|
||||
from common.koppen_legend import DEFAULT_KOPPEN_CODE_MAP # noqa: E402
|
||||
|
||||
CFA, CFB, DFB, ET = 14, 15, 26, 29
|
||||
|
||||
|
||||
def write_global_raster(path: Path, cells: dict[tuple[int, int], int]) -> None:
|
||||
"""Write a 1-degree global raster; row r covers latitudes 89 - r to 90 - r."""
|
||||
data = np.zeros((180, 360), dtype=np.uint8)
|
||||
for (row, col), code in cells.items():
|
||||
data[row, col] = code
|
||||
with rasterio.open(
|
||||
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 write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
class ClassifyTests(unittest.TestCase):
|
||||
def test_clear_majority_is_predominant(self) -> None:
|
||||
self.assertEqual(classify([("Dfb", 0.60), ("Dfa", 0.30)]), "Dfb")
|
||||
|
||||
def test_exact_cutoffs_are_predominant(self) -> None:
|
||||
self.assertEqual(classify([("Csa", 0.50), ("BSk", 0.45)]), "Csa")
|
||||
|
||||
def test_majority_with_close_runner_up_is_mixed(self) -> None:
|
||||
self.assertEqual(classify([("Dfb", 0.501), ("Dfa", 0.499)]), MIXED_CLASS)
|
||||
|
||||
def test_no_majority_is_mixed(self) -> None:
|
||||
self.assertEqual(classify([("BSh", 0.29), ("Csa", 0.24), ("Dsb", 0.23)]), MIXED_CLASS)
|
||||
|
||||
def test_single_class_is_predominant(self) -> None:
|
||||
self.assertEqual(classify([("ET", 1.0)]), "ET")
|
||||
|
||||
def test_no_valid_cells_is_blank(self) -> None:
|
||||
self.assertEqual(classify([]), "")
|
||||
|
||||
|
||||
class RankClassSharesTests(unittest.TestCase):
|
||||
def test_shares_are_ranked_and_ties_go_to_smaller_code(self) -> None:
|
||||
ranked = rank_class_shares({DFB: 2.0, CFA: 2.0, ET: 1.0}, DEFAULT_KOPPEN_CODE_MAP)
|
||||
|
||||
self.assertEqual([name for name, _ in ranked], ["Cfa", "Dfb", "ET"])
|
||||
self.assertAlmostEqual(ranked[0][1], 0.4)
|
||||
self.assertAlmostEqual(sum(share for _, share in ranked), 1.0)
|
||||
|
||||
def test_unknown_code_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
rank_class_shares({99: 1.0}, DEFAULT_KOPPEN_CODE_MAP)
|
||||
|
||||
def test_no_weights_give_no_classes(self) -> None:
|
||||
self.assertEqual(rank_class_shares({}, DEFAULT_KOPPEN_CODE_MAP), [])
|
||||
|
||||
|
||||
class KoppenRasterTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temp_dir = TemporaryDirectory()
|
||||
self.raster_path = Path(self._temp_dir.name) / "koppen.tif"
|
||||
write_global_raster(
|
||||
self.raster_path,
|
||||
{
|
||||
(59, 80): CFA, # 30..31 N, 100..99 W
|
||||
(59, 81): CFB, # 30..31 N, 99..98 W
|
||||
(49, 80): DFB, # 40..41 N, 100..99 W; 99..98 W is ocean
|
||||
(29, 80): DFB, # 60..61 N
|
||||
(79, 80): CFA, # 10..11 N
|
||||
(38, 358): ET, # 51..52 N, 178..179 E
|
||||
(38, 359): ET, # 51..52 N, 179..180 E
|
||||
(38, 0): CFB, # 51..52 N, 180..179 W
|
||||
},
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temp_dir.cleanup()
|
||||
|
||||
def weights(self, geometry) -> dict[int, float]:
|
||||
with rasterio.open(self.raster_path) as source:
|
||||
return area_weighted_class_weights(source, geometry)
|
||||
|
||||
|
||||
class AreaWeightedClassWeightsTests(KoppenRasterTestCase):
|
||||
def test_partial_cell_counts_by_fraction_inside(self) -> None:
|
||||
weights = self.weights(box(-100.0, 30.0, -98.5, 31.0))
|
||||
|
||||
self.assertAlmostEqual(weights[CFB] / weights[CFA], 0.5)
|
||||
|
||||
def test_cells_are_weighted_by_latitude(self) -> None:
|
||||
weights = self.weights(MultiPolygon([box(-100.0, 60.0, -99.0, 61.0), box(-100.0, 10.0, -99.0, 11.0)]))
|
||||
|
||||
expected = math.cos(math.radians(60.5)) / math.cos(math.radians(10.5))
|
||||
self.assertAlmostEqual(weights[DFB] / weights[CFA], expected)
|
||||
|
||||
def test_ocean_cells_are_excluded(self) -> None:
|
||||
weights = self.weights(box(-100.0, 40.0, -98.0, 41.0))
|
||||
|
||||
self.assertEqual(list(weights), [DFB])
|
||||
|
||||
def test_islands_on_both_sides_of_the_date_line_are_combined(self) -> None:
|
||||
weights = self.weights(MultiPolygon([box(178.0, 51.0, 180.0, 52.0), box(-180.0, 51.0, -179.0, 52.0)]))
|
||||
|
||||
self.assertAlmostEqual(weights[ET] / weights[CFB], 2.0)
|
||||
|
||||
def test_no_valid_cells_return_no_weights(self) -> None:
|
||||
self.assertEqual(self.weights(box(-50.0, 30.0, -49.0, 31.0)), {})
|
||||
|
||||
|
||||
class BuildKoppenRecordsTests(KoppenRasterTestCase):
|
||||
def test_counties_are_predominant_mixed_or_blank(self) -> None:
|
||||
counties = gpd.GeoDataFrame(
|
||||
{"county_fips": ["00001", "00002", "00003"], "county_name": ["A", "B", "C"], "state": ["AA"] * 3},
|
||||
geometry=[box(-100.0, 40.0, -99.0, 41.0), box(-100.0, 30.0, -98.0, 31.0), box(-50.0, 30.0, -49.0, 31.0)],
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
records = build_koppen_records(counties, self.raster_path, DEFAULT_KOPPEN_CODE_MAP)
|
||||
|
||||
self.assertEqual([record["koppenZone"] for record in records], ["Dfb", MIXED_CLASS, ""])
|
||||
self.assertEqual((records[0]["koppenTopShare"], records[0]["koppenSecondClass"]), ("1.0000", ""))
|
||||
self.assertEqual(
|
||||
(records[1]["koppenTopClass"], records[1]["koppenTopShare"], records[1]["koppenSecondShare"]),
|
||||
("Cfa", "0.5000", "0.5000"),
|
||||
)
|
||||
self.assertEqual(records[2]["koppenTopShare"], "")
|
||||
|
||||
|
||||
METRIC_TEST_FIELDS = ["countyFips", "koppenZone", "koppenTopClass", "koppenTopShare", "koppenSecondClass"]
|
||||
|
||||
|
||||
class ApplyKoppenMetricTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temp_dir = TemporaryDirectory()
|
||||
base = Path(self._temp_dir.name)
|
||||
self.climate_data = base / "climate-data.csv"
|
||||
self.metric = base / "koppen.csv"
|
||||
self.climate_fields = ["countyFips", "countyName", "state", "koppenZone", "avgTempF", "source"]
|
||||
write_csv(
|
||||
self.climate_data,
|
||||
self.climate_fields,
|
||||
[
|
||||
{"countyFips": "01001", "countyName": "A", "state": "AL", "koppenZone": "Cfa", "avgTempF": "64.5", "source": "s"},
|
||||
{"countyFips": "01003", "countyName": "B", "state": "AL", "koppenZone": "Dfb", "avgTempF": "50.1", "source": "s"},
|
||||
],
|
||||
)
|
||||
self.write_metric(
|
||||
[
|
||||
{"countyFips": "01001", "koppenZone": "Mixed", "koppenTopClass": "Csb", "koppenTopShare": "0.4800", "koppenSecondClass": "Dsb"},
|
||||
{"countyFips": "01003", "koppenZone": "Dfb", "koppenTopClass": "Dfb", "koppenTopShare": "0.9000", "koppenSecondClass": "Dfc"},
|
||||
]
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temp_dir.cleanup()
|
||||
|
||||
def write_metric(self, rows: list[dict[str, str]]) -> None:
|
||||
write_csv(self.metric, METRIC_TEST_FIELDS, rows)
|
||||
|
||||
def read_climate(self) -> tuple[list[str], list[dict[str, str]]]:
|
||||
with self.climate_data.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
return list(reader.fieldnames or []), list(reader)
|
||||
|
||||
def test_stripe_columns_are_added_after_koppen_zone(self) -> None:
|
||||
changes, added_columns = apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
|
||||
fields, _ = self.read_climate()
|
||||
self.assertEqual(added_columns, ["koppenPrimaryClass", "koppenSecondaryClass"])
|
||||
self.assertEqual(
|
||||
fields,
|
||||
["countyFips", "countyName", "state", "koppenZone", "koppenPrimaryClass", "koppenSecondaryClass", "avgTempF", "source"],
|
||||
)
|
||||
self.assertEqual(
|
||||
changes,
|
||||
[
|
||||
("01001", "koppenZone", "Cfa", "Mixed"),
|
||||
("01001", "koppenPrimaryClass", "", "Csb"),
|
||||
("01001", "koppenSecondaryClass", "", "Dsb"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_only_mixed_counties_get_stripe_classes(self) -> None:
|
||||
apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
|
||||
_, rows = self.read_climate()
|
||||
self.assertEqual(
|
||||
[(row["koppenZone"], row["koppenPrimaryClass"], row["koppenSecondaryClass"]) for row in rows],
|
||||
[("Mixed", "Csb", "Dsb"), ("Dfb", "", "")],
|
||||
)
|
||||
self.assertEqual([row["avgTempF"] for row in rows], ["64.5", "50.1"])
|
||||
|
||||
def test_existing_stripe_columns_are_updated_in_place(self) -> None:
|
||||
apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
self.write_metric(
|
||||
[
|
||||
{"countyFips": "01001", "koppenZone": "Cfa", "koppenTopClass": "Cfa", "koppenTopShare": "0.7000", "koppenSecondClass": "Dfb"},
|
||||
{"countyFips": "01003", "koppenZone": "Dfb", "koppenTopClass": "Dfb", "koppenTopShare": "0.9000", "koppenSecondClass": "Dfc"},
|
||||
]
|
||||
)
|
||||
|
||||
changes, added_columns = apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
|
||||
fields, rows = self.read_climate()
|
||||
self.assertEqual(added_columns, [])
|
||||
self.assertEqual(fields.count("koppenPrimaryClass"), 1)
|
||||
self.assertEqual((rows[0]["koppenZone"], rows[0]["koppenPrimaryClass"], rows[0]["koppenSecondaryClass"]), ("Cfa", "", ""))
|
||||
self.assertEqual(len(changes), 3)
|
||||
|
||||
def test_dry_run_writes_nothing(self) -> None:
|
||||
before = self.climate_data.read_bytes()
|
||||
|
||||
changes, added_columns = apply_koppen_metric(self.climate_data, self.metric, self.climate_data, dry_run=True)
|
||||
|
||||
self.assertEqual(len(changes), 3)
|
||||
self.assertEqual(len(added_columns), 2)
|
||||
self.assertEqual(self.climate_data.read_bytes(), before)
|
||||
|
||||
def test_missing_county_raises_without_writing(self) -> None:
|
||||
self.write_metric(
|
||||
[{"countyFips": "01001", "koppenZone": "Mixed", "koppenTopClass": "Csb", "koppenTopShare": "0.4800", "koppenSecondClass": "Dsb"}]
|
||||
)
|
||||
before = self.climate_data.read_bytes()
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
self.assertEqual(self.climate_data.read_bytes(), before)
|
||||
|
||||
def test_mixed_county_without_classes_raises_without_writing(self) -> None:
|
||||
self.write_metric(
|
||||
[
|
||||
{"countyFips": "01001", "koppenZone": "Mixed", "koppenTopClass": "", "koppenTopShare": "", "koppenSecondClass": ""},
|
||||
{"countyFips": "01003", "koppenZone": "Dfb", "koppenTopClass": "Dfb", "koppenTopShare": "0.9000", "koppenSecondClass": "Dfc"},
|
||||
]
|
||||
)
|
||||
before = self.climate_data.read_bytes()
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
apply_koppen_metric(self.climate_data, self.metric, self.climate_data)
|
||||
self.assertEqual(self.climate_data.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user