160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Apply county diurnal temperature range to the app CSV.
|
|
|
|
This joins only the final app-facing metric into climate-data.csv. Detailed ETL
|
|
fields stay in data/noaa/county_diurnal_temperature_range.csv.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from pathlib import Path
|
|
|
|
from build_county_locally_extreme_data import OLD_APP_FIPS_TO_CURRENT_FIPS
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_CLIMATE_DATA = REPO_ROOT / "data" / "climate-data.csv"
|
|
DEFAULT_DTR_CSV = REPO_ROOT / "data" / "noaa" / "county_diurnal_temperature_range.csv"
|
|
METRIC_FIELD = "avgDiurnalTempRangeF"
|
|
SOURCE_TAG_PREFIX = "diurnalTempRange="
|
|
SOURCE_TAG = "diurnalTempRange=noaa-nclimgrid-daily-1991-2020"
|
|
MISSING_SOURCE_TAG = "missing-noaa-diurnal-temperature-range"
|
|
|
|
|
|
def normalize_fips(value: object) -> str:
|
|
"""Return a 5-digit county FIPS string from mixed text or numeric input."""
|
|
digits = "".join(ch for ch in str(value).strip() if ch.isdigit())
|
|
return digits.zfill(5)[-5:] if digits else ""
|
|
|
|
|
|
def read_csv_rows(path: Path) -> tuple[list[str], list[dict]]:
|
|
"""Read a CSV while preserving the source field order."""
|
|
with path.open("r", encoding="utf-8-sig", newline="") as csv_file:
|
|
reader = csv.DictReader(csv_file)
|
|
if reader.fieldnames is None:
|
|
raise ValueError(f"{path} has no CSV header.")
|
|
return list(reader.fieldnames), list(reader)
|
|
|
|
|
|
def load_dtr_lookup(path: Path) -> dict[str, dict]:
|
|
"""Load DTR rows keyed by current county FIPS plus supported old app FIPS."""
|
|
fieldnames, rows = read_csv_rows(path)
|
|
required_fields = {"countyFips", METRIC_FIELD}
|
|
missing_fields = required_fields - set(fieldnames)
|
|
if missing_fields:
|
|
raise ValueError(f"{path} is missing required fields: {sorted(missing_fields)}")
|
|
|
|
current_lookup: dict[str, dict] = {}
|
|
for row in rows:
|
|
county_fips = normalize_fips(row.get("countyFips", ""))
|
|
metric_value = (row.get(METRIC_FIELD) or "").strip()
|
|
if not county_fips or not metric_value:
|
|
continue
|
|
float(metric_value)
|
|
current_lookup[county_fips] = row
|
|
|
|
lookup = dict(current_lookup)
|
|
for old_fips, (current_fips, reason) in OLD_APP_FIPS_TO_CURRENT_FIPS.items():
|
|
current_row = current_lookup.get(normalize_fips(current_fips))
|
|
if not current_row:
|
|
continue
|
|
proxy_row = dict(current_row)
|
|
proxy_row["fipsAdjustment"] = reason
|
|
lookup[normalize_fips(old_fips)] = proxy_row
|
|
|
|
return lookup
|
|
|
|
|
|
def fieldnames_with_metric(original_fields: list[str]) -> list[str]:
|
|
"""Insert the DTR metric near the other temperature field."""
|
|
fields = [field for field in original_fields if field != METRIC_FIELD]
|
|
if "avgTempF" in fields:
|
|
insert_at = fields.index("avgTempF") + 1
|
|
else:
|
|
insert_at = len(fields)
|
|
fields.insert(insert_at, METRIC_FIELD)
|
|
return fields
|
|
|
|
|
|
def replace_dtr_source_tag(source: str, has_value: bool) -> str:
|
|
"""Replace previous DTR provenance with the active one."""
|
|
parts = [
|
|
part.strip()
|
|
for part in (source.strip() or "unknown-source").split("+")
|
|
if part.strip()
|
|
]
|
|
kept_parts = [
|
|
part
|
|
for part in parts
|
|
if not part.startswith(SOURCE_TAG_PREFIX) and part != MISSING_SOURCE_TAG
|
|
]
|
|
kept_parts.append(SOURCE_TAG if has_value else MISSING_SOURCE_TAG)
|
|
return " + ".join(kept_parts)
|
|
|
|
|
|
def apply_diurnal_temperature_range(
|
|
*,
|
|
climate_data: Path,
|
|
dtr_csv: Path,
|
|
out: Path,
|
|
) -> None:
|
|
"""Join DTR values into climate-data.csv."""
|
|
original_fields, climate_rows = read_csv_rows(climate_data)
|
|
dtr_lookup = load_dtr_lookup(dtr_csv)
|
|
fieldnames = fieldnames_with_metric(original_fields)
|
|
|
|
updated_count = 0
|
|
missing_count = 0
|
|
adjusted_count = 0
|
|
for row in climate_rows:
|
|
county_fips = normalize_fips(row.get("countyFips", ""))
|
|
dtr_row = dtr_lookup.get(county_fips)
|
|
if dtr_row:
|
|
row[METRIC_FIELD] = dtr_row.get(METRIC_FIELD, "")
|
|
row["source"] = replace_dtr_source_tag(row.get("source", ""), has_value=True)
|
|
updated_count += 1
|
|
if dtr_row.get("fipsAdjustment"):
|
|
adjusted_count += 1
|
|
else:
|
|
row[METRIC_FIELD] = ""
|
|
row["source"] = replace_dtr_source_tag(row.get("source", ""), has_value=False)
|
|
missing_count += 1
|
|
|
|
with out.open("w", encoding="utf-8", newline="") as csv_file:
|
|
writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore")
|
|
writer.writeheader()
|
|
writer.writerows(climate_rows)
|
|
|
|
print(f"Wrote {len(climate_rows)} rows to {out}")
|
|
print(f"Updated {METRIC_FIELD} for {updated_count} rows.")
|
|
print(f"Used supported county-vintage FIPS adjustments for {adjusted_count} rows.")
|
|
print(f"Left {METRIC_FIELD} blank for {missing_count} rows without DTR data.")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
"""Parse command-line paths."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Apply average diurnal temperature range to climate-data.csv."
|
|
)
|
|
parser.add_argument("--climate-data", type=Path, default=DEFAULT_CLIMATE_DATA)
|
|
parser.add_argument("--dtr-csv", type=Path, default=DEFAULT_DTR_CSV)
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_CLIMATE_DATA)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the climate-data update."""
|
|
args = parse_args()
|
|
apply_diurnal_temperature_range(
|
|
climate_data=args.climate_data,
|
|
dtr_csv=args.dtr_csv,
|
|
out=args.out,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|