Files
Climate-Mood-Analysis/scripts/build_county_climate_data.py
T
2026-06-11 14:50:33 -04:00

862 lines
30 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: majority Koppen-Geiger class
- 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
- avgSolarGhiKwhM2Day: annual average daily global horizontal irradiance (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
import json
from pathlib import Path
from typing import Dict, List, Tuple
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import geometry_mask
import xarray as xr
DEFAULT_COUNTIES_GEOJSON_URL = "https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json"
STATE_FIPS_TO_ABBR = {
"01": "AL",
"02": "AK",
"04": "AZ",
"05": "AR",
"06": "CA",
"08": "CO",
"09": "CT",
"10": "DE",
"11": "DC",
"12": "FL",
"13": "GA",
"15": "HI",
"16": "ID",
"17": "IL",
"18": "IN",
"19": "IA",
"20": "KS",
"21": "KY",
"22": "LA",
"23": "ME",
"24": "MD",
"25": "MA",
"26": "MI",
"27": "MN",
"28": "MS",
"29": "MO",
"30": "MT",
"31": "NE",
"32": "NV",
"33": "NH",
"34": "NJ",
"35": "NM",
"36": "NY",
"37": "NC",
"38": "ND",
"39": "OH",
"40": "OK",
"41": "OR",
"42": "PA",
"44": "RI",
"45": "SC",
"46": "SD",
"47": "TN",
"48": "TX",
"49": "UT",
"50": "VT",
"51": "VA",
"53": "WA",
"54": "WV",
"55": "WI",
"56": "WY",
"60": "AS",
"66": "GU",
"69": "MP",
"72": "PR",
"78": "VI",
}
# Beck et al legend key is expected as text file, but this default handles common codes.
DEFAULT_KOPPEN_CODE_MAP = {
1: "Af",
2: "Am",
3: "Aw",
4: "BWh",
5: "BWk",
6: "BSh",
7: "BSk",
8: "Csa",
9: "Csb",
10: "Csc",
11: "Cwa",
12: "Cwb",
13: "Cwc",
14: "Cfa",
15: "Cfb",
16: "Cfc",
17: "Dsa",
18: "Dsb",
19: "Dsc",
20: "Dsd",
21: "Dwa",
22: "Dwb",
23: "Dwc",
24: "Dwd",
25: "Dfa",
26: "Dfb",
27: "Dfc",
28: "Dfd",
29: "ET",
30: "EF",
}
MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
def _normalize_fips(value: object, width: int) -> str:
"""Return a zero-padded FIPS code with the requested width."""
text = str(value).strip()
digits = "".join(ch for ch in text if ch.isdigit())
if not digits:
return ""
return digits.zfill(width)[-width:]
def _load_counties(counties_geojson: Path) -> gpd.GeoDataFrame:
"""Load county polygons and normalize fields used downstream."""
if not counties_geojson.exists():
try:
print(
f"County GeoJSON not found at {counties_geojson}. "
f"Attempting download from {DEFAULT_COUNTIES_GEOJSON_URL}..."
)
gdf = gpd.read_file(DEFAULT_COUNTIES_GEOJSON_URL)
counties_geojson.parent.mkdir(parents=True, exist_ok=True)
# Cache the downloaded file for subsequent runs.
gdf.to_file(counties_geojson, driver="GeoJSON")
print(f"Downloaded and cached county GeoJSON to {counties_geojson}")
except Exception as exc:
raise FileNotFoundError(
f"County GeoJSON not found at {counties_geojson}, and download from "
f"{DEFAULT_COUNTIES_GEOJSON_URL} failed. Download the file manually "
"and rerun with --counties-geojson pointing to it."
) from exc
gdf = gpd.read_file(counties_geojson)
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:4326")
else:
gdf = gdf.to_crs("EPSG:4326")
feature_id = None
if "id" in gdf.columns:
feature_id = gdf["id"]
elif "GEOID" in gdf.columns:
feature_id = gdf["GEOID"]
elif "GEOID10" in gdf.columns:
feature_id = gdf["GEOID10"]
elif "fips" in gdf.columns:
feature_id = gdf["fips"]
else:
raise ValueError("Unable to locate county FIPS identifier column in county polygons.")
gdf["county_fips"] = feature_id.map(lambda value: _normalize_fips(value, 5))
gdf = gdf[gdf["county_fips"] != ""].copy()
if "NAME" in gdf.columns:
gdf["county_name"] = gdf["NAME"].fillna("").astype(str).str.strip()
elif "name" in gdf.columns:
gdf["county_name"] = gdf["name"].fillna("").astype(str).str.strip()
else:
gdf["county_name"] = gdf["county_fips"].map(lambda value: f"County {value}")
gdf["state_fips"] = gdf["county_fips"].str.slice(0, 2)
gdf["state"] = gdf["state_fips"].map(lambda code: STATE_FIPS_TO_ABBR.get(code, f"S{code}"))
gdf = gdf.sort_values("county_fips").reset_index(drop=True)
return gdf
def _load_koppen_legend(legend_path: Path | None) -> Dict[int, str]:
"""Load Koppen raster codes, using defaults when no legend exists."""
if legend_path is None:
return DEFAULT_KOPPEN_CODE_MAP
mapping: Dict[int, str] = {}
for line in legend_path.read_text(encoding="utf-8").splitlines():
text = line.strip()
if not text or text.startswith("#"):
continue
# Handles patterns like:
# "1: Af ..." or "1 = Af" or "1 Af"
import re
match = re.match(r"^(\d+)\s*[:=]?\s*([A-Za-z]{2,3})\b", text)
if not match:
continue
key = int(match.group(1))
value = match.group(2)
mapping[key] = value
return mapping if mapping else DEFAULT_KOPPEN_CODE_MAP
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 average 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 "avgSolarGhiKwhM2Day" not in reader.fieldnames:
raise ValueError(
f"Solar GHI CSV at {solar_ghi_csv} must include avgSolarGhiKwhM2Day."
)
solar_by_fips: Dict[str, float] = {}
for row in reader:
county_fips = _normalize_fips(row.get(fips_field, ""), 5)
raw_value = str(row.get("avgSolarGhiKwhM2Day", "")).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 avgSolarGhiKwhM2Day 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])
from affine import Affine
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 _zonal_majority_class(koppen_raster: Path, counties: gpd.GeoDataFrame, code_map: Dict[int, str]) -> List[str]:
"""Assign each county its most common Koppen-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)
data = source.read(1, masked=True)
values = np.asarray(data.filled(0))
if source.nodata is not None:
values = np.where(values == source.nodata, 0, values)
for geometry in raster_counties.geometry:
mask = geometry_mask(
[geometry.__geo_interface__],
out_shape=values.shape,
transform=source.transform,
invert=True,
all_touched=True,
)
selected = values[mask]
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)):
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)):
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 avgSolarGhiKwhM2Day blank.")
if solar_ghi_csv is not None:
print(f"Solar GHI CSV not found at {solar_ghi_csv}; leaving avgSolarGhiKwhM2Day 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
)
avg_solar_ghi_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 avg_solar_ghi_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,
"avgSolarGhiKwhM2Day": avg_solar_ghi_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",
"avgSolarGhiKwhM2Day",
"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="Koppen-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 annual average daily GHI in kWh/m2/day for avgSolarGhiKwhM2Day.",
)
parser.add_argument(
"--solar-ghi-csv",
type=Path,
default=None,
help=(
"Optional county CSV with avgSolarGhiKwhM2Day. 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()