Split the pipeline documentation by purpose so each fact has one home: - docs/pipeline-plan.md keeps the plan, checklist, tracker, and guardrails - docs/decisions.md holds open decisions and the dated decision log - docs/reviews/ holds findings and tasks: one file per filter, plus 00-cross-filter.md for findings that span filters - scripts/common/README.md holds the shared-helper rules (formerly Phase 2) - filter-calculations.md now describes calculations only Filed findings 12-22 from a consistency audit of the app, docs, and scripts. Filter 1 (Köppen-Geiger): use "Köppen" with the umlaut in all prose, labels, docstrings, help text, and checker messages (finding 21), and correct the base build's "majority" docstring (finding 22). Filter 2 (annual avg temperature): record the adopted definition in filter-calculations.md §2: equally weighted 1991-2020 monthly normals, per WMO-No. 1203 and NOAA's 2020 methodology; area-weighted county means; blank unless all 12 months exist. Code changes for this filter are still pending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
428 lines
17 KiB
Python
428 lines
17 KiB
Python
"""Validate data/climate-data.csv before the browser app loads it.
|
|
|
|
Checks file format, columns, county keys, the county geometry join, per-metric
|
|
types and ranges, allowed blank values, and cross-field consistency. Exits with
|
|
status 1 when any check fails.
|
|
|
|
Run:
|
|
.venv\\Scripts\\python.exe scripts\\check_climate_data.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, FrozenSet, List, Optional, Sequence, Tuple
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_CLIMATE_CSV = PROJECT_ROOT / "data" / "climate-data.csv"
|
|
DEFAULT_COUNTIES_GEOJSON = PROJECT_ROOT / "data" / "geojson-counties-fips.json"
|
|
DEFAULT_METRIC_SOURCES = PROJECT_ROOT / "data" / "metric_sources.json"
|
|
|
|
FIPS_PATTERN = re.compile(r"\d{5}")
|
|
STATE_PATTERN = re.compile(r"[A-Z]{2}")
|
|
|
|
# The distinct-value check only runs on full-size files, not small fixtures.
|
|
COLLAPSE_CHECK_MIN_ROWS = 100
|
|
|
|
KOPPEN_CODES = (
|
|
"Af", "Am", "Aw", "BWh", "BWk", "BSh", "BSk",
|
|
"Csa", "Csb", "Csc", "Cwa", "Cwb", "Cwc", "Cfa", "Cfb", "Cfc",
|
|
"Dsa", "Dsb", "Dsc", "Dsd", "Dwa", "Dwb", "Dwc", "Dwd",
|
|
"Dfa", "Dfb", "Dfc", "Dfd", "ET", "EF",
|
|
)
|
|
# Counties with no predominant class; see docs/filter-calculations.md, section 1.
|
|
MIXED_KOPPEN_CLASS = "Mixed"
|
|
|
|
MONTH_NAMES = (
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
)
|
|
|
|
# NOAA nClimGrid and gridMET cover the contiguous U.S. only.
|
|
OUTSIDE_CONUS = frozenset({"AK", "HI", "PR"})
|
|
# NSRDB summaries were not requested for Puerto Rico.
|
|
NSRDB_NOT_REQUESTED = frozenset({"PR"})
|
|
# NOAA county daily files do not include Lexington city, VA.
|
|
NOAA_DAILY_MISSING_FIPS = frozenset({"51678"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MetricRule:
|
|
"""Validation rule for one metric column."""
|
|
|
|
kind: str
|
|
minimum: Optional[float] = None
|
|
maximum: Optional[float] = None
|
|
integer: bool = False
|
|
categories: Tuple[str, ...] = ()
|
|
blank_states: FrozenSet[str] = frozenset()
|
|
blank_fips: FrozenSet[str] = frozenset()
|
|
min_distinct: int = 2
|
|
|
|
|
|
def _numeric(
|
|
minimum: float,
|
|
maximum: float,
|
|
*,
|
|
integer: bool = False,
|
|
blank_states: FrozenSet[str] = frozenset(),
|
|
blank_fips: FrozenSet[str] = frozenset(),
|
|
) -> MetricRule:
|
|
"""Build a numeric rule with a plausible physical range."""
|
|
return MetricRule(
|
|
"numeric",
|
|
minimum=minimum,
|
|
maximum=maximum,
|
|
integer=integer,
|
|
blank_states=blank_states,
|
|
blank_fips=blank_fips,
|
|
min_distinct=10,
|
|
)
|
|
|
|
|
|
def _categorical(categories: Sequence[str], *, blank_states: FrozenSet[str] = frozenset()) -> MetricRule:
|
|
"""Build a categorical rule limited to an allowed value list."""
|
|
return MetricRule("categorical", categories=tuple(categories), blank_states=blank_states)
|
|
|
|
|
|
# Ranges are physical plausibility limits, deliberately wider than the current
|
|
# data. They are not the app's color-scale bounds.
|
|
METRIC_RULES: Dict[str, MetricRule] = {
|
|
"koppenZone": _categorical(KOPPEN_CODES + (MIXED_KOPPEN_CLASS,)),
|
|
"avgTempF": _numeric(20, 85, blank_states=OUTSIDE_CONUS),
|
|
"avgDiurnalTempRangeF": _numeric(5, 40, blank_states=OUTSIDE_CONUS, blank_fips=NOAA_DAILY_MISSING_FIPS),
|
|
"annualPrecipIn": _numeric(1, 150, blank_states=OUTSIDE_CONUS),
|
|
"seasonalityIndex": _numeric(0, 100, integer=True, blank_states=OUTSIDE_CONUS),
|
|
"wettestPrecipMonth": _categorical(MONTH_NAMES, blank_states=OUTSIDE_CONUS),
|
|
"driestPrecipMonth": _categorical(MONTH_NAMES, blank_states=OUTSIDE_CONUS),
|
|
"absoluteExtremeDays": _numeric(0, 366, blank_states=OUTSIDE_CONUS, blank_fips=NOAA_DAILY_MISSING_FIPS),
|
|
"meanDailyGlobalHorizontalRadiationKwhM2Day": _numeric(1.5, 7.5, blank_states=NSRDB_NOT_REQUESTED),
|
|
"clearSkyGhiReductionIndex": _numeric(0, 1, blank_states=NSRDB_NOT_REQUESTED),
|
|
"avgSummerSpecificHumidityGKg": _numeric(1, 25, blank_states=OUTSIDE_CONUS),
|
|
"humidHeatDays": _numeric(0, 366, blank_states=OUTSIDE_CONUS),
|
|
}
|
|
|
|
IDENTITY_COLUMNS = ("countyFips", "countyName", "state")
|
|
# Stripe classes the app draws for Mixed Köppen counties; blank otherwise.
|
|
KOPPEN_STRIPE_COLUMNS = ("koppenPrimaryClass", "koppenSecondaryClass")
|
|
AUDIT_COLUMNS = ("humidHeatSourceFips", "humidHeatFipsAdjustment", "source")
|
|
EXPECTED_COLUMNS = IDENTITY_COLUMNS + tuple(METRIC_RULES) + KOPPEN_STRIPE_COLUMNS + AUDIT_COLUMNS
|
|
|
|
CHECK_FORMAT = "file format"
|
|
CHECK_COLUMNS = "columns"
|
|
CHECK_COUNTIES = "county keys"
|
|
CHECK_GEOMETRY = "map geometry join"
|
|
CHECK_BLANKS = "blank values"
|
|
CHECK_VALUES = "value types and ranges"
|
|
CHECK_CROSS = "cross-field consistency"
|
|
CHECK_SOURCES = "metric sources file"
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
"""Problems found per check, in the order checks ran."""
|
|
|
|
checks: Dict[str, List[str]] = field(default_factory=dict)
|
|
notes: List[str] = field(default_factory=list)
|
|
row_count: int = 0
|
|
column_count: int = 0
|
|
|
|
def start(self, check: str) -> None:
|
|
self.checks.setdefault(check, [])
|
|
|
|
def add(self, check: str, message: str) -> None:
|
|
self.checks.setdefault(check, []).append(message)
|
|
|
|
@property
|
|
def problem_count(self) -> int:
|
|
return sum(len(messages) for messages in self.checks.values())
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.problem_count == 0
|
|
|
|
|
|
def read_climate_csv(path: Path) -> Tuple[List[str], List[List[str]]]:
|
|
"""Read the raw header and rows without DictReader's silent padding."""
|
|
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
reader = csv.reader(handle)
|
|
headers = next(reader, [])
|
|
rows = [row for row in reader if row]
|
|
return headers, rows
|
|
|
|
|
|
def check_structure(
|
|
report: Report, headers: List[str], raw_rows: List[List[str]]
|
|
) -> Tuple[List[str], List[Dict[str, str]]]:
|
|
"""Check encoding, header names, and row widths; return rows as dicts."""
|
|
report.start(CHECK_FORMAT)
|
|
report.start(CHECK_COLUMNS)
|
|
|
|
if headers and headers[0].startswith("\ufeff"):
|
|
report.add(CHECK_FORMAT, "file starts with a UTF-8 BOM; the app would not find the first column")
|
|
headers = [headers[0].lstrip("\ufeff")] + headers[1:]
|
|
|
|
seen = set()
|
|
for name in headers:
|
|
if name in seen:
|
|
report.add(CHECK_COLUMNS, f"duplicate column {name!r}")
|
|
seen.add(name)
|
|
|
|
for name in EXPECTED_COLUMNS:
|
|
if name not in seen:
|
|
report.add(CHECK_COLUMNS, f"missing column {name!r}")
|
|
for name in headers:
|
|
if name not in EXPECTED_COLUMNS:
|
|
report.add(CHECK_COLUMNS, f"unexpected column {name!r} (add it to check_climate_data.py if intended)")
|
|
|
|
rows: List[Dict[str, str]] = []
|
|
for index, raw in enumerate(raw_rows):
|
|
if len(raw) != len(headers):
|
|
report.add(CHECK_FORMAT, f"line {index + 2}: {len(raw)} fields, expected {len(headers)}")
|
|
continue
|
|
rows.append(dict(zip(headers, raw)))
|
|
|
|
report.row_count = len(raw_rows)
|
|
report.column_count = len(headers)
|
|
return headers, rows
|
|
|
|
|
|
def check_counties(report: Report, rows: List[Dict[str, str]]) -> None:
|
|
"""Check FIPS format and uniqueness, names, and state/FIPS agreement."""
|
|
report.start(CHECK_COUNTIES)
|
|
seen = set()
|
|
prefix_states: Dict[str, set] = {}
|
|
state_prefixes: Dict[str, set] = {}
|
|
|
|
for row in rows:
|
|
fips = row.get("countyFips") or ""
|
|
if not FIPS_PATTERN.fullmatch(fips):
|
|
report.add(CHECK_COUNTIES, f"invalid countyFips {fips!r}")
|
|
continue
|
|
if fips in seen:
|
|
report.add(CHECK_COUNTIES, f"duplicate countyFips {fips}")
|
|
seen.add(fips)
|
|
|
|
if not (row.get("countyName") or "").strip():
|
|
report.add(CHECK_COUNTIES, f"{fips}: countyName is blank")
|
|
|
|
state = row.get("state") or ""
|
|
if not STATE_PATTERN.fullmatch(state):
|
|
report.add(CHECK_COUNTIES, f"{fips}: invalid state {state!r}")
|
|
continue
|
|
prefix_states.setdefault(fips[:2], set()).add(state)
|
|
state_prefixes.setdefault(state, set()).add(fips[:2])
|
|
|
|
for prefix, states in sorted(prefix_states.items()):
|
|
if len(states) > 1:
|
|
report.add(CHECK_COUNTIES, f"state FIPS {prefix} is labeled as {sorted(states)}")
|
|
for state, prefixes in sorted(state_prefixes.items()):
|
|
if len(prefixes) > 1:
|
|
report.add(CHECK_COUNTIES, f"state {state} spans FIPS prefixes {sorted(prefixes)}")
|
|
|
|
|
|
def _feature_fips(feature: dict) -> str:
|
|
"""Return the 5-digit county FIPS stored on a GeoJSON feature."""
|
|
props = feature.get("properties") or {}
|
|
raw = feature.get("id") or props.get("id") or str(props.get("GEO_ID") or "")[-5:]
|
|
text = str(raw or "").strip()
|
|
return text.zfill(5) if text.isdigit() else text
|
|
|
|
|
|
def check_geometry(report: Report, rows: List[Dict[str, str]], geojson_path: Path) -> None:
|
|
"""Check that CSV counties and map polygons match one-to-one."""
|
|
report.start(CHECK_GEOMETRY)
|
|
if not geojson_path.exists():
|
|
report.add(CHECK_GEOMETRY, f"county geometry file not found: {geojson_path}")
|
|
return
|
|
|
|
features = json.loads(geojson_path.read_text(encoding="utf-8")).get("features", [])
|
|
geo_fips = set()
|
|
for feature in features:
|
|
fips = _feature_fips(feature)
|
|
if not FIPS_PATTERN.fullmatch(fips):
|
|
report.add(CHECK_GEOMETRY, f"map feature without a valid county FIPS: {fips!r}")
|
|
continue
|
|
if fips in geo_fips:
|
|
report.add(CHECK_GEOMETRY, f"map has duplicate polygons for {fips}")
|
|
geo_fips.add(fips)
|
|
state_fips = str((feature.get("properties") or {}).get("STATE") or "").strip()
|
|
if state_fips and state_fips.zfill(2) != fips[:2]:
|
|
report.add(CHECK_GEOMETRY, f"map feature {fips} has STATE {state_fips}")
|
|
|
|
csv_fips = {row.get("countyFips") or "" for row in rows}
|
|
for fips in sorted(csv_fips - geo_fips):
|
|
report.add(CHECK_GEOMETRY, f"{fips} is in the CSV but has no map polygon")
|
|
for fips in sorted(geo_fips - csv_fips):
|
|
report.add(CHECK_GEOMETRY, f"{fips} has a map polygon but no CSV row")
|
|
|
|
|
|
def check_metrics(report: Report, headers: List[str], rows: List[Dict[str, str]]) -> None:
|
|
"""Check each metric's blanks, types, ranges, and categories."""
|
|
report.start(CHECK_BLANKS)
|
|
report.start(CHECK_VALUES)
|
|
|
|
for key, rule in METRIC_RULES.items():
|
|
if key not in headers:
|
|
continue
|
|
present: List[object] = []
|
|
for row in rows:
|
|
fips = row.get("countyFips") or "?"
|
|
state = row.get("state") or ""
|
|
value = row.get(key) or ""
|
|
|
|
if value == "":
|
|
if state not in rule.blank_states and fips not in rule.blank_fips:
|
|
report.add(CHECK_BLANKS, f"{fips} ({state}): {key} is blank")
|
|
continue
|
|
if value != value.strip():
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value!r} has surrounding whitespace")
|
|
continue
|
|
|
|
if rule.kind == "categorical":
|
|
if value in rule.categories:
|
|
present.append(value)
|
|
else:
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value!r} is not an allowed category")
|
|
continue
|
|
|
|
try:
|
|
number = float(value)
|
|
except ValueError:
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value!r} is not a number")
|
|
continue
|
|
if not math.isfinite(number):
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value!r} is not finite")
|
|
continue
|
|
if rule.integer and not number.is_integer():
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value} should be a whole number")
|
|
if not rule.minimum <= number <= rule.maximum:
|
|
report.add(CHECK_VALUES, f"{fips}: {key}={value} outside {rule.minimum:g}..{rule.maximum:g}")
|
|
present.append(number)
|
|
|
|
if len(rows) >= COLLAPSE_CHECK_MIN_ROWS and len(set(present)) < rule.min_distinct:
|
|
report.add(
|
|
CHECK_VALUES,
|
|
f"{key} has only {len(set(present))} distinct values; the column may have been overwritten",
|
|
)
|
|
|
|
|
|
def check_cross_fields(report: Report, rows: List[Dict[str, str]]) -> None:
|
|
"""Check relationships between columns within each row."""
|
|
report.start(CHECK_CROSS)
|
|
for row in rows:
|
|
fips = row.get("countyFips") or "?"
|
|
|
|
wet = row.get("wettestPrecipMonth") or ""
|
|
dry = row.get("driestPrecipMonth") or ""
|
|
if bool(wet) != bool(dry):
|
|
report.add(CHECK_CROSS, f"{fips}: only one of wettestPrecipMonth/driestPrecipMonth is set")
|
|
elif wet and wet == dry:
|
|
report.add(CHECK_CROSS, f"{fips}: wettest and driest month are both {wet}")
|
|
|
|
heat = row.get("humidHeatDays") or ""
|
|
heat_source = row.get("humidHeatSourceFips") or ""
|
|
if bool(heat) != bool(heat_source):
|
|
report.add(CHECK_CROSS, f"{fips}: humidHeatDays and humidHeatSourceFips must both be set or both blank")
|
|
if heat_source and not FIPS_PATTERN.fullmatch(heat_source):
|
|
report.add(CHECK_CROSS, f"{fips}: invalid humidHeatSourceFips {heat_source!r}")
|
|
elif heat_source and heat_source != fips and not (row.get("humidHeatFipsAdjustment") or "").strip():
|
|
report.add(CHECK_CROSS, f"{fips}: uses proxy county {heat_source} without a humidHeatFipsAdjustment note")
|
|
|
|
zone = row.get("koppenZone") or ""
|
|
primary = row.get("koppenPrimaryClass") or ""
|
|
secondary = row.get("koppenSecondaryClass") or ""
|
|
if zone == MIXED_KOPPEN_CLASS:
|
|
if not (primary and secondary):
|
|
report.add(CHECK_CROSS, f"{fips}: Mixed Köppen county needs koppenPrimaryClass and koppenSecondaryClass")
|
|
elif primary not in KOPPEN_CODES or secondary not in KOPPEN_CODES:
|
|
report.add(CHECK_CROSS, f"{fips}: invalid Köppen stripe classes {primary!r}/{secondary!r}")
|
|
elif primary == secondary:
|
|
report.add(CHECK_CROSS, f"{fips}: Köppen stripe classes are both {primary}")
|
|
elif primary or secondary:
|
|
report.add(CHECK_CROSS, f"{fips}: Köppen stripe classes are set but koppenZone is {zone or 'blank'}, not Mixed")
|
|
|
|
if not (row.get("source") or "").strip():
|
|
report.add(CHECK_CROSS, f"{fips}: source is blank")
|
|
|
|
|
|
def check_metric_sources(report: Report, path: Path) -> None:
|
|
"""Check that the metric metadata file parses and names real metrics."""
|
|
report.start(CHECK_SOURCES)
|
|
if not path.exists():
|
|
report.add(CHECK_SOURCES, f"{path.name} not found")
|
|
return
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
report.add(CHECK_SOURCES, f"{path.name} is not valid JSON: {exc}")
|
|
return
|
|
if not isinstance(data, dict) or not isinstance(data.get("metrics"), dict):
|
|
report.add(CHECK_SOURCES, f'{path.name} must be a JSON object with a "metrics" object')
|
|
return
|
|
|
|
documented = set(data["metrics"])
|
|
for key in sorted(documented - set(METRIC_RULES)):
|
|
report.add(CHECK_SOURCES, f"{path.name} describes unknown metric {key!r}")
|
|
covered = len(documented & set(METRIC_RULES))
|
|
report.notes.append(f"{path.name} documents {covered} of {len(METRIC_RULES)} metrics.")
|
|
|
|
|
|
def run_checks(csv_path: Path, geojson_path: Path, metric_sources_path: Path) -> Report:
|
|
"""Run every check and return the combined report."""
|
|
report = Report()
|
|
headers, raw_rows = read_climate_csv(csv_path)
|
|
headers, rows = check_structure(report, headers, raw_rows)
|
|
check_counties(report, rows)
|
|
check_geometry(report, rows, geojson_path)
|
|
check_metrics(report, headers, rows)
|
|
check_cross_fields(report, rows)
|
|
check_metric_sources(report, metric_sources_path)
|
|
return report
|
|
|
|
|
|
def print_report(report: Report, csv_path: Path, max_examples: int) -> None:
|
|
"""Print a pass/fail line per check with example problems."""
|
|
print(f"Checked {csv_path}: {report.row_count} rows, {report.column_count} columns")
|
|
for check, messages in report.checks.items():
|
|
status = "PASS" if not messages else f"FAIL ({len(messages)})"
|
|
print(f" {status:<11}{check}")
|
|
for message in messages[:max_examples]:
|
|
print(f" - {message}")
|
|
if len(messages) > max_examples:
|
|
print(f" ... and {len(messages) - max_examples} more")
|
|
for note in report.notes:
|
|
print(f" note: {note}")
|
|
print("Result: PASS" if report.ok else f"Result: FAIL ({report.problem_count} problems)")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
"""Define and parse command-line options for this checker."""
|
|
parser = argparse.ArgumentParser(description="Validate the browser app's county climate CSV.")
|
|
parser.add_argument("--csv", type=Path, default=DEFAULT_CLIMATE_CSV, help="Climate data CSV path.")
|
|
parser.add_argument("--geojson", type=Path, default=DEFAULT_COUNTIES_GEOJSON, help="County GeoJSON path.")
|
|
parser.add_argument("--metric-sources", type=Path, default=DEFAULT_METRIC_SOURCES, help="Metric metadata JSON path.")
|
|
parser.add_argument("--max-examples", type=int, default=10, help="Problems to print per failing check.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if not args.csv.exists():
|
|
print(f"Climate data CSV not found: {args.csv}", file=sys.stderr)
|
|
return 2
|
|
report = run_checks(args.csv, args.geojson, args.metric_sources)
|
|
print_report(report, args.csv, args.max_examples)
|
|
return 0 if report.ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|