691 lines
25 KiB
Python
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())
|