Files
Climate-Mood-Analysis/scripts/build_county_climate_data.py
T
KnouandClaude Opus 5 e855d583e3 Document restructuring and the beginnings of Filter 2 changes
Split the pipeline documentation by purpose so each fact has one home:
- docs/pipeline-plan.md keeps the plan, checklist, tracker, and guardrails
- docs/decisions.md holds open decisions and the dated decision log
- docs/reviews/ holds findings and tasks: one file per filter, plus
  00-cross-filter.md for findings that span filters
- scripts/common/README.md holds the shared-helper rules (formerly Phase 2)
- filter-calculations.md now describes calculations only

Filed findings 12-22 from a consistency audit of the app, docs, and scripts.

Filter 1 (Köppen-Geiger): use "Köppen" with the umlaut in all prose, labels,
docstrings, help text, and checker messages (finding 21), and correct the
base build's "majority" docstring (finding 22).

Filter 2 (annual avg temperature): record the adopted definition in
filter-calculations.md §2: equally weighted 1991-2020 monthly normals, per
WMO-No. 1203 and NOAA's 2020 methodology; area-weighted county means; blank
unless all 12 months exist. Code changes for this filter are still pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:13:11 -04:00

691 lines
27 KiB
Python

#!/usr/bin/env python3
"""
Build county-level climate records for the web app filters.
Outputs a CSV file compatible with the browser app:
climate-data.csv
Metrics produced per county:
- koppenZone: largest-share Köppen-Geiger class among touched cells (replaced by the Köppen apply step)
- avgTempF: annual mean temperature from NOAA 1991-2020 gridded normals
- annualPrecipIn: annual total precipitation from NOAA 1991-2020 gridded normals
- seasonalityIndex: precipitation seasonality, coefficient of variation of monthly totals (%)
- wettestPrecipMonth: month with the highest 1991-2020 county mean precipitation
- driestPrecipMonth: month with the lowest 1991-2020 county mean precipitation
- extremeDays: count of normal-days with Tmax >= hot threshold or Tmin <= freeze threshold
- meanDailyGlobalHorizontalRadiationKwhM2Day: mean daily global horizontal radiation (GHI), when a solar raster or representative-point CSV is provided
This script is intended for offline generation of complete county records.
"""
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from typing import Dict, List, Tuple
import geopandas as gpd
import numpy as np
import rasterio
import xarray as xr
from affine import Affine
from rasterio.features import geometry_mask
from shapely.geometry.base import BaseGeometry
from common.counties import load_counties, normalize_fips
from common.county_zonal_stats import geometry_window, split_at_antimeridian
from common.koppen_legend import load_koppen_legend
MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
def _select_data_var(dataset: xr.Dataset, preferred: str) -> str:
"""Choose the best matching climate variable from a dataset."""
if preferred in dataset.data_vars:
return preferred
alias_map = {
"mlytavg_norm": ["tavg", "tavg_norm"],
"mlyprcp_norm": ["prcp", "prcp_norm"],
"dlytmax_norm": ["tmax", "tmax_norm"],
"dlytmin_norm": ["tmin", "tmin_norm"],
}
for alias in alias_map.get(preferred, []):
if alias in dataset.data_vars:
return alias
for candidate in dataset.data_vars:
if candidate.endswith("_norm"):
return candidate
# If the dataset only has one variable, use it as a final fallback.
if len(dataset.data_vars) == 1:
return next(iter(dataset.data_vars))
raise ValueError(
f"Unable to select a climate variable. Preferred='{preferred}', available={list(dataset.data_vars)}"
)
def _load_solar_ghi_csv(solar_ghi_csv: Path, counties: gpd.GeoDataFrame) -> List[float]:
"""Load county-keyed mean daily GHI values from a representative-point or area-average CSV."""
with solar_ghi_csv.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
if reader.fieldnames is None:
raise ValueError(f"Solar GHI CSV at {solar_ghi_csv} has no header row.")
if "county_fips" in reader.fieldnames:
fips_field = "county_fips"
elif "countyFips" in reader.fieldnames:
fips_field = "countyFips"
else:
raise ValueError(
f"Solar GHI CSV at {solar_ghi_csv} must include county_fips or countyFips."
)
if "meanDailyGlobalHorizontalRadiationKwhM2Day" not in reader.fieldnames:
raise ValueError(
f"Solar GHI CSV at {solar_ghi_csv} must include meanDailyGlobalHorizontalRadiationKwhM2Day."
)
solar_by_fips: Dict[str, float] = {}
for row in reader:
county_fips = normalize_fips(row.get(fips_field, ""), 5)
raw_value = str(row.get("meanDailyGlobalHorizontalRadiationKwhM2Day", "")).strip()
if not county_fips or not raw_value:
continue
try:
solar_by_fips[county_fips] = float(raw_value)
except ValueError as exc:
raise ValueError(
f"Invalid meanDailyGlobalHorizontalRadiationKwhM2Day value for county {county_fips}: {raw_value}"
) from exc
return [
solar_by_fips.get(str(row["county_fips"]), float("nan"))
for _, row in counties.iterrows()
]
def _as_monthly_climatology(data_array: xr.DataArray, start_year: int, end_year: int) -> xr.DataArray:
"""Return 12 monthly normals from slices or a time series."""
if "time" not in data_array.dims:
raise ValueError(f"Expected a time dimension, got dims={data_array.dims}")
time_size = int(data_array.sizes.get("time", 0))
if time_size == 12:
return data_array
time_index = data_array["time"]
if not hasattr(time_index, "dt"):
# Attempt CF decoding when time is numeric with units/calendar attrs.
try:
decoded = xr.decode_cf(xr.Dataset({"_v": data_array}))
data_array = decoded["_v"]
time_index = data_array["time"]
except Exception as exc:
raise ValueError(
"Time coordinate does not support datetime access for monthly climatology grouping, "
"and CF decoding failed."
) from exc
if not hasattr(time_index, "dt"):
raise ValueError("Time coordinate does not support datetime access for monthly climatology grouping.")
period = data_array.where(
(time_index.dt.year >= start_year) & (time_index.dt.year <= end_year),
drop=True,
)
if int(period.sizes.get("time", 0)) == 0:
raise ValueError(f"No monthly data found between {start_year} and {end_year}.")
monthly = period.groupby("time.month").mean("time", skipna=True)
if int(monthly.sizes.get("month", 0)) != 12:
raise ValueError(
f"Monthly climatology for {start_year}-{end_year} has {monthly.sizes.get('month', 0)} months; expected 12."
)
# Standardize to a `time` dimension so downstream code can reuse .isel(time=month_idx).
monthly = monthly.rename({"month": "time"})
monthly = monthly.assign_coords(time=np.arange(1, 13))
return monthly
def _infer_time_resolution_days(data_array: xr.DataArray) -> float:
"""Estimate the median spacing between time steps in days."""
time_values = np.asarray(data_array["time"].values)
if time_values.size < 2:
return float("inf")
deltas = np.diff(time_values).astype("timedelta64[D]").astype(np.int64)
deltas = deltas[deltas > 0]
if deltas.size == 0:
return float("inf")
return float(np.median(deltas))
def _extract_grid_2d(data_array: xr.DataArray) -> Tuple[np.ndarray, Affine]:
"""Convert a lat/lon slice to a raster array and transform."""
# Expected shape for 2D arrays: lat, lon
# Build affine from center coordinates.
lon = np.asarray(data_array["lon"].values, dtype=np.float64)
lat = np.asarray(data_array["lat"].values, dtype=np.float64)
arr = np.asarray(data_array.values, dtype=np.float64)
if arr.shape != (lat.size, lon.size):
raise ValueError(f"Unexpected grid shape {arr.shape}; expected {(lat.size, lon.size)}")
# Make sure the first row is northernmost to match affine from top-left.
if lat[0] < lat[-1]:
lat = lat[::-1]
arr = arr[::-1, :]
x_res = abs(lon[1] - lon[0])
y_res = abs(lat[0] - lat[1])
top_left_x = lon.min() - (x_res / 2.0)
top_left_y = lat.max() + (y_res / 2.0)
transform = Affine.translation(top_left_x, top_left_y) * Affine.scale(x_res, -y_res)
return arr, transform
def _zonal_mean(arr: np.ndarray, transform, counties: gpd.GeoDataFrame) -> List[float]:
"""Calculate the mean raster value inside each county polygon."""
values = np.asarray(arr, dtype=np.float64)
means: List[float] = []
for geometry in counties.geometry:
mask = geometry_mask(
[geometry.__geo_interface__],
out_shape=values.shape,
transform=transform,
invert=True,
all_touched=True,
)
selected = values[mask]
selected = selected[np.isfinite(selected)]
means.append(float(selected.mean()) if selected.size else float("nan"))
return means
def _zonal_mean_raster(raster_path: Path, counties: gpd.GeoDataFrame) -> List[float]:
"""Calculate the mean raster-file value for each county."""
with rasterio.open(raster_path) as source:
raster_counties = counties
if source.crs is not None and counties.crs is not None and counties.crs != source.crs:
raster_counties = counties.to_crs(source.crs)
data = source.read(1, masked=True)
values = np.asarray(data.filled(np.nan), dtype=np.float64)
if source.nodata is not None:
values = np.where(values == source.nodata, np.nan, values)
return _zonal_mean(values, source.transform, raster_counties)
def _touched_raster_values(source, geometry: BaseGeometry, split_antimeridian: bool) -> np.ndarray:
"""Read every raster cell a geometry touches, using a small window per piece."""
pieces = split_at_antimeridian(geometry) if split_antimeridian else [geometry]
selected: List[np.ndarray] = []
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)
mask = geometry_mask(
[piece.__geo_interface__],
out_shape=values.shape,
transform=source.window_transform(window),
invert=True,
all_touched=True,
)
selected.append(values[mask])
return np.concatenate(selected) if selected else np.array([], dtype=np.int64)
def _zonal_majority_class(koppen_raster: Path, counties: gpd.GeoDataFrame, code_map: Dict[int, str]) -> List[str]:
"""Assign each county its most common Köppen-Geiger class."""
classes: List[str] = []
with rasterio.open(koppen_raster) as source:
raster_counties = counties
if source.crs is not None and counties.crs is not None and counties.crs != source.crs:
raster_counties = counties.to_crs(source.crs)
# The 180-degree split only makes sense for longitude/latitude rasters.
split_antimeridian = source.crs is None or source.crs.is_geographic
for geometry in raster_counties.geometry:
selected = _touched_raster_values(source, geometry, split_antimeridian)
selected = selected[selected != 0]
if selected.size == 0:
classes.append("Cfa")
continue
codes, counts = np.unique(selected.astype(np.int64), return_counts=True)
code_int = int(codes[int(np.argmax(counts))])
classes.append(code_map.get(code_int, "Cfa"))
return classes
def _compute_extreme_days(
tmax_daily: xr.Dataset,
tmin_daily: xr.Dataset,
counties: gpd.GeoDataFrame,
hot_threshold_c: float,
freeze_threshold_c: float,
) -> List[float]:
"""Count daily hot or freezing days for each county."""
tmax_var = _select_data_var(tmax_daily, "dlytmax_norm")
tmin_var = _select_data_var(tmin_daily, "dlytmin_norm")
tmax = tmax_daily[tmax_var]
tmin = tmin_daily[tmin_var]
# If datasets differ slightly in day count, use the overlapping day count.
day_count = int(min(tmax.sizes["time"], tmin.sizes["time"]))
totals = np.zeros(len(counties), dtype=np.float64)
for day in range(day_count):
tmax_arr, tmax_transform = _extract_grid_2d(tmax.isel(time=day))
tmin_arr, tmin_transform = _extract_grid_2d(tmin.isel(time=day))
if tmax_transform != tmin_transform:
raise ValueError("Daily tmax/tmin grids do not share the same transform.")
day_tmax = _zonal_mean(tmax_arr, tmax_transform, counties)
day_tmin = _zonal_mean(tmin_arr, tmin_transform, counties)
for idx, (mx, mn) in enumerate(zip(day_tmax, day_tmin, strict=True)):
if np.isnan(mx) or np.isnan(mn):
continue
if mx >= hot_threshold_c or mn <= freeze_threshold_c:
totals[idx] += 1.0
return totals.tolist()
def _compute_extreme_days_monthly_proxy(
tmax_monthly: xr.DataArray,
tmin_monthly: xr.DataArray,
counties: gpd.GeoDataFrame,
hot_threshold_c: float,
freeze_threshold_c: float,
) -> List[float]:
"""Estimate extreme days from monthly tmax and tmin means."""
if int(tmax_monthly.sizes.get("time", 0)) != 12 or int(tmin_monthly.sizes.get("time", 0)) != 12:
raise ValueError("Monthly proxy for extremeDays requires 12 monthly slices for tmax and tmin.")
month_days = np.array([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], dtype=np.float64)
totals = np.zeros(len(counties), dtype=np.float64)
for month in range(12):
tmax_arr, tmax_transform = _extract_grid_2d(tmax_monthly.isel(time=month))
tmin_arr, tmin_transform = _extract_grid_2d(tmin_monthly.isel(time=month))
if tmax_transform != tmin_transform:
raise ValueError("Monthly proxy tmax/tmin grids do not share the same transform.")
month_tmax = _zonal_mean(tmax_arr, tmax_transform, counties)
month_tmin = _zonal_mean(tmin_arr, tmin_transform, counties)
for idx, (mx, mn) in enumerate(zip(month_tmax, month_tmin, strict=True)):
if np.isnan(mx) or np.isnan(mn):
continue
if mx >= hot_threshold_c or mn <= freeze_threshold_c:
totals[idx] += month_days[month]
return totals.tolist()
def _precip_month_extremes(prcp_months_mm: np.ndarray) -> tuple[str | None, str | None]:
"""Return wettest and driest month names from 12 monthly precipitation totals."""
valid_mask = np.isfinite(prcp_months_mm)
if not np.any(valid_mask):
return None, None
comparable = np.where(valid_mask, prcp_months_mm, np.nan)
wettest_month = MONTH_NAMES[int(np.nanargmax(comparable))]
driest_month = MONTH_NAMES[int(np.nanargmin(comparable))]
return wettest_month, driest_month
def build_county_records(
counties_geojson: Path,
koppen_raster: Path,
koppen_legend: Path | None,
monthly_tavg_nc: Path,
monthly_prcp_nc: Path,
daily_tmax_nc: Path,
daily_tmin_nc: Path,
hot_threshold_f: float,
freeze_threshold_f: float,
climatology_start_year: int,
climatology_end_year: int,
extreme_days_mode: str,
solar_ghi_raster: Path | None,
solar_ghi_csv: Path | None,
) -> Dict[str, dict]:
"""Build county climate records consumed by the web app."""
counties = load_counties(counties_geojson)
koppen_classes = _zonal_majority_class(koppen_raster, counties, load_koppen_legend(koppen_legend))
monthly_tavg = xr.open_dataset(monthly_tavg_nc, decode_times=True)
monthly_prcp = xr.open_dataset(monthly_prcp_nc, decode_times=True)
daily_tmax = xr.open_dataset(daily_tmax_nc, decode_times=True)
daily_tmin = xr.open_dataset(daily_tmin_nc, decode_times=True)
tavg_var = _select_data_var(monthly_tavg, "mlytavg_norm")
prcp_var = _select_data_var(monthly_prcp, "mlyprcp_norm")
tavg_monthly = _as_monthly_climatology(
monthly_tavg[tavg_var],
start_year=climatology_start_year,
end_year=climatology_end_year,
)
prcp_monthly = _as_monthly_climatology(
monthly_prcp[prcp_var],
start_year=climatology_start_year,
end_year=climatology_end_year,
)
tavg_by_month: List[List[float]] = []
prcp_by_month_mm: List[List[float]] = []
for month in range(12):
tavg_arr, tavg_transform = _extract_grid_2d(tavg_monthly.isel(time=month))
prcp_arr, prcp_transform = _extract_grid_2d(prcp_monthly.isel(time=month))
if tavg_transform != prcp_transform:
raise ValueError("Monthly tavg/prcp grids do not share the same transform.")
tavg_by_month.append(_zonal_mean(tavg_arr, tavg_transform, counties))
prcp_by_month_mm.append(_zonal_mean(prcp_arr, prcp_transform, counties))
hot_threshold_c = (hot_threshold_f - 32.0) * (5.0 / 9.0)
freeze_threshold_c = (freeze_threshold_f - 32.0) * (5.0 / 9.0)
tmax_var = _select_data_var(daily_tmax, "dlytmax_norm")
tmin_var = _select_data_var(daily_tmin, "dlytmin_norm")
tmax_da = daily_tmax[tmax_var]
tmin_da = daily_tmin[tmin_var]
resolution_days = min(_infer_time_resolution_days(tmax_da), _infer_time_resolution_days(tmin_da))
# Daily-like input (true daily normals or daily grids).
if resolution_days <= 2.0:
extreme_days = _compute_extreme_days(
daily_tmax=daily_tmax,
daily_tmin=daily_tmin,
counties=counties,
hot_threshold_c=hot_threshold_c,
freeze_threshold_c=freeze_threshold_c,
)
extreme_days_source_tag = "daily"
else:
if extreme_days_mode == "require-daily":
raise ValueError(
"Extreme-days inputs appear to be monthly series, but --extreme-days-mode=require-daily was set."
)
tmax_monthly = _as_monthly_climatology(
tmax_da,
start_year=climatology_start_year,
end_year=climatology_end_year,
)
tmin_monthly = _as_monthly_climatology(
tmin_da,
start_year=climatology_start_year,
end_year=climatology_end_year,
)
extreme_days = _compute_extreme_days_monthly_proxy(
tmax_monthly=tmax_monthly,
tmin_monthly=tmin_monthly,
counties=counties,
hot_threshold_c=hot_threshold_c,
freeze_threshold_c=freeze_threshold_c,
)
extreme_days_source_tag = "monthly-proxy"
if solar_ghi_raster is not None and solar_ghi_raster.exists():
solar_ghi_kwh_m2_day = _zonal_mean_raster(solar_ghi_raster, counties)
solar_source_tag = "solar-ghi-raster"
elif solar_ghi_csv is not None and solar_ghi_csv.exists():
solar_ghi_kwh_m2_day = _load_solar_ghi_csv(solar_ghi_csv, counties)
solar_source_tag = "solar-ghi-representative-point"
else:
if solar_ghi_raster is not None:
print(f"Solar GHI raster not found at {solar_ghi_raster}; leaving meanDailyGlobalHorizontalRadiationKwhM2Day blank.")
if solar_ghi_csv is not None:
print(f"Solar GHI CSV not found at {solar_ghi_csv}; leaving meanDailyGlobalHorizontalRadiationKwhM2Day blank.")
solar_ghi_kwh_m2_day = [float("nan")] * len(counties)
solar_source_tag = "no-solar-ghi-source"
records: Dict[str, dict] = {}
missing_numeric_count = 0
for idx, row in counties.iterrows():
county_fips = row["county_fips"]
county_name = row["county_name"]
state = row["state"]
koppen_zone = koppen_classes[idx]
temp_months_c = np.array([tavg_by_month[m][idx] for m in range(12)], dtype=np.float64)
prcp_months_mm = np.array([prcp_by_month_mm[m][idx] for m in range(12)], dtype=np.float64)
# Guard against nodata counties (outside CONUS grids, islands, etc.).
valid_temp = temp_months_c[np.isfinite(temp_months_c)]
valid_prcp = prcp_months_mm[np.isfinite(prcp_months_mm)]
avg_temp_c = np.nanmean(valid_temp) if valid_temp.size else np.nan
annual_prcp_mm = np.nansum(valid_prcp) if valid_prcp.size else np.nan
# Precipitation seasonality as coefficient of variation, capped 0-100.
if valid_prcp.size:
mean_prcp = float(np.nanmean(valid_prcp))
std_prcp = float(np.nanstd(valid_prcp))
seasonality = 0.0 if mean_prcp <= 0 else max(0.0, min(100.0, (std_prcp / mean_prcp) * 100.0))
else:
seasonality = np.nan
wettest_precip_month, driest_precip_month = _precip_month_extremes(prcp_months_mm)
use_missing_temp = not np.isfinite(avg_temp_c)
use_missing_prcp = not np.isfinite(annual_prcp_mm)
use_missing_seasonality = not np.isfinite(seasonality)
use_missing_base_noaa = use_missing_temp or use_missing_prcp or use_missing_seasonality
use_missing_extreme = use_missing_base_noaa or not np.isfinite(extreme_days[idx])
used_any_missing_numeric = (
use_missing_temp or use_missing_prcp or use_missing_seasonality or use_missing_extreme
)
if used_any_missing_numeric:
missing_numeric_count += 1
avg_temp_f = (
round((float(avg_temp_c) * 9.0 / 5.0) + 32.0, 1)
if not use_missing_temp
else None
)
annual_prcp_in = (
round(float(annual_prcp_mm) / 25.4, 1)
if not use_missing_prcp
else None
)
seasonality_idx = (
int(round(float(seasonality)))
if not use_missing_seasonality
else None
)
extreme_days_value = (
int(round(float(extreme_days[idx])))
if not use_missing_extreme
else None
)
mean_daily_global_horizontal_radiation_value = (
round(float(solar_ghi_kwh_m2_day[idx]), 2)
if np.isfinite(solar_ghi_kwh_m2_day[idx])
else None
)
source_suffix = " + missing-noaa-numeric" if used_any_missing_numeric else ""
solar_source_suffix = "" if mean_daily_global_horizontal_radiation_value is not None else " + missing-solar-ghi"
record = {
"countyName": county_name,
"state": state,
"koppenZone": koppen_zone,
"avgTempF": avg_temp_f,
"annualPrecipIn": annual_prcp_in,
"seasonalityIndex": seasonality_idx,
"wettestPrecipMonth": wettest_precip_month,
"driestPrecipMonth": driest_precip_month,
"extremeDays": extreme_days_value,
"meanDailyGlobalHorizontalRadiationKwhM2Day": mean_daily_global_horizontal_radiation_value,
"source": (
"kg-beck2023 + noaa-nclimgrid-1991-2020 "
f"({extreme_days_source_tag}) + {solar_source_tag}{source_suffix}{solar_source_suffix}"
)
}
records[county_fips] = record
monthly_tavg.close()
monthly_prcp.close()
daily_tmax.close()
daily_tmin.close()
if missing_numeric_count:
print(
f"Marked missing numeric NOAA values for {missing_numeric_count} counties "
"(likely outside NOAA grid coverage)."
)
return records
def write_csv(records: Dict[str, dict], out_file: Path) -> None:
"""Write county records to the browser-loaded CSV payload."""
fields = [
"countyFips",
"countyName",
"state",
"koppenZone",
"avgTempF",
"annualPrecipIn",
"seasonalityIndex",
"wettestPrecipMonth",
"driestPrecipMonth",
"extremeDays",
"meanDailyGlobalHorizontalRadiationKwhM2Day",
"source",
]
with out_file.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for county_fips in sorted(records):
row = {"countyFips": county_fips}
row.update(records[county_fips])
writer.writerow(row)
def parse_args() -> argparse.Namespace:
"""Define and parse command-line options for this generator."""
parser = argparse.ArgumentParser(description="Generate county climate records for the web app.")
parser.add_argument("--counties-geojson", type=Path, required=True, help="County polygon GeoJSON path.")
parser.add_argument("--koppen-raster", type=Path, required=True, help="Köppen-Geiger raster TIFF path.")
parser.add_argument("--koppen-legend", type=Path, default=None, help="Optional legend.txt mapping integer codes.")
parser.add_argument(
"--monthly-tavg-nc",
type=Path,
required=True,
help="NOAA monthly tavg netCDF (12-slice normals or monthly time-series).",
)
parser.add_argument(
"--monthly-prcp-nc",
type=Path,
required=True,
help="NOAA monthly prcp netCDF (12-slice normals or monthly time-series).",
)
parser.add_argument(
"--daily-tmax-nc",
type=Path,
required=True,
help="NOAA tmax netCDF (daily normals/grids preferred; monthly time-series allowed in proxy mode).",
)
parser.add_argument(
"--daily-tmin-nc",
type=Path,
required=True,
help="NOAA tmin netCDF (daily normals/grids preferred; monthly time-series allowed in proxy mode).",
)
parser.add_argument("--hot-threshold-f", type=float, default=95.0, help="Hot day threshold in Fahrenheit.")
parser.add_argument("--freeze-threshold-f", type=float, default=32.0, help="Freeze day threshold in Fahrenheit.")
parser.add_argument(
"--climatology-start-year",
type=int,
default=1991,
help="Start year (inclusive) for monthly climatology calculation when time series files are provided.",
)
parser.add_argument(
"--climatology-end-year",
type=int,
default=2020,
help="End year (inclusive) for monthly climatology calculation when time series files are provided.",
)
parser.add_argument(
"--extreme-days-mode",
choices=["auto", "require-daily"],
default="auto",
help="`auto` allows monthly-proxy extremeDays if daily grids are not provided; `require-daily` enforces daily input.",
)
parser.add_argument(
"--solar-ghi-raster",
type=Path,
default=None,
help="Optional raster of mean daily GHI in kWh/m2/day for meanDailyGlobalHorizontalRadiationKwhM2Day.",
)
parser.add_argument(
"--solar-ghi-csv",
type=Path,
default=None,
help=(
"Optional county CSV with meanDailyGlobalHorizontalRadiationKwhM2Day. Used as a representative-point fallback "
"when --solar-ghi-raster is not supplied."
),
)
parser.add_argument("--out", type=Path, default=Path("data/climate-data.csv"), help="Output CSV file path.")
return parser.parse_args()
def main() -> None:
"""Run the ETL flow and write the final CSV file."""
args = parse_args()
records = build_county_records(
counties_geojson=args.counties_geojson,
koppen_raster=args.koppen_raster,
koppen_legend=args.koppen_legend,
monthly_tavg_nc=args.monthly_tavg_nc,
monthly_prcp_nc=args.monthly_prcp_nc,
daily_tmax_nc=args.daily_tmax_nc,
daily_tmin_nc=args.daily_tmin_nc,
hot_threshold_f=args.hot_threshold_f,
freeze_threshold_f=args.freeze_threshold_f,
climatology_start_year=args.climatology_start_year,
climatology_end_year=args.climatology_end_year,
extreme_days_mode=args.extreme_days_mode,
solar_ghi_raster=args.solar_ghi_raster,
solar_ghi_csv=args.solar_ghi_csv,
)
write_csv(records=records, out_file=args.out)
print(f"Wrote {len(records)} county records to {args.out}")
if __name__ == "__main__":
main()