192 lines
6.3 KiB
Python
192 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Build county-level average diurnal temperature range from NOAA nClimGrid-Daily.
|
|
|
|
The output is an ETL artifact used to keep climate-data.csv lean:
|
|
|
|
data/noaa/county_diurnal_temperature_range.csv
|
|
|
|
Metric definition:
|
|
- avgDiurnalTempRangeF: mean daily county Tmax - Tmin, expressed as a
|
|
Fahrenheit temperature difference, over the selected analysis period.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from build_county_locally_extreme_data import (
|
|
DEFAULT_STATE_CROSSWALK,
|
|
DEFAULT_TMAX_DIR,
|
|
DEFAULT_TMIN_DIR,
|
|
load_state_crosswalk,
|
|
require_monthly_file,
|
|
rows_by_fips,
|
|
)
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_OUT = REPO_ROOT / "data" / "noaa" / "county_diurnal_temperature_range.csv"
|
|
SOURCE_TAG = "noaa-nclimgrid-daily-county-area-averages-scaled"
|
|
|
|
|
|
@dataclass
|
|
class DiurnalRangeTotals:
|
|
"""Running total for one county's daily Tmax-Tmin differences."""
|
|
|
|
noaa_region_code: str = ""
|
|
county_name: str = ""
|
|
total_range_c: float = 0.0
|
|
valid_days: int = 0
|
|
skipped_negative_days: int = 0
|
|
|
|
|
|
def c_delta_to_f_delta(value_c: float) -> float:
|
|
"""Convert a Celsius temperature difference to a Fahrenheit difference."""
|
|
return value_c * 9.0 / 5.0
|
|
|
|
|
|
def build_diurnal_temperature_range(
|
|
*,
|
|
start_year: int,
|
|
end_year: int,
|
|
tmax_dir: Path,
|
|
tmin_dir: Path,
|
|
state_crosswalk_path: Path,
|
|
) -> dict[str, DiurnalRangeTotals]:
|
|
"""Average daily county Tmax-Tmin over the requested analysis years."""
|
|
state_crosswalk = load_state_crosswalk(state_crosswalk_path)
|
|
totals: dict[str, DiurnalRangeTotals] = {}
|
|
|
|
for year in range(start_year, end_year + 1):
|
|
print(f"Reading daily Tmax/Tmin for {year}...")
|
|
for month in range(1, 13):
|
|
tmax_path = require_monthly_file(tmax_dir, "tmax", year, month)
|
|
tmin_path = require_monthly_file(tmin_dir, "tmin", year, month)
|
|
tmax_rows = rows_by_fips(
|
|
tmax_path,
|
|
year=year,
|
|
month=month,
|
|
state_crosswalk=state_crosswalk,
|
|
)
|
|
tmin_rows = rows_by_fips(
|
|
tmin_path,
|
|
year=year,
|
|
month=month,
|
|
state_crosswalk=state_crosswalk,
|
|
)
|
|
|
|
for county_fips in sorted(set(tmax_rows) & set(tmin_rows)):
|
|
tmax_record = tmax_rows[county_fips]
|
|
tmin_record = tmin_rows[county_fips]
|
|
county_totals = totals.setdefault(
|
|
county_fips,
|
|
DiurnalRangeTotals(
|
|
noaa_region_code=tmax_record.region_code,
|
|
county_name=tmax_record.county_name,
|
|
),
|
|
)
|
|
|
|
for tmax_value, tmin_value in zip(tmax_record.values, tmin_record.values):
|
|
if tmax_value is None or tmin_value is None:
|
|
continue
|
|
daily_range_c = tmax_value - tmin_value
|
|
if daily_range_c < 0:
|
|
county_totals.skipped_negative_days += 1
|
|
continue
|
|
county_totals.total_range_c += daily_range_c
|
|
county_totals.valid_days += 1
|
|
|
|
return totals
|
|
|
|
|
|
def write_diurnal_temperature_range_csv(
|
|
*,
|
|
path: Path,
|
|
totals: dict[str, DiurnalRangeTotals],
|
|
start_year: int,
|
|
end_year: int,
|
|
) -> None:
|
|
"""Write county DTR summary rows."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fieldnames = [
|
|
"countyFips",
|
|
"noaaRegionCode",
|
|
"countyName",
|
|
"avgDiurnalTempRangeC",
|
|
"avgDiurnalTempRangeF",
|
|
"validDays",
|
|
"skippedNegativeDays",
|
|
"analysisStartYear",
|
|
"analysisEndYear",
|
|
"source",
|
|
]
|
|
|
|
with path.open("w", encoding="utf-8", newline="") as csv_file:
|
|
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for county_fips in sorted(totals):
|
|
county_totals = totals[county_fips]
|
|
avg_range_c = (
|
|
county_totals.total_range_c / county_totals.valid_days
|
|
if county_totals.valid_days
|
|
else None
|
|
)
|
|
writer.writerow(
|
|
{
|
|
"countyFips": county_fips,
|
|
"noaaRegionCode": county_totals.noaa_region_code,
|
|
"countyName": county_totals.county_name,
|
|
"avgDiurnalTempRangeC": f"{avg_range_c:.2f}" if avg_range_c is not None else "",
|
|
"avgDiurnalTempRangeF": (
|
|
f"{c_delta_to_f_delta(avg_range_c):.2f}" if avg_range_c is not None else ""
|
|
),
|
|
"validDays": county_totals.valid_days,
|
|
"skippedNegativeDays": county_totals.skipped_negative_days,
|
|
"analysisStartYear": start_year,
|
|
"analysisEndYear": end_year,
|
|
"source": SOURCE_TAG,
|
|
}
|
|
)
|
|
|
|
print(f"Wrote {len(totals)} county DTR rows to {path}")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
"""Parse command-line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Build county average diurnal temperature range from cached NOAA Tmax/Tmin files."
|
|
)
|
|
parser.add_argument("--start-year", type=int, default=1991)
|
|
parser.add_argument("--end-year", type=int, default=2020)
|
|
parser.add_argument("--tmax-dir", type=Path, default=DEFAULT_TMAX_DIR)
|
|
parser.add_argument("--tmin-dir", type=Path, default=DEFAULT_TMIN_DIR)
|
|
parser.add_argument("--state-crosswalk", type=Path, default=DEFAULT_STATE_CROSSWALK)
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the DTR ETL."""
|
|
args = parse_args()
|
|
totals = build_diurnal_temperature_range(
|
|
start_year=args.start_year,
|
|
end_year=args.end_year,
|
|
tmax_dir=args.tmax_dir,
|
|
tmin_dir=args.tmin_dir,
|
|
state_crosswalk_path=args.state_crosswalk,
|
|
)
|
|
write_diurnal_temperature_range_csv(
|
|
path=args.out,
|
|
totals=totals,
|
|
start_year=args.start_year,
|
|
end_year=args.end_year,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|