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

691 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Aggregate downloaded gridMET humidity/heat files to county-level metrics.
Expected input layout:
data/gridmet/<year>/sph.nc
data/gridmet/<year>/rmax.nc
data/gridmet/<year>/rmin.nc
Default output:
data/gridmet/county_gridmet_humidity.csv
"""
from __future__ import annotations
import argparse
import csv
import math
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import xarray as xr
STATE_FIPS_TO_ABBR = {
"01": "AL",
"04": "AZ",
"05": "AR",
"06": "CA",
"08": "CO",
"09": "CT",
"10": "DE",
"11": "DC",
"12": "FL",
"13": "GA",
"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",
}
CONUS_STATE_FIPS = set(STATE_FIPS_TO_ABBR)
REQUIRED_VARIABLES = ("sph", "rmax", "rmin")
SUMMER_MONTHS = {6, 7, 8}
NOAA_REGION_CODE_TO_FIPS_OVERRIDES = {
"18511": "11001",
}
NOAA_TMAX_SOURCE_FIPS_OVERRIDES = {
"46113": (
"46102",
"Shannon County, SD changed name/code to Oglala Lakota County, SD effective 2015-05-01.",
),
"51515": (
"51019",
"Bedford independent city, VA changed to town status and was added to Bedford County effective 2013-07-01.",
),
"51678": (
"51163",
"Lexington independent city, VA uses surrounding Rockbridge County, VA as NOAA tmax proxy because NOAA county daily files do not include Lexington city.",
),
}
@dataclass(frozen=True)
class CountyGridMap:
counties: gpd.GeoDataFrame
pixel_indices: np.ndarray
county_indices: np.ndarray
weights: np.ndarray
sorted_pixel_indices: np.ndarray
sorted_county_indices: np.ndarray
sorted_weights: np.ndarray
group_starts: np.ndarray
group_county_indices: np.ndarray
lat_dim: str
lon_dim: str
@dataclass
class YearData:
datasets: list[xr.Dataset]
arrays: dict[str, xr.DataArray]
def _normalize_fips(value: object, width: int = 5) -> str:
text = str(value).strip()
digits = "".join(ch for ch in text if ch.isdigit())
return digits.zfill(width)[-width:] if digits else ""
def _load_state_crosswalk(path: Path) -> dict[str, str]:
if not path.exists():
print(
f"State crosswalk not found at {path}. Falling back to the first two "
"NOAA region-code digits as state FIPS."
)
return {}
rows = list(csv.reader(path.open("r", encoding="utf-8-sig", newline="")))
if not rows:
return {}
header = [cell.strip().lower() for cell in rows[0]]
ncei_idx = next((idx for idx, cell in enumerate(header) if "ncei" in cell and "code" in cell), None)
fips_idx = next((idx for idx, cell in enumerate(header) if "fips" in cell and "code" in cell), None)
data_rows = rows[1:] if ncei_idx is not None and fips_idx is not None else rows
mapping: dict[str, str] = {}
for row in data_rows:
if ncei_idx is not None and fips_idx is not None:
if len(row) <= max(ncei_idx, fips_idx):
continue
ncei = _normalize_fips(row[ncei_idx], 2)
fips = _normalize_fips(row[fips_idx], 2)
else:
codes = [_normalize_fips(cell, 2) for cell in row]
codes = [code for code in codes if code]
if len(codes) < 2:
continue
ncei, fips = codes[0], codes[1]
if ncei and fips:
mapping[ncei] = fips
return mapping
def _county_fips_from_noaa_region(region_code: object, state_crosswalk: dict[str, str]) -> str:
code = _normalize_fips(region_code, 5)
if code in NOAA_REGION_CODE_TO_FIPS_OVERRIDES:
return NOAA_REGION_CODE_TO_FIPS_OVERRIDES[code]
state_fips = state_crosswalk.get(code[:2], code[:2])
return f"{state_fips}{code[-3:]}" if state_fips else ""
def _load_counties(path: Path) -> gpd.GeoDataFrame:
counties = gpd.read_file(path)
if counties.crs is None:
counties = counties.set_crs("EPSG:4326")
else:
counties = counties.to_crs("EPSG:4326")
if "id" in counties.columns:
fips = counties["id"]
elif "GEOID" in counties.columns:
fips = counties["GEOID"]
elif "GEOID10" in counties.columns:
fips = counties["GEOID10"]
else:
raise ValueError("County GeoJSON needs an id, GEOID, or GEOID10 column.")
counties["countyFips"] = fips.map(_normalize_fips)
counties["stateFips"] = counties["countyFips"].str.slice(0, 2)
counties = counties[counties["stateFips"].isin(CONUS_STATE_FIPS)].copy()
counties["countyName"] = counties.get("NAME", counties["countyFips"]).astype(str)
counties["state"] = counties["stateFips"].map(STATE_FIPS_TO_ABBR)
counties = counties.sort_values("countyFips").reset_index(drop=True)
counties["countyIndex"] = np.arange(len(counties), dtype=np.int32)
return counties
def _find_dimension(dataset: xr.Dataset, candidates: tuple[str, ...]) -> str:
for candidate in candidates:
if candidate in dataset.dims or candidate in dataset.coords:
return candidate
lower_lookup = {name.lower(): name for name in set(dataset.dims) | set(dataset.coords)}
for candidate in candidates:
if candidate.lower() in lower_lookup:
return lower_lookup[candidate.lower()]
raise ValueError(f"Unable to find one of these dimensions: {', '.join(candidates)}")
def _select_variable(dataset: xr.Dataset, preferred: str) -> str:
if preferred in dataset.data_vars:
return preferred
for name in dataset.data_vars:
if name.lower() == preferred.lower():
return name
if len(dataset.data_vars) == 1:
return next(iter(dataset.data_vars))
raise ValueError(f"Unable to select variable {preferred}; found {list(dataset.data_vars)}")
def _year_folder(path: Path) -> int | None:
try:
return int(path.name)
except ValueError:
return None
def _discover_complete_years(gridmet_dir: Path) -> list[int]:
years: list[int] = []
for child in sorted(gridmet_dir.iterdir()):
if not child.is_dir():
continue
year = _year_folder(child)
if year is None:
continue
if all((child / f"{variable}.nc").exists() for variable in REQUIRED_VARIABLES):
years.append(year)
return years
def _load_noaa_tmax_month(
noaa_tmax_dir: Path,
year: int,
month: int,
state_crosswalk: dict[str, str],
) -> dict[str, list[float]]:
path = noaa_tmax_dir / str(year) / f"tmax-{year}{month:02d}-cty-scaled.csv"
if not path.exists():
return {}
values: dict[str, list[float]] = {}
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
for row in reader:
if len(row) < 7 or row[0].strip().lower() != "cty":
continue
county_fips = _county_fips_from_noaa_region(row[1], state_crosswalk)
day_values: list[float] = []
for raw_value in row[6:]:
try:
value = float(raw_value)
except ValueError:
day_values.append(math.nan)
continue
day_values.append(value if value > -999 else math.nan)
values[county_fips] = day_values
return values
def _noaa_tmax_for_date(
*,
date: pd.Timestamp,
county_fips: Sequence[str],
noaa_tmax_dir: Path,
state_crosswalk: dict[str, str],
month_cache: dict[tuple[int, int], dict[str, list[float]]],
) -> np.ndarray:
cache_key = (int(date.year), int(date.month))
if cache_key not in month_cache:
month_cache[cache_key] = _load_noaa_tmax_month(
noaa_tmax_dir,
int(date.year),
int(date.month),
state_crosswalk,
)
month_values = month_cache[cache_key]
result = np.full(len(county_fips), np.nan, dtype=np.float64)
day_offset = int(date.day) - 1
for index, fips in enumerate(county_fips):
source_fips = NOAA_TMAX_SOURCE_FIPS_OVERRIDES.get(fips, (fips, ""))[0]
values = month_values.get(source_fips)
if values is not None and day_offset < len(values):
result[index] = values[day_offset]
return result
def _build_county_grid_map(counties: gpd.GeoDataFrame, sample_file: Path) -> CountyGridMap:
with xr.open_dataset(sample_file) as dataset:
lat_dim = _find_dimension(dataset, ("lat", "latitude", "y"))
lon_dim = _find_dimension(dataset, ("lon", "longitude", "x"))
lat_values = np.asarray(dataset[lat_dim].values)
lon_values = np.asarray(dataset[lon_dim].values)
if lat_values.ndim != 1 or lon_values.ndim != 1:
raise ValueError("This script expects 1D latitude and longitude coordinates.")
lon_grid, lat_grid = np.meshgrid(lon_values, lat_values)
flat_lats = lat_grid.ravel()
flat_lons = lon_grid.ravel()
flat_indices = np.arange(flat_lats.size, dtype=np.int64)
bounds = counties.total_bounds
in_bounds = (
(flat_lons >= bounds[0] - 0.25)
& (flat_lats >= bounds[1] - 0.25)
& (flat_lons <= bounds[2] + 0.25)
& (flat_lats <= bounds[3] + 0.25)
)
point_frame = gpd.GeoDataFrame(
{
"pixelIndex": flat_indices[in_bounds],
"lat": flat_lats[in_bounds],
},
geometry=gpd.points_from_xy(flat_lons[in_bounds], flat_lats[in_bounds]),
crs="EPSG:4326",
)
joined = gpd.sjoin(
point_frame,
counties[["countyIndex", "geometry"]],
how="inner",
predicate="within",
)
joined = joined.drop_duplicates(subset=["pixelIndex", "countyIndex"])
pixel_indices = joined["pixelIndex"].to_numpy(dtype=np.int64)
county_indices = joined["countyIndex"].to_numpy(dtype=np.int32)
weights = np.cos(np.deg2rad(joined["lat"].to_numpy(dtype=np.float64)))
mapped_counties = set(county_indices.tolist())
fallback_pixels: list[int] = []
fallback_counties: list[int] = []
fallback_weights: list[float] = []
for county_index, geometry in enumerate(counties.geometry):
if county_index in mapped_counties or geometry.is_empty:
continue
point = geometry.representative_point()
lon_position = int(np.argmin(np.abs(lon_values - point.x)))
lat_position = int(np.argmin(np.abs(lat_values - point.y)))
flat_index = lat_position * len(lon_values) + lon_position
fallback_pixels.append(flat_index)
fallback_counties.append(county_index)
fallback_weights.append(float(math.cos(math.radians(float(lat_values[lat_position])))))
if fallback_pixels:
pixel_indices = np.concatenate([pixel_indices, np.asarray(fallback_pixels, dtype=np.int64)])
county_indices = np.concatenate([county_indices, np.asarray(fallback_counties, dtype=np.int32)])
weights = np.concatenate([weights, np.asarray(fallback_weights, dtype=np.float64)])
print(
f"mapped {len(np.unique(county_indices)):,} counties to {len(pixel_indices):,} grid cells "
f"({len(fallback_pixels):,} nearest-cell fallback counties)"
)
sort_order = np.argsort(county_indices, kind="stable")
sorted_county_indices = county_indices[sort_order]
group_starts = np.r_[0, np.flatnonzero(np.diff(sorted_county_indices)) + 1]
group_county_indices = sorted_county_indices[group_starts]
return CountyGridMap(
counties=counties,
pixel_indices=pixel_indices,
county_indices=county_indices,
weights=weights,
sorted_pixel_indices=pixel_indices[sort_order],
sorted_county_indices=sorted_county_indices,
sorted_weights=weights[sort_order],
group_starts=group_starts,
group_county_indices=group_county_indices,
lat_dim=lat_dim,
lon_dim=lon_dim,
)
def _county_means(
data_array: xr.DataArray,
time_index: int,
time_dim: str,
county_map: CountyGridMap,
) -> np.ndarray:
data = data_array.isel({time_dim: time_index}).transpose(county_map.lat_dim, county_map.lon_dim)
flat = np.asarray(data.values, dtype=np.float64).ravel()
values = flat[county_map.pixel_indices]
valid = np.isfinite(values)
county_count = len(county_map.counties)
sums = np.bincount(
county_map.county_indices[valid],
weights=values[valid] * county_map.weights[valid],
minlength=county_count,
)
weight_sums = np.bincount(
county_map.county_indices[valid],
weights=county_map.weights[valid],
minlength=county_count,
)
means = np.full(county_count, np.nan, dtype=np.float64)
np.divide(sums, weight_sums, out=means, where=weight_sums > 0)
return means
def _county_means_chunk(
data_array: xr.DataArray,
start_index: int,
end_index: int,
time_dim: str,
county_map: CountyGridMap,
) -> np.ndarray:
data = data_array.isel({time_dim: slice(start_index, end_index)}).transpose(
time_dim,
county_map.lat_dim,
county_map.lon_dim,
)
raw = np.asarray(data.values, dtype=np.float64)
selected = raw.reshape(raw.shape[0], -1)[:, county_map.sorted_pixel_indices]
valid = np.isfinite(selected)
weighted_values = np.where(valid, selected * county_map.sorted_weights, 0.0)
weight_values = np.where(valid, county_map.sorted_weights, 0.0)
sums = np.add.reduceat(weighted_values, county_map.group_starts, axis=1)
weight_sums = np.add.reduceat(weight_values, county_map.group_starts, axis=1)
grouped_means = np.full_like(sums, np.nan, dtype=np.float64)
np.divide(sums, weight_sums, out=grouped_means, where=weight_sums > 0)
means = np.full((raw.shape[0], len(county_map.counties)), np.nan, dtype=np.float64)
means[:, county_map.group_county_indices] = grouped_means
return means
def _heat_index_f(t_f: np.ndarray, rh_pct: np.ndarray) -> np.ndarray:
rh = np.clip(rh_pct, 0, 100)
heat_index = (
-42.379
+ 2.04901523 * t_f
+ 10.14333127 * rh
- 0.22475541 * t_f * rh
- 0.00683783 * t_f * t_f
- 0.05481717 * rh * rh
+ 0.00122874 * t_f * t_f * rh
+ 0.00085282 * t_f * rh * rh
- 0.00000199 * t_f * t_f * rh * rh
)
low_rh_adjustment = ((13 - rh) / 4) * np.sqrt(np.maximum((17 - np.abs(t_f - 95)) / 17, 0))
high_rh_adjustment = ((rh - 85) / 10) * ((87 - t_f) / 5)
heat_index = np.where((rh < 13) & (80 <= t_f) & (t_f <= 112), heat_index - low_rh_adjustment, heat_index)
heat_index = np.where((rh > 85) & (80 <= t_f) & (t_f <= 87), heat_index + high_rh_adjustment, heat_index)
return np.where(t_f >= 80, heat_index, t_f)
def _time_dimension(data_array: xr.DataArray, lat_dim: str, lon_dim: str) -> str:
candidates = [dim for dim in data_array.dims if dim not in {lat_dim, lon_dim}]
if not candidates:
raise ValueError(f"No time dimension found for {data_array.name}.")
return candidates[0]
def _open_year_datasets(gridmet_dir: Path, year: int) -> YearData:
datasets: list[xr.Dataset] = []
arrays: dict[str, xr.DataArray] = {}
for variable in REQUIRED_VARIABLES:
dataset = xr.open_dataset(gridmet_dir / str(year) / f"{variable}.nc")
datasets.append(dataset)
variable_name = _select_variable(dataset, variable)
arrays[variable] = dataset[variable_name]
return YearData(datasets=datasets, arrays=arrays)
def _close_year_datasets(year_data: YearData) -> None:
for dataset in year_data.datasets:
try:
dataset.close()
except Exception:
pass
def _round_or_blank(value: float, digits: int) -> str:
if not np.isfinite(value):
return ""
return str(round(float(value), digits))
def summarize(args: argparse.Namespace) -> None:
years = args.years or _discover_complete_years(args.gridmet_dir)
if not years:
raise FileNotFoundError(
f"No complete gridMET years found in {args.gridmet_dir}. "
"Run scripts/download_gridmet_data.py first."
)
if args.years is None:
start = args.start_year if args.start_year is not None else 1991
end = args.end_year if args.end_year is not None else 2020
years = [year for year in years if start <= year <= end]
elif args.start_year is not None or args.end_year is not None:
start = args.start_year if args.start_year is not None else min(years)
end = args.end_year if args.end_year is not None else max(years)
years = [year for year in years if start <= year <= end]
if not years:
raise FileNotFoundError("No complete gridMET years matched the requested year filters.")
sample_file = args.gridmet_dir / str(years[0]) / "sph.nc"
counties = _load_counties(args.counties_geojson)
county_map = _build_county_grid_map(counties, sample_file)
county_count = len(county_map.counties)
county_fips = county_map.counties["countyFips"].astype(str).tolist()
state_crosswalk = _load_state_crosswalk(args.state_crosswalk)
noaa_month_cache: dict[tuple[int, int], dict[str, list[float]]] = {}
annual_sph_sum = np.zeros(county_count, dtype=np.float64)
annual_sph_count = np.zeros(county_count, dtype=np.float64)
summer_sph_sum = np.zeros(county_count, dtype=np.float64)
summer_sph_count = np.zeros(county_count, dtype=np.float64)
annual_rh_sum = np.zeros(county_count, dtype=np.float64)
annual_rh_count = np.zeros(county_count, dtype=np.float64)
summer_rh_sum = np.zeros(county_count, dtype=np.float64)
summer_rh_count = np.zeros(county_count, dtype=np.float64)
heat_day_sum = np.zeros(county_count, dtype=np.float64)
years_with_heat_data = np.zeros(county_count, dtype=np.float64)
for year in years:
print(f"process {year}")
year_data = _open_year_datasets(args.gridmet_dir, year)
arrays = year_data.arrays
try:
time_dim = _time_dimension(arrays["sph"], county_map.lat_dim, county_map.lon_dim)
dates = pd.to_datetime(arrays["sph"][time_dim].values)
yearly_heat_days = np.zeros(county_count, dtype=np.float64)
yearly_heat_valid_days = np.zeros(county_count, dtype=np.float64)
for chunk_start in range(0, len(dates), args.chunk_days):
chunk_end = min(chunk_start + args.chunk_days, len(dates))
chunk_dates = dates[chunk_start:chunk_end]
sph_chunk = _county_means_chunk(arrays["sph"], chunk_start, chunk_end, time_dim, county_map) * 1000
rmax_chunk = _county_means_chunk(arrays["rmax"], chunk_start, chunk_end, time_dim, county_map)
rmin_chunk = _county_means_chunk(arrays["rmin"], chunk_start, chunk_end, time_dim, county_map)
for offset, date in enumerate(chunk_dates):
sph = sph_chunk[offset]
rmax = rmax_chunk[offset]
rmin = rmin_chunk[offset]
tmax_f = _noaa_tmax_for_date(
date=date,
county_fips=county_fips,
noaa_tmax_dir=args.noaa_tmax_dir,
state_crosswalk=state_crosswalk,
month_cache=noaa_month_cache,
) * 9 / 5 + 32
rh_mean = (rmax + rmin) / 2
annual_sph_valid = np.isfinite(sph)
annual_rh_valid = np.isfinite(rh_mean)
annual_sph_sum[annual_sph_valid] += sph[annual_sph_valid]
annual_sph_count[annual_sph_valid] += 1
annual_rh_sum[annual_rh_valid] += rh_mean[annual_rh_valid]
annual_rh_count[annual_rh_valid] += 1
if int(date.month) in SUMMER_MONTHS:
summer_sph_sum[annual_sph_valid] += sph[annual_sph_valid]
summer_sph_count[annual_sph_valid] += 1
summer_rh_sum[annual_rh_valid] += rh_mean[annual_rh_valid]
summer_rh_count[annual_rh_valid] += 1
heat_valid = np.isfinite(tmax_f) & np.isfinite(rmin)
heat_index = _heat_index_f(tmax_f, rmin)
yearly_heat_days[heat_valid] += heat_index[heat_valid] >= args.heat_index_threshold_f
yearly_heat_valid_days[heat_valid] += 1
has_heat_year = yearly_heat_valid_days > 0
heat_day_sum[has_heat_year] += yearly_heat_days[has_heat_year]
years_with_heat_data[has_heat_year] += 1
finally:
_close_year_datasets(year_data)
avg_sph = np.divide(annual_sph_sum, annual_sph_count, out=np.full(county_count, np.nan), where=annual_sph_count > 0)
avg_summer_sph = np.divide(
summer_sph_sum,
summer_sph_count,
out=np.full(county_count, np.nan),
where=summer_sph_count > 0,
)
avg_rh = np.divide(annual_rh_sum, annual_rh_count, out=np.full(county_count, np.nan), where=annual_rh_count > 0)
avg_summer_rh = np.divide(
summer_rh_sum,
summer_rh_count,
out=np.full(county_count, np.nan),
where=summer_rh_count > 0,
)
humid_heat_days = np.divide(
heat_day_sum,
years_with_heat_data,
out=np.full(county_count, np.nan),
where=years_with_heat_data > 0,
)
source_period = f"{min(years)}-{max(years)}" if len(years) > 1 else str(years[0])
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle,
fieldnames=[
"countyFips",
"countyName",
"state",
"avgSpecificHumidityGKg",
"avgSummerSpecificHumidityGKg",
"avgRelHumidityPct",
"avgSummerRelHumidityPct",
"humidHeatDays",
"humidHeatSourceFips",
"humidHeatFipsAdjustment",
"gridmetAnalysisYears",
"gridmetSource",
],
)
writer.writeheader()
for index, county in county_map.counties.iterrows():
writer.writerow(
{
"countyFips": county["countyFips"],
"countyName": county["countyName"],
"state": county["state"],
"avgSpecificHumidityGKg": _round_or_blank(avg_sph[index], 3),
"avgSummerSpecificHumidityGKg": _round_or_blank(avg_summer_sph[index], 3),
"avgRelHumidityPct": _round_or_blank(avg_rh[index], 2),
"avgSummerRelHumidityPct": _round_or_blank(avg_summer_rh[index], 2),
"humidHeatDays": _round_or_blank(humid_heat_days[index], 2),
"humidHeatSourceFips": NOAA_TMAX_SOURCE_FIPS_OVERRIDES.get(
county["countyFips"],
(county["countyFips"], ""),
)[0],
"humidHeatFipsAdjustment": NOAA_TMAX_SOURCE_FIPS_OVERRIDES.get(
county["countyFips"],
("", ""),
)[1],
"gridmetAnalysisYears": len(years),
"gridmetSource": (
f"gridmet-{source_period} county-cell-weighted; "
"relative-humidity=(rmax+rmin)/2; "
"humidHeatDays=noaa-nclimgrid-tmax+gridmet-rmin heat-index-gte-90f"
),
}
)
print(f"wrote {args.output}")
def _parse_years(text: str) -> list[int]:
years: set[int] = set()
for part in text.split(","):
token = part.strip()
if not token:
continue
if "-" in token:
start_text, end_text = token.split("-", 1)
years.update(range(int(start_text), int(end_text) + 1))
else:
years.add(int(token))
return sorted(years)
def main() -> int:
parser = argparse.ArgumentParser(
description="Summarize downloaded gridMET humidity files into county-level CSV metrics."
)
parser.add_argument("--gridmet-dir", type=Path, default=Path("data/gridmet"))
parser.add_argument("--noaa-tmax-dir", type=Path, default=Path("data/noaa/tmax_cty_scaled"))
parser.add_argument("--state-crosswalk", type=Path, default=Path("data/noaa/nclimgrid/us-state-codes_ncei-to-fips.csv"))
parser.add_argument("--counties-geojson", type=Path, default=Path("data/geojson-counties-fips.json"))
parser.add_argument("--output", type=Path, default=Path("data/gridmet/county_gridmet_humidity.csv"))
parser.add_argument("--years", type=_parse_years, default=None, help="Optional comma/range years, e.g. 1991-2020.")
parser.add_argument("--start-year", type=int, default=None)
parser.add_argument("--end-year", type=int, default=None)
parser.add_argument("--heat-index-threshold-f", type=float, default=90.0)
parser.add_argument("--chunk-days", type=int, default=31)
summarize(parser.parse_args())
return 0
if __name__ == "__main__":
raise SystemExit(main())