Initial Climate Mood Analysis project
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Local Python environment and caches
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Local editor and operating-system files
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Downloaded and generated climate datasets are too large for Git.
|
||||||
|
# Keep only the assets required by the browser application.
|
||||||
|
data/*
|
||||||
|
!data/climate-data.csv
|
||||||
|
!data/geojson-counties-fips.json
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
*.log
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+113
@@ -0,0 +1,113 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>US County Climate Explorer</title>
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Source+Sans+3:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin=""
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="styles.css?v=dtr-20260531" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-shell">
|
||||||
|
<aside class="control-panel">
|
||||||
|
<h1>US County Climate Explorer</h1>
|
||||||
|
<p class="intro">Explore county-level climate filters. Click any county to inspect details and zoom directly to it.</p>
|
||||||
|
|
||||||
|
<div class="control-stack">
|
||||||
|
<label for="metricGroupSelect">Group</label>
|
||||||
|
<select id="metricGroupSelect"></select>
|
||||||
|
|
||||||
|
<div id="metricSelectWrap" class="metric-select-wrap">
|
||||||
|
<label for="metricSelect">Metric</label>
|
||||||
|
<select id="metricSelect"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="koppenFilterWrap" class="range-wrap is-hidden">
|
||||||
|
<label for="koppenFilterSelect">Category Filter</label>
|
||||||
|
<select id="koppenFilterSelect"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="range-wrap">
|
||||||
|
<label for="minRange">Minimum Value</label>
|
||||||
|
<input id="minRange" type="range" />
|
||||||
|
<output id="minValue"></output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="range-wrap">
|
||||||
|
<label for="maxRange">Maximum Value</label>
|
||||||
|
<input id="maxRange" type="range" />
|
||||||
|
<output id="maxValue"></output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="resetViewButton" type="button">Reset Country View</button>
|
||||||
|
<button id="clearSelectionButton" type="button">Clear Selection</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-panel-spacer" aria-hidden="true"></div>
|
||||||
|
<div class="sources-control" style="margin-top: auto;">
|
||||||
|
<button id="sourcesButton" type="button" aria-haspopup="dialog">Sources</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="map-panel">
|
||||||
|
<div id="map" aria-label="US county climate map"></div>
|
||||||
|
<section id="metricInfoPanel" class="metric-info-panel" aria-label="County metric details">
|
||||||
|
<button
|
||||||
|
id="metricInfoToggle"
|
||||||
|
class="metric-info-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="stateDetails"
|
||||||
|
>
|
||||||
|
Hide County Metrics
|
||||||
|
</button>
|
||||||
|
<div id="stateDetails" class="state-details">
|
||||||
|
<h2>Selected County</h2>
|
||||||
|
<p>Click a county to see details and auto-focus.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="legendInfoPanel" class="legend-info-panel is-collapsed" aria-label="Map legend">
|
||||||
|
<button
|
||||||
|
id="legendInfoToggle"
|
||||||
|
class="legend-info-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="legend"
|
||||||
|
>
|
||||||
|
Show Legend
|
||||||
|
</button>
|
||||||
|
<div id="legend" class="legend"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sourcesModal" class="sources-modal" role="dialog" aria-modal="true" aria-labelledby="sourcesModalTitle" hidden>
|
||||||
|
<div class="sources-modal-backdrop" data-sources-close></div>
|
||||||
|
<section class="sources-dialog" aria-label="Sources details">
|
||||||
|
<header class="sources-dialog-header">
|
||||||
|
<h2 id="sourcesModalTitle">Sources</h2>
|
||||||
|
<button id="sourcesCloseButton" class="sources-close-button" type="button" aria-label="Close sources">X</button>
|
||||||
|
</header>
|
||||||
|
<div id="sourcesModalContent" class="sources-modal-content"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script
|
||||||
|
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""
|
||||||
|
></script>
|
||||||
|
<script src="app.js?v=precip-months-20260601"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Merge selected gridMET humidity and heat metrics into climate-data.csv.
|
||||||
|
|
||||||
|
Currently adds:
|
||||||
|
- avgSummerSpecificHumidityGKg
|
||||||
|
- humidHeatDays
|
||||||
|
- humidHeatSourceFips
|
||||||
|
- humidHeatFipsAdjustment
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CLIMATE_DATA = Path("data/climate-data.csv")
|
||||||
|
DEFAULT_GRIDMET_HUMIDITY = Path("data/gridmet/county_gridmet_humidity.csv")
|
||||||
|
METRIC_FIELDS = [
|
||||||
|
"avgSummerSpecificHumidityGKg",
|
||||||
|
"humidHeatDays",
|
||||||
|
"humidHeatSourceFips",
|
||||||
|
"humidHeatFipsAdjustment",
|
||||||
|
]
|
||||||
|
SOURCE_TAGS_BY_FIELD = {
|
||||||
|
"avgSummerSpecificHumidityGKg": "gridmet-specific-humidity-1991-2020",
|
||||||
|
"humidHeatDays": "heat-index-noaa-tmax-gridmet-rmin-1991-2020",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_gridmet_values(path: Path) -> dict[str, dict[str, str]]:
|
||||||
|
values: dict[str, dict[str, str]] = {}
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
fieldnames = reader.fieldnames or []
|
||||||
|
missing_fields = [field for field in METRIC_FIELDS if field not in fieldnames]
|
||||||
|
if missing_fields:
|
||||||
|
raise ValueError(f"{path} is missing fields: {', '.join(missing_fields)}")
|
||||||
|
for row in reader:
|
||||||
|
county_fips = (row.get("countyFips") or "").strip().zfill(5)
|
||||||
|
if county_fips:
|
||||||
|
values[county_fips] = {
|
||||||
|
field: (row.get(field) or "").strip() for field in METRIC_FIELDS
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def append_source_tag(source: str, tag: str) -> str:
|
||||||
|
clean_source = source.strip()
|
||||||
|
if tag in clean_source:
|
||||||
|
return clean_source
|
||||||
|
return f"{clean_source} + {tag}".strip(" +")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_field_after(fieldnames: list[str], field: str, after_field: str | None = None) -> None:
|
||||||
|
if field in fieldnames:
|
||||||
|
return
|
||||||
|
if after_field and after_field in fieldnames:
|
||||||
|
fieldnames.insert(fieldnames.index(after_field) + 1, field)
|
||||||
|
return
|
||||||
|
insert_at = fieldnames.index("source") if "source" in fieldnames else len(fieldnames)
|
||||||
|
fieldnames.insert(insert_at, field)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metrics(climate_data: Path, humidity_csv: Path) -> tuple[int, dict[str, int]]:
|
||||||
|
gridmet_by_fips = load_gridmet_values(humidity_csv)
|
||||||
|
|
||||||
|
with climate_data.open("r", encoding="utf-8", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
fieldnames = list(reader.fieldnames or [])
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if "countyFips" not in fieldnames:
|
||||||
|
raise ValueError(f"{climate_data} is missing countyFips")
|
||||||
|
|
||||||
|
ensure_field_after(fieldnames, "avgSummerSpecificHumidityGKg")
|
||||||
|
ensure_field_after(fieldnames, "humidHeatDays", "avgSummerSpecificHumidityGKg")
|
||||||
|
ensure_field_after(fieldnames, "humidHeatSourceFips", "humidHeatDays")
|
||||||
|
ensure_field_after(fieldnames, "humidHeatFipsAdjustment", "humidHeatSourceFips")
|
||||||
|
|
||||||
|
missing_counts = {field: 0 for field in METRIC_FIELDS}
|
||||||
|
for row in rows:
|
||||||
|
county_fips = (row.get("countyFips") or "").strip().zfill(5)
|
||||||
|
gridmet_values = gridmet_by_fips.get(county_fips, {})
|
||||||
|
for field in METRIC_FIELDS:
|
||||||
|
value = gridmet_values.get(field, "")
|
||||||
|
row[field] = value
|
||||||
|
if not value:
|
||||||
|
missing_counts[field] += 1
|
||||||
|
for field, tag in SOURCE_TAGS_BY_FIELD.items():
|
||||||
|
if row.get(field):
|
||||||
|
row["source"] = append_source_tag(row.get("source", ""), tag)
|
||||||
|
|
||||||
|
with climate_data.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
return len(rows), missing_counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Merge gridMET humidity and Heat Index metrics into climate-data.csv.")
|
||||||
|
parser.add_argument("--climate-data", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
parser.add_argument("--humidity-csv", type=Path, default=DEFAULT_GRIDMET_HUMIDITY)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
row_count, missing_counts = merge_metrics(args.climate_data, args.humidity_csv)
|
||||||
|
print(f"Updated {args.climate_data} with {', '.join(METRIC_FIELDS)}")
|
||||||
|
print(f"Rows: {row_count}")
|
||||||
|
for field, missing_count in missing_counts.items():
|
||||||
|
print(f"Missing {field}: {missing_count}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Apply NOAA daily extreme-day and polygon solar metrics to the app CSV.
|
||||||
|
|
||||||
|
The browser app already reads the `extremeDays` column, so this script updates
|
||||||
|
that column to the average locally extreme days/year while preserving the older
|
||||||
|
proxy value in `oldExtremeDays`. It also writes an absolute daily heat/cold
|
||||||
|
bucket metric from the same NOAA nClimGrid-Daily county data.
|
||||||
|
|
||||||
|
When an NSRDB polygon archive summary is available, this also replaces the
|
||||||
|
older representative-point solar GHI value with the county polygon value. Rows
|
||||||
|
without a polygon summary keep the representative-point fallback when present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_CLIMATE_DATA = REPO_ROOT / "data" / "climate-data.csv"
|
||||||
|
DEFAULT_COMPARISON = REPO_ROOT / "data" / "noaa" / "county_locally_extreme_days_comparison.csv"
|
||||||
|
DEFAULT_POLYGON_SOLAR_GHI = REPO_ROOT / "data" / "nrel" / "county_polygon_ghi_summary.csv"
|
||||||
|
DEFAULT_REPRESENTATIVE_POINT_SOLAR_GHI = REPO_ROOT / "data" / "nrel" / "county_representative_point_ghi_summary.csv"
|
||||||
|
|
||||||
|
BASE_FIELDS = [
|
||||||
|
"countyFips",
|
||||||
|
"countyName",
|
||||||
|
"state",
|
||||||
|
"koppenZone",
|
||||||
|
"avgTempF",
|
||||||
|
"annualPrecipIn",
|
||||||
|
"seasonalityIndex",
|
||||||
|
"wettestPrecipMonth",
|
||||||
|
"driestPrecipMonth",
|
||||||
|
"extremeDays",
|
||||||
|
"locallyExtremeDays",
|
||||||
|
"locallyExtremeHotDays",
|
||||||
|
"locallyExtremeColdDays",
|
||||||
|
"absoluteExtremeDays",
|
||||||
|
"oldExtremeDays",
|
||||||
|
"locallyExtremeAnalysisYears",
|
||||||
|
"locallyExtremeSourceFips",
|
||||||
|
"locallyExtremeFipsAdjustment",
|
||||||
|
"avgSolarGhiKwhM2Day",
|
||||||
|
"source",
|
||||||
|
]
|
||||||
|
|
||||||
|
LEGACY_FIELDS = {
|
||||||
|
"absoluteExtremeHeatDays",
|
||||||
|
"absoluteExtremeColdDays",
|
||||||
|
}
|
||||||
|
|
||||||
|
SOLAR_SOURCE_TAGS = {
|
||||||
|
"solar-ghi-centroid-preview",
|
||||||
|
"solar-ghi-representative-point",
|
||||||
|
"solar-ghi-polygon-area-weighted",
|
||||||
|
"solar-ghi-polygon-archive-area-weighted",
|
||||||
|
"no-solar-ghi-source",
|
||||||
|
"missing-solar-ghi",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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_locally_extreme_lookup(comparison_csv: Path) -> dict[str, dict]:
|
||||||
|
"""Map current and supported old app FIPS codes to locally extreme rows."""
|
||||||
|
_, rows = read_csv_rows(comparison_csv)
|
||||||
|
lookup: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.get("hasLocallyExtremeData") != "yes":
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_fips = normalize_fips(row.get("countyFips", ""))
|
||||||
|
if current_fips:
|
||||||
|
lookup[current_fips] = row
|
||||||
|
|
||||||
|
for old_fips in (row.get("oldSourceFips") or "").split(";"):
|
||||||
|
normalized_old_fips = normalize_fips(old_fips)
|
||||||
|
if normalized_old_fips:
|
||||||
|
lookup[normalized_old_fips] = row
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def load_solar_ghi_lookup(solar_ghi_csv: Path) -> dict[str, str]:
|
||||||
|
"""Map county FIPS codes to average daily GHI values from a county CSV."""
|
||||||
|
if not solar_ghi_csv.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
fieldnames, rows = read_csv_rows(solar_ghi_csv)
|
||||||
|
if "avgSolarGhiKwhM2Day" not in fieldnames:
|
||||||
|
raise ValueError(f"{solar_ghi_csv} must include avgSolarGhiKwhM2Day.")
|
||||||
|
|
||||||
|
if "county_fips" in fieldnames:
|
||||||
|
fips_field = "county_fips"
|
||||||
|
elif "countyFips" in fieldnames:
|
||||||
|
fips_field = "countyFips"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{solar_ghi_csv} must include county_fips or countyFips.")
|
||||||
|
|
||||||
|
lookup: dict[str, str] = {}
|
||||||
|
for row in rows:
|
||||||
|
county_fips = normalize_fips(row.get(fips_field, ""))
|
||||||
|
raw_value = (row.get("avgSolarGhiKwhM2Day") or "").strip()
|
||||||
|
if not county_fips or not raw_value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
float(raw_value)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid avgSolarGhiKwhM2Day value for county {county_fips}: {raw_value}"
|
||||||
|
) from error
|
||||||
|
lookup[county_fips] = raw_value
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def append_source_tag(source: str, has_local_value: bool) -> str:
|
||||||
|
"""Add the locally extreme provenance tags without duplicating old tags."""
|
||||||
|
old_source = source.strip() or "unknown-source"
|
||||||
|
old_source = old_source.replace(" + absoluteExtremeHeatDays=tmax-gte-95f", "")
|
||||||
|
old_source = old_source.replace(" + absoluteExtremeColdDays=tmin-lte-0f", "")
|
||||||
|
if not has_local_value:
|
||||||
|
return f"{old_source} + missing-noaa-locally-extreme-days"
|
||||||
|
|
||||||
|
has_local_tag = "extremeDays=locally-extreme-noaa-nclimgrid-daily-1991-2025" in old_source
|
||||||
|
has_absolute_tag = "absoluteExtremeDays=tmax-gte-95f-or-tmin-lte-0f" in old_source
|
||||||
|
if has_local_tag and has_absolute_tag:
|
||||||
|
return old_source
|
||||||
|
if has_local_tag:
|
||||||
|
return f"{old_source} + absoluteExtremeDays=tmax-gte-95f-or-tmin-lte-0f"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{old_source} + extremeDays=locally-extreme-noaa-nclimgrid-daily-1991-2025 "
|
||||||
|
"+ absoluteExtremeDays=tmax-gte-95f-or-tmin-lte-0f "
|
||||||
|
"+ oldExtremeDays=previous-monthly-proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_solar_source_tag(source: str, solar_source_tag: str) -> str:
|
||||||
|
"""Replace any previous solar provenance tag with the active one."""
|
||||||
|
parts = [part.strip() for part in (source.strip() or "unknown-source").split("+")]
|
||||||
|
kept_parts = [
|
||||||
|
part
|
||||||
|
for part in parts
|
||||||
|
if part and part not in SOLAR_SOURCE_TAGS
|
||||||
|
]
|
||||||
|
if solar_source_tag not in kept_parts:
|
||||||
|
kept_parts.append(solar_source_tag)
|
||||||
|
return " + ".join(kept_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_locally_extreme_metric(
|
||||||
|
*,
|
||||||
|
climate_data: Path,
|
||||||
|
comparison_csv: Path,
|
||||||
|
polygon_solar_ghi_csv: Path,
|
||||||
|
representative_point_solar_ghi_csv: Path,
|
||||||
|
out: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Replace app extreme-day fields and solar GHI with improved metrics."""
|
||||||
|
original_fields, climate_rows = read_csv_rows(climate_data)
|
||||||
|
local_lookup = load_locally_extreme_lookup(comparison_csv)
|
||||||
|
polygon_solar_lookup = load_solar_ghi_lookup(polygon_solar_ghi_csv)
|
||||||
|
representative_solar_lookup = load_solar_ghi_lookup(representative_point_solar_ghi_csv)
|
||||||
|
|
||||||
|
extra_fields = [
|
||||||
|
field
|
||||||
|
for field in original_fields
|
||||||
|
if field not in BASE_FIELDS and field not in LEGACY_FIELDS
|
||||||
|
]
|
||||||
|
fieldnames = BASE_FIELDS + extra_fields
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
missing_count = 0
|
||||||
|
polygon_solar_count = 0
|
||||||
|
representative_solar_count = 0
|
||||||
|
missing_solar_count = 0
|
||||||
|
for row in climate_rows:
|
||||||
|
county_fips = normalize_fips(row.get("countyFips", ""))
|
||||||
|
local_row = local_lookup.get(county_fips)
|
||||||
|
old_extreme_days = (row.get("oldExtremeDays") or row.get("extremeDays") or "").strip()
|
||||||
|
|
||||||
|
row["oldExtremeDays"] = old_extreme_days
|
||||||
|
if local_row:
|
||||||
|
row["extremeDays"] = local_row.get("avgLocallyExtremeDays", "")
|
||||||
|
row["locallyExtremeDays"] = local_row.get("avgLocallyExtremeDays", "")
|
||||||
|
row["locallyExtremeHotDays"] = local_row.get("avgHotExtremeDays", "")
|
||||||
|
row["locallyExtremeColdDays"] = local_row.get("avgColdExtremeDays", "")
|
||||||
|
row["absoluteExtremeDays"] = local_row.get("avgAbsoluteExtremeDays", "")
|
||||||
|
row["locallyExtremeAnalysisYears"] = local_row.get("analysisYears", "")
|
||||||
|
row["locallyExtremeSourceFips"] = local_row.get("countyFips", "")
|
||||||
|
row["locallyExtremeFipsAdjustment"] = local_row.get("fipsAdjustment", "")
|
||||||
|
row["source"] = append_source_tag(row.get("source", ""), has_local_value=True)
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
row["extremeDays"] = ""
|
||||||
|
row["locallyExtremeDays"] = ""
|
||||||
|
row["locallyExtremeHotDays"] = ""
|
||||||
|
row["locallyExtremeColdDays"] = ""
|
||||||
|
row["absoluteExtremeDays"] = ""
|
||||||
|
row["locallyExtremeAnalysisYears"] = ""
|
||||||
|
row["locallyExtremeSourceFips"] = ""
|
||||||
|
row["locallyExtremeFipsAdjustment"] = ""
|
||||||
|
row["source"] = append_source_tag(row.get("source", ""), has_local_value=False)
|
||||||
|
missing_count += 1
|
||||||
|
|
||||||
|
polygon_solar_value = polygon_solar_lookup.get(county_fips)
|
||||||
|
representative_solar_value = representative_solar_lookup.get(county_fips)
|
||||||
|
current_solar_value = (row.get("avgSolarGhiKwhM2Day") or "").strip()
|
||||||
|
if polygon_solar_value:
|
||||||
|
row["avgSolarGhiKwhM2Day"] = polygon_solar_value
|
||||||
|
row["source"] = replace_solar_source_tag(
|
||||||
|
row.get("source", ""),
|
||||||
|
"solar-ghi-polygon-archive-area-weighted",
|
||||||
|
)
|
||||||
|
polygon_solar_count += 1
|
||||||
|
elif representative_solar_value or current_solar_value:
|
||||||
|
row["avgSolarGhiKwhM2Day"] = representative_solar_value or current_solar_value
|
||||||
|
row["source"] = replace_solar_source_tag(
|
||||||
|
row.get("source", ""),
|
||||||
|
"solar-ghi-representative-point",
|
||||||
|
)
|
||||||
|
representative_solar_count += 1
|
||||||
|
else:
|
||||||
|
row["avgSolarGhiKwhM2Day"] = ""
|
||||||
|
row["source"] = replace_solar_source_tag(row.get("source", ""), "missing-solar-ghi")
|
||||||
|
missing_solar_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 active extremeDays from locally extreme data for {updated_count} rows.")
|
||||||
|
print(f"Left active extremeDays blank for {missing_count} rows without locally extreme data.")
|
||||||
|
print(f"Updated avgSolarGhiKwhM2Day from polygon archives for {polygon_solar_count} rows.")
|
||||||
|
print(f"Kept representative-point solar fallback for {representative_solar_count} rows.")
|
||||||
|
print(f"Left avgSolarGhiKwhM2Day blank for {missing_solar_count} rows without solar data.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line paths for the climate-data update."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Apply locally extreme days/year and polygon solar GHI to climate-data.csv."
|
||||||
|
)
|
||||||
|
parser.add_argument("--climate-data", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
parser.add_argument("--comparison-csv", type=Path, default=DEFAULT_COMPARISON)
|
||||||
|
parser.add_argument("--polygon-solar-ghi-csv", type=Path, default=DEFAULT_POLYGON_SOLAR_GHI)
|
||||||
|
parser.add_argument(
|
||||||
|
"--representative-point-solar-ghi-csv",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_REPRESENTATIVE_POINT_SOLAR_GHI,
|
||||||
|
)
|
||||||
|
parser.add_argument("--out", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the CSV update from parsed command-line arguments."""
|
||||||
|
args = parse_args()
|
||||||
|
apply_locally_extreme_metric(
|
||||||
|
climate_data=args.climate_data,
|
||||||
|
comparison_csv=args.comparison_csv,
|
||||||
|
polygon_solar_ghi_csv=args.polygon_solar_ghi_csv,
|
||||||
|
representative_point_solar_ghi_csv=args.representative_point_solar_ghi_csv,
|
||||||
|
out=args.out,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Merge NSRDB cloudiness metrics into climate-data.csv.
|
||||||
|
|
||||||
|
Adds:
|
||||||
|
- cloudinessIndexPct
|
||||||
|
|
||||||
|
Polygon area-weighted values are used first when available; representative-point
|
||||||
|
values remain the fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CLIMATE_DATA = Path("data/climate-data.csv")
|
||||||
|
DEFAULT_POLYGON_CLOUD_SUMMARY = Path("data/nrel/county_polygon_cloud_summary.csv")
|
||||||
|
DEFAULT_REPRESENTATIVE_POINT_CLOUD_SUMMARY = Path("data/nrel/county_representative_point_cloud_summary.csv")
|
||||||
|
METRIC_FIELD = "cloudinessIndexPct"
|
||||||
|
POLYGON_SOURCE_TAG = "nsrdb-polygon-area-weighted-cloudiness-tmy"
|
||||||
|
REPRESENTATIVE_POINT_SOURCE_TAG = "nsrdb-representative-point-cloudiness-tmy"
|
||||||
|
CLOUD_SOURCE_TAGS = {
|
||||||
|
POLYGON_SOURCE_TAG,
|
||||||
|
REPRESENTATIVE_POINT_SOURCE_TAG,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_cloud_values(path: Path) -> dict[str, str]:
|
||||||
|
"""Read cloudiness values keyed by county FIPS."""
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
fieldnames = reader.fieldnames or []
|
||||||
|
required_fields = {"county_fips", METRIC_FIELD}
|
||||||
|
missing_fields = sorted(required_fields - set(fieldnames))
|
||||||
|
if missing_fields:
|
||||||
|
raise ValueError(f"{path} is missing fields: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
county_fips = (row.get("county_fips") or "").strip().zfill(5)
|
||||||
|
value = (row.get(METRIC_FIELD) or "").strip()
|
||||||
|
if county_fips and value:
|
||||||
|
values[county_fips] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def append_source_tag(source: str, tag: str) -> str:
|
||||||
|
"""Append a source tag if it is not already present."""
|
||||||
|
parts = [
|
||||||
|
part.strip()
|
||||||
|
for part in source.split("+")
|
||||||
|
if part.strip() and part.strip() not in CLOUD_SOURCE_TAGS
|
||||||
|
]
|
||||||
|
if tag not in parts:
|
||||||
|
parts.append(tag)
|
||||||
|
return " + ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_field_after(fieldnames: list[str], field: str, after_field: str | None = None) -> None:
|
||||||
|
"""Insert a field into CSV field order if it is missing."""
|
||||||
|
if field in fieldnames:
|
||||||
|
return
|
||||||
|
if after_field and after_field in fieldnames:
|
||||||
|
fieldnames.insert(fieldnames.index(after_field) + 1, field)
|
||||||
|
return
|
||||||
|
insert_at = fieldnames.index("source") if "source" in fieldnames else len(fieldnames)
|
||||||
|
fieldnames.insert(insert_at, field)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_metric(
|
||||||
|
climate_data: Path,
|
||||||
|
polygon_cloud_summary: Path,
|
||||||
|
representative_point_cloud_summary: Path,
|
||||||
|
) -> tuple[int, int, int, int]:
|
||||||
|
"""Merge cloudiness values into the app climate CSV."""
|
||||||
|
polygon_cloudiness_by_fips = (
|
||||||
|
load_cloud_values(polygon_cloud_summary)
|
||||||
|
if polygon_cloud_summary.exists()
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
representative_cloudiness_by_fips = load_cloud_values(representative_point_cloud_summary)
|
||||||
|
|
||||||
|
with climate_data.open("r", encoding="utf-8", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
fieldnames = list(reader.fieldnames or [])
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if "countyFips" not in fieldnames:
|
||||||
|
raise ValueError(f"{climate_data} is missing countyFips")
|
||||||
|
|
||||||
|
ensure_field_after(fieldnames, METRIC_FIELD, "avgSolarGhiKwhM2Day")
|
||||||
|
|
||||||
|
polygon_count = 0
|
||||||
|
representative_count = 0
|
||||||
|
missing_count = 0
|
||||||
|
for row in rows:
|
||||||
|
county_fips = (row.get("countyFips") or "").strip().zfill(5)
|
||||||
|
polygon_value = polygon_cloudiness_by_fips.get(county_fips, "")
|
||||||
|
representative_value = representative_cloudiness_by_fips.get(county_fips, "")
|
||||||
|
value = polygon_value or representative_value
|
||||||
|
row[METRIC_FIELD] = value
|
||||||
|
if polygon_value:
|
||||||
|
row["source"] = append_source_tag(row.get("source", ""), POLYGON_SOURCE_TAG)
|
||||||
|
polygon_count += 1
|
||||||
|
elif representative_value:
|
||||||
|
row["source"] = append_source_tag(row.get("source", ""), REPRESENTATIVE_POINT_SOURCE_TAG)
|
||||||
|
representative_count += 1
|
||||||
|
else:
|
||||||
|
missing_count += 1
|
||||||
|
|
||||||
|
with climate_data.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
return len(rows), polygon_count, representative_count, missing_count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Merge NSRDB cloudiness metric into climate-data.csv.")
|
||||||
|
parser.add_argument("--climate-data", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
parser.add_argument("--polygon-cloud-summary", type=Path, default=DEFAULT_POLYGON_CLOUD_SUMMARY)
|
||||||
|
parser.add_argument(
|
||||||
|
"--representative-point-cloud-summary",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_REPRESENTATIVE_POINT_CLOUD_SUMMARY,
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
row_count, polygon_count, representative_count, missing_count = merge_metric(
|
||||||
|
args.climate_data,
|
||||||
|
args.polygon_cloud_summary,
|
||||||
|
args.representative_point_cloud_summary,
|
||||||
|
)
|
||||||
|
print(f"Updated {args.climate_data} with {METRIC_FIELD}")
|
||||||
|
print(f"Rows: {row_count}")
|
||||||
|
print(f"Polygon area-weighted rows: {polygon_count}")
|
||||||
|
print(f"Representative-point fallback rows: {representative_count}")
|
||||||
|
print(f"Missing {METRIC_FIELD}: {missing_count}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Add wettest/driest precipitation month metrics to data/climate-data.csv.
|
||||||
|
|
||||||
|
The metrics use the existing monthly NOAA nClimGrid precipitation file. They
|
||||||
|
are categorical month labels derived from 1991-2020 county-area means.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import xarray as xr
|
||||||
|
|
||||||
|
from build_county_climate_data import (
|
||||||
|
MONTH_NAMES,
|
||||||
|
_as_monthly_climatology,
|
||||||
|
_extract_grid_2d,
|
||||||
|
_load_counties,
|
||||||
|
_select_data_var,
|
||||||
|
_zonal_mean,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_CLIMATE_DATA = REPO_ROOT / "data" / "climate-data.csv"
|
||||||
|
DEFAULT_COUNTIES_GEOJSON = REPO_ROOT / "data" / "geojson-counties-fips.json"
|
||||||
|
DEFAULT_MONTHLY_PRCP_NC = REPO_ROOT / "data" / "noaa" / "nclimgrid" / "nclimgrid_prcp.nc"
|
||||||
|
PRECIP_MONTH_FIELDS = ["wettestPrecipMonth", "driestPrecipMonth"]
|
||||||
|
|
||||||
|
|
||||||
|
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 fieldnames_with_precip_months(original_fields: list[str]) -> list[str]:
|
||||||
|
"""Place the new fields next to the existing precipitation metrics."""
|
||||||
|
fields = [field for field in original_fields if field not in PRECIP_MONTH_FIELDS]
|
||||||
|
insert_after = "seasonalityIndex"
|
||||||
|
insert_at = fields.index(insert_after) + 1 if insert_after in fields else len(fields)
|
||||||
|
return fields[:insert_at] + PRECIP_MONTH_FIELDS + fields[insert_at:]
|
||||||
|
|
||||||
|
|
||||||
|
def build_precip_month_lookup(
|
||||||
|
*,
|
||||||
|
counties_geojson: Path,
|
||||||
|
monthly_prcp_nc: Path,
|
||||||
|
climatology_start_year: int,
|
||||||
|
climatology_end_year: int,
|
||||||
|
) -> dict[str, tuple[str, str]]:
|
||||||
|
"""Calculate wettest and driest precipitation month for each county."""
|
||||||
|
counties = _load_counties(counties_geojson)
|
||||||
|
monthly_prcp = xr.open_dataset(monthly_prcp_nc, decode_times=True)
|
||||||
|
try:
|
||||||
|
prcp_var = _select_data_var(monthly_prcp, "mlyprcp_norm")
|
||||||
|
prcp_monthly = _as_monthly_climatology(
|
||||||
|
monthly_prcp[prcp_var],
|
||||||
|
start_year=climatology_start_year,
|
||||||
|
end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
prcp_by_month: list[list[float]] = []
|
||||||
|
for month in range(12):
|
||||||
|
prcp_arr, prcp_transform = _extract_grid_2d(prcp_monthly.isel(time=month))
|
||||||
|
prcp_by_month.append(_zonal_mean(prcp_arr, prcp_transform, counties))
|
||||||
|
finally:
|
||||||
|
monthly_prcp.close()
|
||||||
|
|
||||||
|
lookup: dict[str, tuple[str, str]] = {}
|
||||||
|
for idx, row in counties.iterrows():
|
||||||
|
county_fips = str(row["county_fips"])
|
||||||
|
prcp_months_mm = np.array([prcp_by_month[m][idx] for m in range(12)], dtype=np.float64)
|
||||||
|
if not np.any(np.isfinite(prcp_months_mm)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
wettest_month = MONTH_NAMES[int(np.nanargmax(prcp_months_mm))]
|
||||||
|
driest_month = MONTH_NAMES[int(np.nanargmin(prcp_months_mm))]
|
||||||
|
lookup[county_fips] = (wettest_month, driest_month)
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def apply_precipitation_month_metrics(
|
||||||
|
*,
|
||||||
|
climate_data: Path,
|
||||||
|
counties_geojson: Path,
|
||||||
|
monthly_prcp_nc: Path,
|
||||||
|
out: Path,
|
||||||
|
climatology_start_year: int,
|
||||||
|
climatology_end_year: int,
|
||||||
|
) -> None:
|
||||||
|
"""Merge precipitation month categories into the app climate CSV."""
|
||||||
|
original_fields, rows = read_csv_rows(climate_data)
|
||||||
|
lookup = build_precip_month_lookup(
|
||||||
|
counties_geojson=counties_geojson,
|
||||||
|
monthly_prcp_nc=monthly_prcp_nc,
|
||||||
|
climatology_start_year=climatology_start_year,
|
||||||
|
climatology_end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
missing_count = 0
|
||||||
|
for row in rows:
|
||||||
|
county_fips = normalize_fips(row.get("countyFips", ""))
|
||||||
|
values = lookup.get(county_fips)
|
||||||
|
if values:
|
||||||
|
row["wettestPrecipMonth"], row["driestPrecipMonth"] = values
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
row["wettestPrecipMonth"] = ""
|
||||||
|
row["driestPrecipMonth"] = ""
|
||||||
|
missing_count += 1
|
||||||
|
|
||||||
|
source = (row.get("source") or "").strip() or "unknown-source"
|
||||||
|
tag = f"precip-month-extremes=noaa-nclimgrid-monthly-{climatology_start_year}-{climatology_end_year}"
|
||||||
|
if tag not in source:
|
||||||
|
row["source"] = f"{source} + {tag}"
|
||||||
|
|
||||||
|
fieldnames = fieldnames_with_precip_months(original_fields)
|
||||||
|
with out.open("w", encoding="utf-8", newline="") as csv_file:
|
||||||
|
writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
print(f"Wrote {len(rows)} rows to {out}")
|
||||||
|
print(f"Updated precipitation month metrics for {updated_count} rows.")
|
||||||
|
print(f"Left precipitation month metrics blank for {missing_count} rows.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line paths for the precipitation month update."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Add wettestPrecipMonth and driestPrecipMonth to climate-data.csv."
|
||||||
|
)
|
||||||
|
parser.add_argument("--climate-data", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
parser.add_argument("--counties-geojson", type=Path, default=DEFAULT_COUNTIES_GEOJSON)
|
||||||
|
parser.add_argument("--monthly-prcp-nc", type=Path, default=DEFAULT_MONTHLY_PRCP_NC)
|
||||||
|
parser.add_argument("--out", type=Path, default=DEFAULT_CLIMATE_DATA)
|
||||||
|
parser.add_argument("--climatology-start-year", type=int, default=1991)
|
||||||
|
parser.add_argument("--climatology-end-year", type=int, default=2020)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the CSV update."""
|
||||||
|
args = parse_args()
|
||||||
|
apply_precipitation_month_metrics(
|
||||||
|
climate_data=args.climate_data,
|
||||||
|
counties_geojson=args.counties_geojson,
|
||||||
|
monthly_prcp_nc=args.monthly_prcp_nc,
|
||||||
|
out=args.out,
|
||||||
|
climatology_start_year=args.climatology_start_year,
|
||||||
|
climatology_end_year=args.climatology_end_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build county-level climate records for the web app filters.
|
||||||
|
|
||||||
|
Outputs a CSV file compatible with the browser app:
|
||||||
|
climate-data.csv
|
||||||
|
|
||||||
|
Metrics produced per county:
|
||||||
|
- koppenZone: majority Koppen-Geiger class
|
||||||
|
- avgTempF: annual mean temperature from NOAA 1991-2020 gridded normals
|
||||||
|
- annualPrecipIn: annual total precipitation from NOAA 1991-2020 gridded normals
|
||||||
|
- seasonalityIndex: precipitation seasonality, coefficient of variation of monthly totals (%)
|
||||||
|
- wettestPrecipMonth: month with the highest 1991-2020 county mean precipitation
|
||||||
|
- driestPrecipMonth: month with the lowest 1991-2020 county mean precipitation
|
||||||
|
- extremeDays: count of normal-days with Tmax >= hot threshold or Tmin <= freeze threshold
|
||||||
|
- avgSolarGhiKwhM2Day: annual average daily global horizontal irradiance (GHI), when a solar raster or representative-point CSV is provided
|
||||||
|
|
||||||
|
This script is intended for offline generation of complete county records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import geopandas as gpd
|
||||||
|
import numpy as np
|
||||||
|
import rasterio
|
||||||
|
from rasterio.features import geometry_mask
|
||||||
|
import xarray as xr
|
||||||
|
|
||||||
|
DEFAULT_COUNTIES_GEOJSON_URL = "https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json"
|
||||||
|
|
||||||
|
|
||||||
|
STATE_FIPS_TO_ABBR = {
|
||||||
|
"01": "AL",
|
||||||
|
"02": "AK",
|
||||||
|
"04": "AZ",
|
||||||
|
"05": "AR",
|
||||||
|
"06": "CA",
|
||||||
|
"08": "CO",
|
||||||
|
"09": "CT",
|
||||||
|
"10": "DE",
|
||||||
|
"11": "DC",
|
||||||
|
"12": "FL",
|
||||||
|
"13": "GA",
|
||||||
|
"15": "HI",
|
||||||
|
"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",
|
||||||
|
"60": "AS",
|
||||||
|
"66": "GU",
|
||||||
|
"69": "MP",
|
||||||
|
"72": "PR",
|
||||||
|
"78": "VI",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Beck et al legend key is expected as text file, but this default handles common codes.
|
||||||
|
DEFAULT_KOPPEN_CODE_MAP = {
|
||||||
|
1: "Af",
|
||||||
|
2: "Am",
|
||||||
|
3: "Aw",
|
||||||
|
4: "BWh",
|
||||||
|
5: "BWk",
|
||||||
|
6: "BSh",
|
||||||
|
7: "BSk",
|
||||||
|
8: "Csa",
|
||||||
|
9: "Csb",
|
||||||
|
10: "Csc",
|
||||||
|
11: "Cwa",
|
||||||
|
12: "Cwb",
|
||||||
|
13: "Cwc",
|
||||||
|
14: "Cfa",
|
||||||
|
15: "Cfb",
|
||||||
|
16: "Cfc",
|
||||||
|
17: "Dsa",
|
||||||
|
18: "Dsb",
|
||||||
|
19: "Dsc",
|
||||||
|
20: "Dsd",
|
||||||
|
21: "Dwa",
|
||||||
|
22: "Dwb",
|
||||||
|
23: "Dwc",
|
||||||
|
24: "Dwd",
|
||||||
|
25: "Dfa",
|
||||||
|
26: "Dfb",
|
||||||
|
27: "Dfc",
|
||||||
|
28: "Dfd",
|
||||||
|
29: "ET",
|
||||||
|
30: "EF",
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTH_NAMES = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fips(value: object, width: int) -> str:
|
||||||
|
"""Return a zero-padded FIPS code with the requested width."""
|
||||||
|
text = str(value).strip()
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
if not digits:
|
||||||
|
return ""
|
||||||
|
return digits.zfill(width)[-width:]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_counties(counties_geojson: Path) -> gpd.GeoDataFrame:
|
||||||
|
"""Load county polygons and normalize fields used downstream."""
|
||||||
|
if not counties_geojson.exists():
|
||||||
|
try:
|
||||||
|
print(
|
||||||
|
f"County GeoJSON not found at {counties_geojson}. "
|
||||||
|
f"Attempting download from {DEFAULT_COUNTIES_GEOJSON_URL}..."
|
||||||
|
)
|
||||||
|
gdf = gpd.read_file(DEFAULT_COUNTIES_GEOJSON_URL)
|
||||||
|
counties_geojson.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Cache the downloaded file for subsequent runs.
|
||||||
|
gdf.to_file(counties_geojson, driver="GeoJSON")
|
||||||
|
print(f"Downloaded and cached county GeoJSON to {counties_geojson}")
|
||||||
|
except Exception as exc:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"County GeoJSON not found at {counties_geojson}, and download from "
|
||||||
|
f"{DEFAULT_COUNTIES_GEOJSON_URL} failed. Download the file manually "
|
||||||
|
"and rerun with --counties-geojson pointing to it."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
gdf = gpd.read_file(counties_geojson)
|
||||||
|
if gdf.crs is None:
|
||||||
|
gdf = gdf.set_crs("EPSG:4326")
|
||||||
|
else:
|
||||||
|
gdf = gdf.to_crs("EPSG:4326")
|
||||||
|
|
||||||
|
feature_id = None
|
||||||
|
if "id" in gdf.columns:
|
||||||
|
feature_id = gdf["id"]
|
||||||
|
elif "GEOID" in gdf.columns:
|
||||||
|
feature_id = gdf["GEOID"]
|
||||||
|
elif "GEOID10" in gdf.columns:
|
||||||
|
feature_id = gdf["GEOID10"]
|
||||||
|
elif "fips" in gdf.columns:
|
||||||
|
feature_id = gdf["fips"]
|
||||||
|
else:
|
||||||
|
raise ValueError("Unable to locate county FIPS identifier column in county polygons.")
|
||||||
|
|
||||||
|
gdf["county_fips"] = feature_id.map(lambda value: _normalize_fips(value, 5))
|
||||||
|
gdf = gdf[gdf["county_fips"] != ""].copy()
|
||||||
|
|
||||||
|
if "NAME" in gdf.columns:
|
||||||
|
gdf["county_name"] = gdf["NAME"].fillna("").astype(str).str.strip()
|
||||||
|
elif "name" in gdf.columns:
|
||||||
|
gdf["county_name"] = gdf["name"].fillna("").astype(str).str.strip()
|
||||||
|
else:
|
||||||
|
gdf["county_name"] = gdf["county_fips"].map(lambda value: f"County {value}")
|
||||||
|
|
||||||
|
gdf["state_fips"] = gdf["county_fips"].str.slice(0, 2)
|
||||||
|
gdf["state"] = gdf["state_fips"].map(lambda code: STATE_FIPS_TO_ABBR.get(code, f"S{code}"))
|
||||||
|
gdf = gdf.sort_values("county_fips").reset_index(drop=True)
|
||||||
|
return gdf
|
||||||
|
|
||||||
|
|
||||||
|
def _load_koppen_legend(legend_path: Path | None) -> Dict[int, str]:
|
||||||
|
"""Load Koppen raster codes, using defaults when no legend exists."""
|
||||||
|
if legend_path is None:
|
||||||
|
return DEFAULT_KOPPEN_CODE_MAP
|
||||||
|
|
||||||
|
mapping: Dict[int, str] = {}
|
||||||
|
for line in legend_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
if not text or text.startswith("#"):
|
||||||
|
continue
|
||||||
|
# Handles patterns like:
|
||||||
|
# "1: Af ..." or "1 = Af" or "1 Af"
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.match(r"^(\d+)\s*[:=]?\s*([A-Za-z]{2,3})\b", text)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = int(match.group(1))
|
||||||
|
value = match.group(2)
|
||||||
|
mapping[key] = value
|
||||||
|
|
||||||
|
return mapping if mapping else DEFAULT_KOPPEN_CODE_MAP
|
||||||
|
|
||||||
|
|
||||||
|
def _select_data_var(dataset: xr.Dataset, preferred: str) -> str:
|
||||||
|
"""Choose the best matching climate variable from a dataset."""
|
||||||
|
if preferred in dataset.data_vars:
|
||||||
|
return preferred
|
||||||
|
|
||||||
|
alias_map = {
|
||||||
|
"mlytavg_norm": ["tavg", "tavg_norm"],
|
||||||
|
"mlyprcp_norm": ["prcp", "prcp_norm"],
|
||||||
|
"dlytmax_norm": ["tmax", "tmax_norm"],
|
||||||
|
"dlytmin_norm": ["tmin", "tmin_norm"],
|
||||||
|
}
|
||||||
|
for alias in alias_map.get(preferred, []):
|
||||||
|
if alias in dataset.data_vars:
|
||||||
|
return alias
|
||||||
|
|
||||||
|
for candidate in dataset.data_vars:
|
||||||
|
if candidate.endswith("_norm"):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# If the dataset only has one variable, use it as a final fallback.
|
||||||
|
if len(dataset.data_vars) == 1:
|
||||||
|
return next(iter(dataset.data_vars))
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to select a climate variable. Preferred='{preferred}', available={list(dataset.data_vars)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_solar_ghi_csv(solar_ghi_csv: Path, counties: gpd.GeoDataFrame) -> List[float]:
|
||||||
|
"""Load county-keyed average daily GHI values from a representative-point or area-average CSV."""
|
||||||
|
with solar_ghi_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
raise ValueError(f"Solar GHI CSV at {solar_ghi_csv} has no header row.")
|
||||||
|
|
||||||
|
if "county_fips" in reader.fieldnames:
|
||||||
|
fips_field = "county_fips"
|
||||||
|
elif "countyFips" in reader.fieldnames:
|
||||||
|
fips_field = "countyFips"
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Solar GHI CSV at {solar_ghi_csv} must include county_fips or countyFips."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "avgSolarGhiKwhM2Day" not in reader.fieldnames:
|
||||||
|
raise ValueError(
|
||||||
|
f"Solar GHI CSV at {solar_ghi_csv} must include avgSolarGhiKwhM2Day."
|
||||||
|
)
|
||||||
|
|
||||||
|
solar_by_fips: Dict[str, float] = {}
|
||||||
|
for row in reader:
|
||||||
|
county_fips = _normalize_fips(row.get(fips_field, ""), 5)
|
||||||
|
raw_value = str(row.get("avgSolarGhiKwhM2Day", "")).strip()
|
||||||
|
if not county_fips or not raw_value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
solar_by_fips[county_fips] = float(raw_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid avgSolarGhiKwhM2Day value for county {county_fips}: {raw_value}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return [
|
||||||
|
solar_by_fips.get(str(row["county_fips"]), float("nan"))
|
||||||
|
for _, row in counties.iterrows()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _as_monthly_climatology(data_array: xr.DataArray, start_year: int, end_year: int) -> xr.DataArray:
|
||||||
|
"""Return 12 monthly normals from slices or a time series."""
|
||||||
|
if "time" not in data_array.dims:
|
||||||
|
raise ValueError(f"Expected a time dimension, got dims={data_array.dims}")
|
||||||
|
|
||||||
|
time_size = int(data_array.sizes.get("time", 0))
|
||||||
|
if time_size == 12:
|
||||||
|
return data_array
|
||||||
|
|
||||||
|
time_index = data_array["time"]
|
||||||
|
if not hasattr(time_index, "dt"):
|
||||||
|
# Attempt CF decoding when time is numeric with units/calendar attrs.
|
||||||
|
try:
|
||||||
|
decoded = xr.decode_cf(xr.Dataset({"_v": data_array}))
|
||||||
|
data_array = decoded["_v"]
|
||||||
|
time_index = data_array["time"]
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Time coordinate does not support datetime access for monthly climatology grouping, "
|
||||||
|
"and CF decoding failed."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not hasattr(time_index, "dt"):
|
||||||
|
raise ValueError("Time coordinate does not support datetime access for monthly climatology grouping.")
|
||||||
|
|
||||||
|
period = data_array.where(
|
||||||
|
(time_index.dt.year >= start_year) & (time_index.dt.year <= end_year),
|
||||||
|
drop=True,
|
||||||
|
)
|
||||||
|
if int(period.sizes.get("time", 0)) == 0:
|
||||||
|
raise ValueError(f"No monthly data found between {start_year} and {end_year}.")
|
||||||
|
|
||||||
|
monthly = period.groupby("time.month").mean("time", skipna=True)
|
||||||
|
if int(monthly.sizes.get("month", 0)) != 12:
|
||||||
|
raise ValueError(
|
||||||
|
f"Monthly climatology for {start_year}-{end_year} has {monthly.sizes.get('month', 0)} months; expected 12."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Standardize to a `time` dimension so downstream code can reuse .isel(time=month_idx).
|
||||||
|
monthly = monthly.rename({"month": "time"})
|
||||||
|
monthly = monthly.assign_coords(time=np.arange(1, 13))
|
||||||
|
return monthly
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_time_resolution_days(data_array: xr.DataArray) -> float:
|
||||||
|
"""Estimate the median spacing between time steps in days."""
|
||||||
|
time_values = np.asarray(data_array["time"].values)
|
||||||
|
if time_values.size < 2:
|
||||||
|
return float("inf")
|
||||||
|
|
||||||
|
deltas = np.diff(time_values).astype("timedelta64[D]").astype(np.int64)
|
||||||
|
deltas = deltas[deltas > 0]
|
||||||
|
if deltas.size == 0:
|
||||||
|
return float("inf")
|
||||||
|
return float(np.median(deltas))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_grid_2d(data_array: xr.DataArray) -> Tuple[np.ndarray, "Affine"]:
|
||||||
|
"""Convert a lat/lon slice to a raster array and transform."""
|
||||||
|
# Expected shape for 2D arrays: lat, lon
|
||||||
|
# Build affine from center coordinates.
|
||||||
|
lon = np.asarray(data_array["lon"].values, dtype=np.float64)
|
||||||
|
lat = np.asarray(data_array["lat"].values, dtype=np.float64)
|
||||||
|
arr = np.asarray(data_array.values, dtype=np.float64)
|
||||||
|
|
||||||
|
if arr.shape != (lat.size, lon.size):
|
||||||
|
raise ValueError(f"Unexpected grid shape {arr.shape}; expected {(lat.size, lon.size)}")
|
||||||
|
|
||||||
|
# Make sure the first row is northernmost to match affine from top-left.
|
||||||
|
if lat[0] < lat[-1]:
|
||||||
|
lat = lat[::-1]
|
||||||
|
arr = arr[::-1, :]
|
||||||
|
|
||||||
|
x_res = abs(lon[1] - lon[0])
|
||||||
|
y_res = abs(lat[0] - lat[1])
|
||||||
|
|
||||||
|
from affine import Affine
|
||||||
|
|
||||||
|
top_left_x = lon.min() - (x_res / 2.0)
|
||||||
|
top_left_y = lat.max() + (y_res / 2.0)
|
||||||
|
transform = Affine.translation(top_left_x, top_left_y) * Affine.scale(x_res, -y_res)
|
||||||
|
return arr, transform
|
||||||
|
|
||||||
|
|
||||||
|
def _zonal_mean(arr: np.ndarray, transform, counties: gpd.GeoDataFrame) -> List[float]:
|
||||||
|
"""Calculate the mean raster value inside each county polygon."""
|
||||||
|
values = np.asarray(arr, dtype=np.float64)
|
||||||
|
means: List[float] = []
|
||||||
|
for geometry in counties.geometry:
|
||||||
|
mask = geometry_mask(
|
||||||
|
[geometry.__geo_interface__],
|
||||||
|
out_shape=values.shape,
|
||||||
|
transform=transform,
|
||||||
|
invert=True,
|
||||||
|
all_touched=True,
|
||||||
|
)
|
||||||
|
selected = values[mask]
|
||||||
|
selected = selected[np.isfinite(selected)]
|
||||||
|
means.append(float(selected.mean()) if selected.size else float("nan"))
|
||||||
|
return means
|
||||||
|
|
||||||
|
|
||||||
|
def _zonal_mean_raster(raster_path: Path, counties: gpd.GeoDataFrame) -> List[float]:
|
||||||
|
"""Calculate the mean raster-file value for each county."""
|
||||||
|
with rasterio.open(raster_path) as source:
|
||||||
|
raster_counties = counties
|
||||||
|
if source.crs is not None and counties.crs is not None and counties.crs != source.crs:
|
||||||
|
raster_counties = counties.to_crs(source.crs)
|
||||||
|
|
||||||
|
data = source.read(1, masked=True)
|
||||||
|
values = np.asarray(data.filled(np.nan), dtype=np.float64)
|
||||||
|
if source.nodata is not None:
|
||||||
|
values = np.where(values == source.nodata, np.nan, values)
|
||||||
|
return _zonal_mean(values, source.transform, raster_counties)
|
||||||
|
|
||||||
|
|
||||||
|
def _zonal_majority_class(koppen_raster: Path, counties: gpd.GeoDataFrame, code_map: Dict[int, str]) -> List[str]:
|
||||||
|
"""Assign each county its most common Koppen-Geiger class."""
|
||||||
|
classes: List[str] = []
|
||||||
|
with rasterio.open(koppen_raster) as source:
|
||||||
|
raster_counties = counties
|
||||||
|
if source.crs is not None and counties.crs is not None and counties.crs != source.crs:
|
||||||
|
raster_counties = counties.to_crs(source.crs)
|
||||||
|
|
||||||
|
data = source.read(1, masked=True)
|
||||||
|
values = np.asarray(data.filled(0))
|
||||||
|
if source.nodata is not None:
|
||||||
|
values = np.where(values == source.nodata, 0, values)
|
||||||
|
|
||||||
|
for geometry in raster_counties.geometry:
|
||||||
|
mask = geometry_mask(
|
||||||
|
[geometry.__geo_interface__],
|
||||||
|
out_shape=values.shape,
|
||||||
|
transform=source.transform,
|
||||||
|
invert=True,
|
||||||
|
all_touched=True,
|
||||||
|
)
|
||||||
|
selected = values[mask]
|
||||||
|
selected = selected[selected != 0]
|
||||||
|
if selected.size == 0:
|
||||||
|
classes.append("Cfa")
|
||||||
|
continue
|
||||||
|
|
||||||
|
codes, counts = np.unique(selected.astype(np.int64), return_counts=True)
|
||||||
|
code_int = int(codes[int(np.argmax(counts))])
|
||||||
|
classes.append(code_map.get(code_int, "Cfa"))
|
||||||
|
return classes
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_extreme_days(
|
||||||
|
tmax_daily: xr.Dataset,
|
||||||
|
tmin_daily: xr.Dataset,
|
||||||
|
counties: gpd.GeoDataFrame,
|
||||||
|
hot_threshold_c: float,
|
||||||
|
freeze_threshold_c: float,
|
||||||
|
) -> List[float]:
|
||||||
|
"""Count daily hot or freezing days for each county."""
|
||||||
|
tmax_var = _select_data_var(tmax_daily, "dlytmax_norm")
|
||||||
|
tmin_var = _select_data_var(tmin_daily, "dlytmin_norm")
|
||||||
|
|
||||||
|
tmax = tmax_daily[tmax_var]
|
||||||
|
tmin = tmin_daily[tmin_var]
|
||||||
|
|
||||||
|
# If datasets differ slightly in day count, use the overlapping day count.
|
||||||
|
day_count = int(min(tmax.sizes["time"], tmin.sizes["time"]))
|
||||||
|
totals = np.zeros(len(counties), dtype=np.float64)
|
||||||
|
|
||||||
|
for day in range(day_count):
|
||||||
|
tmax_arr, tmax_transform = _extract_grid_2d(tmax.isel(time=day))
|
||||||
|
tmin_arr, tmin_transform = _extract_grid_2d(tmin.isel(time=day))
|
||||||
|
|
||||||
|
if tmax_transform != tmin_transform:
|
||||||
|
raise ValueError("Daily tmax/tmin grids do not share the same transform.")
|
||||||
|
|
||||||
|
day_tmax = _zonal_mean(tmax_arr, tmax_transform, counties)
|
||||||
|
day_tmin = _zonal_mean(tmin_arr, tmin_transform, counties)
|
||||||
|
|
||||||
|
for idx, (mx, mn) in enumerate(zip(day_tmax, day_tmin)):
|
||||||
|
if np.isnan(mx) or np.isnan(mn):
|
||||||
|
continue
|
||||||
|
if mx >= hot_threshold_c or mn <= freeze_threshold_c:
|
||||||
|
totals[idx] += 1.0
|
||||||
|
|
||||||
|
return totals.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_extreme_days_monthly_proxy(
|
||||||
|
tmax_monthly: xr.DataArray,
|
||||||
|
tmin_monthly: xr.DataArray,
|
||||||
|
counties: gpd.GeoDataFrame,
|
||||||
|
hot_threshold_c: float,
|
||||||
|
freeze_threshold_c: float,
|
||||||
|
) -> List[float]:
|
||||||
|
"""Estimate extreme days from monthly tmax and tmin means."""
|
||||||
|
if int(tmax_monthly.sizes.get("time", 0)) != 12 or int(tmin_monthly.sizes.get("time", 0)) != 12:
|
||||||
|
raise ValueError("Monthly proxy for extremeDays requires 12 monthly slices for tmax and tmin.")
|
||||||
|
|
||||||
|
month_days = np.array([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], dtype=np.float64)
|
||||||
|
totals = np.zeros(len(counties), dtype=np.float64)
|
||||||
|
|
||||||
|
for month in range(12):
|
||||||
|
tmax_arr, tmax_transform = _extract_grid_2d(tmax_monthly.isel(time=month))
|
||||||
|
tmin_arr, tmin_transform = _extract_grid_2d(tmin_monthly.isel(time=month))
|
||||||
|
if tmax_transform != tmin_transform:
|
||||||
|
raise ValueError("Monthly proxy tmax/tmin grids do not share the same transform.")
|
||||||
|
|
||||||
|
month_tmax = _zonal_mean(tmax_arr, tmax_transform, counties)
|
||||||
|
month_tmin = _zonal_mean(tmin_arr, tmin_transform, counties)
|
||||||
|
|
||||||
|
for idx, (mx, mn) in enumerate(zip(month_tmax, month_tmin)):
|
||||||
|
if np.isnan(mx) or np.isnan(mn):
|
||||||
|
continue
|
||||||
|
if mx >= hot_threshold_c or mn <= freeze_threshold_c:
|
||||||
|
totals[idx] += month_days[month]
|
||||||
|
|
||||||
|
return totals.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def _precip_month_extremes(prcp_months_mm: np.ndarray) -> tuple[str | None, str | None]:
|
||||||
|
"""Return wettest and driest month names from 12 monthly precipitation totals."""
|
||||||
|
valid_mask = np.isfinite(prcp_months_mm)
|
||||||
|
if not np.any(valid_mask):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
comparable = np.where(valid_mask, prcp_months_mm, np.nan)
|
||||||
|
wettest_month = MONTH_NAMES[int(np.nanargmax(comparable))]
|
||||||
|
driest_month = MONTH_NAMES[int(np.nanargmin(comparable))]
|
||||||
|
return wettest_month, driest_month
|
||||||
|
|
||||||
|
|
||||||
|
def build_county_records(
|
||||||
|
counties_geojson: Path,
|
||||||
|
koppen_raster: Path,
|
||||||
|
koppen_legend: Path | None,
|
||||||
|
monthly_tavg_nc: Path,
|
||||||
|
monthly_prcp_nc: Path,
|
||||||
|
daily_tmax_nc: Path,
|
||||||
|
daily_tmin_nc: Path,
|
||||||
|
hot_threshold_f: float,
|
||||||
|
freeze_threshold_f: float,
|
||||||
|
climatology_start_year: int,
|
||||||
|
climatology_end_year: int,
|
||||||
|
extreme_days_mode: str,
|
||||||
|
solar_ghi_raster: Path | None,
|
||||||
|
solar_ghi_csv: Path | None,
|
||||||
|
) -> Dict[str, dict]:
|
||||||
|
"""Build county climate records consumed by the web app."""
|
||||||
|
counties = _load_counties(counties_geojson)
|
||||||
|
|
||||||
|
koppen_classes = _zonal_majority_class(koppen_raster, counties, _load_koppen_legend(koppen_legend))
|
||||||
|
|
||||||
|
monthly_tavg = xr.open_dataset(monthly_tavg_nc, decode_times=True)
|
||||||
|
monthly_prcp = xr.open_dataset(monthly_prcp_nc, decode_times=True)
|
||||||
|
daily_tmax = xr.open_dataset(daily_tmax_nc, decode_times=True)
|
||||||
|
daily_tmin = xr.open_dataset(daily_tmin_nc, decode_times=True)
|
||||||
|
|
||||||
|
tavg_var = _select_data_var(monthly_tavg, "mlytavg_norm")
|
||||||
|
prcp_var = _select_data_var(monthly_prcp, "mlyprcp_norm")
|
||||||
|
|
||||||
|
tavg_monthly = _as_monthly_climatology(
|
||||||
|
monthly_tavg[tavg_var],
|
||||||
|
start_year=climatology_start_year,
|
||||||
|
end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
prcp_monthly = _as_monthly_climatology(
|
||||||
|
monthly_prcp[prcp_var],
|
||||||
|
start_year=climatology_start_year,
|
||||||
|
end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
tavg_by_month: List[List[float]] = []
|
||||||
|
prcp_by_month_mm: List[List[float]] = []
|
||||||
|
for month in range(12):
|
||||||
|
tavg_arr, tavg_transform = _extract_grid_2d(tavg_monthly.isel(time=month))
|
||||||
|
prcp_arr, prcp_transform = _extract_grid_2d(prcp_monthly.isel(time=month))
|
||||||
|
|
||||||
|
if tavg_transform != prcp_transform:
|
||||||
|
raise ValueError("Monthly tavg/prcp grids do not share the same transform.")
|
||||||
|
|
||||||
|
tavg_by_month.append(_zonal_mean(tavg_arr, tavg_transform, counties))
|
||||||
|
prcp_by_month_mm.append(_zonal_mean(prcp_arr, prcp_transform, counties))
|
||||||
|
|
||||||
|
hot_threshold_c = (hot_threshold_f - 32.0) * (5.0 / 9.0)
|
||||||
|
freeze_threshold_c = (freeze_threshold_f - 32.0) * (5.0 / 9.0)
|
||||||
|
|
||||||
|
tmax_var = _select_data_var(daily_tmax, "dlytmax_norm")
|
||||||
|
tmin_var = _select_data_var(daily_tmin, "dlytmin_norm")
|
||||||
|
tmax_da = daily_tmax[tmax_var]
|
||||||
|
tmin_da = daily_tmin[tmin_var]
|
||||||
|
resolution_days = min(_infer_time_resolution_days(tmax_da), _infer_time_resolution_days(tmin_da))
|
||||||
|
|
||||||
|
# Daily-like input (true daily normals or daily grids).
|
||||||
|
if resolution_days <= 2.0:
|
||||||
|
extreme_days = _compute_extreme_days(
|
||||||
|
daily_tmax=daily_tmax,
|
||||||
|
daily_tmin=daily_tmin,
|
||||||
|
counties=counties,
|
||||||
|
hot_threshold_c=hot_threshold_c,
|
||||||
|
freeze_threshold_c=freeze_threshold_c,
|
||||||
|
)
|
||||||
|
extreme_days_source_tag = "daily"
|
||||||
|
else:
|
||||||
|
if extreme_days_mode == "require-daily":
|
||||||
|
raise ValueError(
|
||||||
|
"Extreme-days inputs appear to be monthly series, but --extreme-days-mode=require-daily was set."
|
||||||
|
)
|
||||||
|
|
||||||
|
tmax_monthly = _as_monthly_climatology(
|
||||||
|
tmax_da,
|
||||||
|
start_year=climatology_start_year,
|
||||||
|
end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
tmin_monthly = _as_monthly_climatology(
|
||||||
|
tmin_da,
|
||||||
|
start_year=climatology_start_year,
|
||||||
|
end_year=climatology_end_year,
|
||||||
|
)
|
||||||
|
extreme_days = _compute_extreme_days_monthly_proxy(
|
||||||
|
tmax_monthly=tmax_monthly,
|
||||||
|
tmin_monthly=tmin_monthly,
|
||||||
|
counties=counties,
|
||||||
|
hot_threshold_c=hot_threshold_c,
|
||||||
|
freeze_threshold_c=freeze_threshold_c,
|
||||||
|
)
|
||||||
|
extreme_days_source_tag = "monthly-proxy"
|
||||||
|
|
||||||
|
if solar_ghi_raster is not None and solar_ghi_raster.exists():
|
||||||
|
solar_ghi_kwh_m2_day = _zonal_mean_raster(solar_ghi_raster, counties)
|
||||||
|
solar_source_tag = "solar-ghi-raster"
|
||||||
|
elif solar_ghi_csv is not None and solar_ghi_csv.exists():
|
||||||
|
solar_ghi_kwh_m2_day = _load_solar_ghi_csv(solar_ghi_csv, counties)
|
||||||
|
solar_source_tag = "solar-ghi-representative-point"
|
||||||
|
else:
|
||||||
|
if solar_ghi_raster is not None:
|
||||||
|
print(f"Solar GHI raster not found at {solar_ghi_raster}; leaving avgSolarGhiKwhM2Day blank.")
|
||||||
|
if solar_ghi_csv is not None:
|
||||||
|
print(f"Solar GHI CSV not found at {solar_ghi_csv}; leaving avgSolarGhiKwhM2Day blank.")
|
||||||
|
solar_ghi_kwh_m2_day = [float("nan")] * len(counties)
|
||||||
|
solar_source_tag = "no-solar-ghi-source"
|
||||||
|
|
||||||
|
records: Dict[str, dict] = {}
|
||||||
|
missing_numeric_count = 0
|
||||||
|
for idx, row in counties.iterrows():
|
||||||
|
county_fips = row["county_fips"]
|
||||||
|
county_name = row["county_name"]
|
||||||
|
state = row["state"]
|
||||||
|
koppen_zone = koppen_classes[idx]
|
||||||
|
|
||||||
|
temp_months_c = np.array([tavg_by_month[m][idx] for m in range(12)], dtype=np.float64)
|
||||||
|
prcp_months_mm = np.array([prcp_by_month_mm[m][idx] for m in range(12)], dtype=np.float64)
|
||||||
|
|
||||||
|
# Guard against nodata counties (outside CONUS grids, islands, etc.).
|
||||||
|
valid_temp = temp_months_c[np.isfinite(temp_months_c)]
|
||||||
|
valid_prcp = prcp_months_mm[np.isfinite(prcp_months_mm)]
|
||||||
|
|
||||||
|
avg_temp_c = np.nanmean(valid_temp) if valid_temp.size else np.nan
|
||||||
|
annual_prcp_mm = np.nansum(valid_prcp) if valid_prcp.size else np.nan
|
||||||
|
|
||||||
|
# Precipitation seasonality as coefficient of variation, capped 0-100.
|
||||||
|
if valid_prcp.size:
|
||||||
|
mean_prcp = float(np.nanmean(valid_prcp))
|
||||||
|
std_prcp = float(np.nanstd(valid_prcp))
|
||||||
|
seasonality = 0.0 if mean_prcp <= 0 else max(0.0, min(100.0, (std_prcp / mean_prcp) * 100.0))
|
||||||
|
else:
|
||||||
|
seasonality = np.nan
|
||||||
|
wettest_precip_month, driest_precip_month = _precip_month_extremes(prcp_months_mm)
|
||||||
|
|
||||||
|
use_missing_temp = not np.isfinite(avg_temp_c)
|
||||||
|
use_missing_prcp = not np.isfinite(annual_prcp_mm)
|
||||||
|
use_missing_seasonality = not np.isfinite(seasonality)
|
||||||
|
use_missing_base_noaa = use_missing_temp or use_missing_prcp or use_missing_seasonality
|
||||||
|
use_missing_extreme = use_missing_base_noaa or not np.isfinite(extreme_days[idx])
|
||||||
|
used_any_missing_numeric = (
|
||||||
|
use_missing_temp or use_missing_prcp or use_missing_seasonality or use_missing_extreme
|
||||||
|
)
|
||||||
|
|
||||||
|
if used_any_missing_numeric:
|
||||||
|
missing_numeric_count += 1
|
||||||
|
|
||||||
|
avg_temp_f = (
|
||||||
|
round((float(avg_temp_c) * 9.0 / 5.0) + 32.0, 1)
|
||||||
|
if not use_missing_temp
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
annual_prcp_in = (
|
||||||
|
round(float(annual_prcp_mm) / 25.4, 1)
|
||||||
|
if not use_missing_prcp
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
seasonality_idx = (
|
||||||
|
int(round(float(seasonality)))
|
||||||
|
if not use_missing_seasonality
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
extreme_days_value = (
|
||||||
|
int(round(float(extreme_days[idx])))
|
||||||
|
if not use_missing_extreme
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
avg_solar_ghi_value = (
|
||||||
|
round(float(solar_ghi_kwh_m2_day[idx]), 2)
|
||||||
|
if np.isfinite(solar_ghi_kwh_m2_day[idx])
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
source_suffix = " + missing-noaa-numeric" if used_any_missing_numeric else ""
|
||||||
|
solar_source_suffix = "" if avg_solar_ghi_value is not None else " + missing-solar-ghi"
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"countyName": county_name,
|
||||||
|
"state": state,
|
||||||
|
"koppenZone": koppen_zone,
|
||||||
|
"avgTempF": avg_temp_f,
|
||||||
|
"annualPrecipIn": annual_prcp_in,
|
||||||
|
"seasonalityIndex": seasonality_idx,
|
||||||
|
"wettestPrecipMonth": wettest_precip_month,
|
||||||
|
"driestPrecipMonth": driest_precip_month,
|
||||||
|
"extremeDays": extreme_days_value,
|
||||||
|
"avgSolarGhiKwhM2Day": avg_solar_ghi_value,
|
||||||
|
"source": (
|
||||||
|
"kg-beck2023 + noaa-nclimgrid-1991-2020 "
|
||||||
|
f"({extreme_days_source_tag}) + {solar_source_tag}{source_suffix}{solar_source_suffix}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
records[county_fips] = record
|
||||||
|
|
||||||
|
monthly_tavg.close()
|
||||||
|
monthly_prcp.close()
|
||||||
|
daily_tmax.close()
|
||||||
|
daily_tmin.close()
|
||||||
|
if missing_numeric_count:
|
||||||
|
print(
|
||||||
|
f"Marked missing numeric NOAA values for {missing_numeric_count} counties "
|
||||||
|
"(likely outside NOAA grid coverage)."
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(records: Dict[str, dict], out_file: Path) -> None:
|
||||||
|
"""Write county records to the browser-loaded CSV payload."""
|
||||||
|
fields = [
|
||||||
|
"countyFips",
|
||||||
|
"countyName",
|
||||||
|
"state",
|
||||||
|
"koppenZone",
|
||||||
|
"avgTempF",
|
||||||
|
"annualPrecipIn",
|
||||||
|
"seasonalityIndex",
|
||||||
|
"wettestPrecipMonth",
|
||||||
|
"driestPrecipMonth",
|
||||||
|
"extremeDays",
|
||||||
|
"avgSolarGhiKwhM2Day",
|
||||||
|
"source",
|
||||||
|
]
|
||||||
|
|
||||||
|
with out_file.open("w", encoding="utf-8", newline="") as csv_file:
|
||||||
|
writer = csv.DictWriter(csv_file, fieldnames=fields, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
for county_fips in sorted(records):
|
||||||
|
row = {"countyFips": county_fips}
|
||||||
|
row.update(records[county_fips])
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Define and parse command-line options for this generator."""
|
||||||
|
parser = argparse.ArgumentParser(description="Generate county climate records for the web app.")
|
||||||
|
parser.add_argument("--counties-geojson", type=Path, required=True, help="County polygon GeoJSON path.")
|
||||||
|
parser.add_argument("--koppen-raster", type=Path, required=True, help="Koppen-Geiger raster TIFF path.")
|
||||||
|
parser.add_argument("--koppen-legend", type=Path, default=None, help="Optional legend.txt mapping integer codes.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--monthly-tavg-nc",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="NOAA monthly tavg netCDF (12-slice normals or monthly time-series).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--monthly-prcp-nc",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="NOAA monthly prcp netCDF (12-slice normals or monthly time-series).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--daily-tmax-nc",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="NOAA tmax netCDF (daily normals/grids preferred; monthly time-series allowed in proxy mode).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--daily-tmin-nc",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="NOAA tmin netCDF (daily normals/grids preferred; monthly time-series allowed in proxy mode).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--hot-threshold-f", type=float, default=95.0, help="Hot day threshold in Fahrenheit.")
|
||||||
|
parser.add_argument("--freeze-threshold-f", type=float, default=32.0, help="Freeze day threshold in Fahrenheit.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--climatology-start-year",
|
||||||
|
type=int,
|
||||||
|
default=1991,
|
||||||
|
help="Start year (inclusive) for monthly climatology calculation when time series files are provided.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--climatology-end-year",
|
||||||
|
type=int,
|
||||||
|
default=2020,
|
||||||
|
help="End year (inclusive) for monthly climatology calculation when time series files are provided.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extreme-days-mode",
|
||||||
|
choices=["auto", "require-daily"],
|
||||||
|
default="auto",
|
||||||
|
help="`auto` allows monthly-proxy extremeDays if daily grids are not provided; `require-daily` enforces daily input.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--solar-ghi-raster",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional raster of annual average daily GHI in kWh/m2/day for avgSolarGhiKwhM2Day.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--solar-ghi-csv",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Optional county CSV with avgSolarGhiKwhM2Day. Used as a representative-point fallback "
|
||||||
|
"when --solar-ghi-raster is not supplied."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--out", type=Path, default=Path("data/climate-data.csv"), help="Output CSV file path.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the ETL flow and write the final CSV file."""
|
||||||
|
args = parse_args()
|
||||||
|
records = build_county_records(
|
||||||
|
counties_geojson=args.counties_geojson,
|
||||||
|
koppen_raster=args.koppen_raster,
|
||||||
|
koppen_legend=args.koppen_legend,
|
||||||
|
monthly_tavg_nc=args.monthly_tavg_nc,
|
||||||
|
monthly_prcp_nc=args.monthly_prcp_nc,
|
||||||
|
daily_tmax_nc=args.daily_tmax_nc,
|
||||||
|
daily_tmin_nc=args.daily_tmin_nc,
|
||||||
|
hot_threshold_f=args.hot_threshold_f,
|
||||||
|
freeze_threshold_f=args.freeze_threshold_f,
|
||||||
|
climatology_start_year=args.climatology_start_year,
|
||||||
|
climatology_end_year=args.climatology_end_year,
|
||||||
|
extreme_days_mode=args.extreme_days_mode,
|
||||||
|
solar_ghi_raster=args.solar_ghi_raster,
|
||||||
|
solar_ghi_csv=args.solar_ghi_csv,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_csv(records=records, out_file=args.out)
|
||||||
|
print(f"Wrote {len(records)} county records to {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build representative county points for NSRDB point-based solar sampling.
|
||||||
|
|
||||||
|
The output CSV is intended as input to fetch_nsrdb_representative_point_ghi.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import geopandas as gpd
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_COUNTIES_GEOJSON = Path("data/geojson-counties-fips.json")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_representative_points.csv")
|
||||||
|
|
||||||
|
STATE_FIPS_TO_ABBR = {
|
||||||
|
"01": "AL",
|
||||||
|
"02": "AK",
|
||||||
|
"04": "AZ",
|
||||||
|
"05": "AR",
|
||||||
|
"06": "CA",
|
||||||
|
"08": "CO",
|
||||||
|
"09": "CT",
|
||||||
|
"10": "DE",
|
||||||
|
"11": "DC",
|
||||||
|
"12": "FL",
|
||||||
|
"13": "GA",
|
||||||
|
"15": "HI",
|
||||||
|
"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",
|
||||||
|
"60": "AS",
|
||||||
|
"66": "GU",
|
||||||
|
"69": "MP",
|
||||||
|
"72": "PR",
|
||||||
|
"78": "VI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fips(value: object, width: int) -> str:
|
||||||
|
"""Return a zero-padded FIPS string from a mixed text or numeric value."""
|
||||||
|
digits = "".join(character for character in str(value).strip() if character.isdigit())
|
||||||
|
return digits.zfill(width)[-width:] if digits else ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_county_points(input_geojson: Path, include_puerto_rico: bool) -> list[dict[str, str]]:
|
||||||
|
"""Load county polygons and return one interior representative point per county."""
|
||||||
|
counties = gpd.read_file(input_geojson)
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for _, county in counties.iterrows():
|
||||||
|
state_fips = normalize_fips(county.get("STATE"), 2)
|
||||||
|
county_code = normalize_fips(county.get("COUNTY"), 3)
|
||||||
|
county_fips = normalize_fips(county.get("id") or f"{state_fips}{county_code}", 5)
|
||||||
|
|
||||||
|
if not county_fips or (state_fips == "72" and not include_puerto_rico):
|
||||||
|
continue
|
||||||
|
|
||||||
|
point = county.geometry.representative_point()
|
||||||
|
county_name = str(county.get("NAME") or f"County {county_fips}").strip()
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"county_fips": county_fips,
|
||||||
|
"county_name": county_name,
|
||||||
|
"state_fips": state_fips,
|
||||||
|
"state_abbr": STATE_FIPS_TO_ABBR.get(state_fips, state_fips),
|
||||||
|
"lat": f"{point.y:.6f}",
|
||||||
|
"lon": f"{point.x:.6f}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(rows, key=lambda row: row["county_fips"])
|
||||||
|
|
||||||
|
|
||||||
|
def write_points_csv(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write county representative points to CSV."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=["county_fips", "county_name", "state_fips", "state_abbr", "lat", "lon"],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input-geojson", type=Path, default=DEFAULT_COUNTIES_GEOJSON)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-puerto-rico",
|
||||||
|
action="store_true",
|
||||||
|
help="Include Puerto Rico counties. The current web app excludes Puerto Rico polygons.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Build and write the county representative-point CSV."""
|
||||||
|
args = parse_args()
|
||||||
|
rows = build_county_points(args.input_geojson, args.include_puerto_rico)
|
||||||
|
write_points_csv(rows, args.output_csv)
|
||||||
|
print(f"Wrote {len(rows)} county representative points to {args.output_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# County Climate Data Sources and Mapping
|
||||||
|
|
||||||
|
This project now supports county polygons and county-level filter records keyed by 5-digit county FIPS.
|
||||||
|
|
||||||
|
## Launching the app
|
||||||
|
|
||||||
|
The browser blocks `fetch("data/climate-data.csv")` when `index.html` is opened directly as a `file://` URL, so run the site through a local HTTP server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\serve.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:8000/](http://localhost:8000/). This keeps the app CSV-only while allowing the map and filters to load normally.
|
||||||
|
|
||||||
|
## Source 1: Koppen-Geiger classes (`koppenZone`)
|
||||||
|
|
||||||
|
- Dataset: Beck et al. updated 1-km Koppen-Geiger climate classes (historical + future windows)
|
||||||
|
- Landing page: [https://www.gloh2o.org/koppen/](https://www.gloh2o.org/koppen/)
|
||||||
|
- Primary paper for updated release: [https://www.nature.com/articles/s41597-023-02549-6](https://www.nature.com/articles/s41597-023-02549-6)
|
||||||
|
- Coverage: 1901-2099 (use historical 1991-2020 layer for this project to align with NOAA baselines)
|
||||||
|
- License shown on dataset page: CC BY 4.0
|
||||||
|
|
||||||
|
## Source 2: NOAA 1991-2020 gridded normals (`avgTempF`, `annualPrecipIn`, `seasonalityIndex`, previous `extremeDays`)
|
||||||
|
|
||||||
|
- Main product page: [https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals](https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals)
|
||||||
|
- Monthly gridded normals readme: [https://www.ncei.noaa.gov/sites/default/files/2022-04/Readme_Monthly_Gridded_Normals.pdf](https://www.ncei.noaa.gov/sites/default/files/2022-04/Readme_Monthly_Gridded_Normals.pdf)
|
||||||
|
- Daily gridded normals readme: [https://www.ncei.noaa.gov/sites/default/files/2022-09/Readme%20for%20Daily%20Gridded%20Normals%201991-2020.pdf](https://www.ncei.noaa.gov/sites/default/files/2022-09/Readme%20for%20Daily%20Gridded%20Normals%201991-2020.pdf)
|
||||||
|
- Daily gridded normals documentation (includes units in C/mm): [https://www.ncei.noaa.gov/sites/default/files/2022-09/Documentation_Daily_Gridded_Normals%20V1.0.pdf](https://www.ncei.noaa.gov/sites/default/files/2022-09/Documentation_Daily_Gridded_Normals%20V1.0.pdf)
|
||||||
|
|
||||||
|
The ETL script uses:
|
||||||
|
|
||||||
|
- Monthly `tavg` normals (C)
|
||||||
|
- Monthly `prcp` normals (mm)
|
||||||
|
- Daily `tmax` normals (C)
|
||||||
|
- Daily `tmin` normals (C)
|
||||||
|
|
||||||
|
If you have monthly nClimGrid history files (for example `nclimgrid_tavg.nc`) rather than 12-slice normals files:
|
||||||
|
|
||||||
|
- The script now computes a 1991-2020 monthly climatology from the time series automatically.
|
||||||
|
- `extremeDays` can run in `auto` mode, which uses a documented monthly proxy when daily grids are not provided.
|
||||||
|
|
||||||
|
## Source 3: County polygons / FIPS join geometry
|
||||||
|
|
||||||
|
- Current app geometry file: `data/geojson-counties-fips.json`
|
||||||
|
- Original Plotly county geometry source: [https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json](https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json)
|
||||||
|
- Official county geometry reference (Census TIGER/Line): [https://www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html](https://www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html)
|
||||||
|
|
||||||
|
## Source 4: Solar resource (`avgSolarGhiKwhM2Day`)
|
||||||
|
|
||||||
|
- Recommended dataset: NREL National Solar Radiation Database (NSRDB)
|
||||||
|
- Data/API page: [https://developer.nrel.gov/docs/solar/nsrdb/](https://developer.nrel.gov/docs/solar/nsrdb/)
|
||||||
|
- Maps/geospatial data page: [https://www.nrel.gov/gis/solar-resource-maps](https://www.nrel.gov/gis/solar-resource-maps)
|
||||||
|
- Fallback point API option: NASA POWER `ALLSKY_SFC_SW_DWN` (surface shortwave downwelling radiation), [https://power.larc.nasa.gov/docs/tutorials/service-data-request/api/](https://power.larc.nasa.gov/docs/tutorials/service-data-request/api/)
|
||||||
|
|
||||||
|
The app metric is designed for annual average daily global horizontal irradiance (GHI), in `kWh/m2/day`.
|
||||||
|
For county means, use a gridded annual GHI raster and pass it to the generator with `--solar-ghi-raster`.
|
||||||
|
|
||||||
|
### First test: NSRDB county representative points
|
||||||
|
|
||||||
|
For a lightweight first pass, sample each county at one interior representative point instead of requesting full county polygons.
|
||||||
|
This creates a point CSV:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\build_county_representative_points.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then fetch a small NSRDB GOES TMY test batch.
|
||||||
|
If `--email` or `--api-key` are omitted, the script prompts you for them:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\fetch_nsrdb_representative_point_ghi.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
The fetch script uses GOES TMY first.
|
||||||
|
For high-latitude points, it can automatically retry the NSRDB Polar TMY endpoint when GOES reports no data.
|
||||||
|
If a county FIPS appears in `data/nrel/county_representative_point_ghi_error_log.csv` from an earlier run, that county tries the Polar endpoint first on the next run.
|
||||||
|
Disable this behavior with `--no-polar-fallback`.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `data/nrel/county_representative_points.csv`: county FIPS, name, state, latitude, and longitude.
|
||||||
|
- `data/nrel/representative_point_csv/`: cached raw NSRDB CSV responses by county FIPS.
|
||||||
|
- `data/nrel/county_representative_point_ghi_summary.csv`: summarized `avgSolarGhiKwhM2Day` values.
|
||||||
|
|
||||||
|
The calculation is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
avgSolarGhiKwhM2Day = sum(hourly GHI) / 1000 / 365
|
||||||
|
```
|
||||||
|
|
||||||
|
If the raw cache is complete but the summary CSV only contains the last fetched batch, rebuild the summary from cached files without calling the API:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\rebuild_nsrdb_representative_point_ghi_summary.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This point-based workflow is easier to validate and resume than full polygon downloads, but it is an approximation of county sunlight rather than an area-weighted county mean.
|
||||||
|
|
||||||
|
### First cloud-cover pass: NSRDB representative points
|
||||||
|
|
||||||
|
For a fast cloud-cover input layer, fetch representative-point NSRDB CSVs with observed GHI, Clearsky GHI, and Cloud Type:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\fetch_nsrdb_representative_point_cloud_metrics.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the first batch looks right, fetch every county:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\fetch_nsrdb_representative_point_cloud_metrics.py --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `data/nrel/representative_point_cloud_csv/`: cached raw NSRDB CSV responses with `ghi,clearsky_ghi,cloud_type`.
|
||||||
|
- `data/nrel/county_representative_point_cloud_summary.csv`: summarized cloud-cover proxy fields.
|
||||||
|
- `data/nrel/county_representative_point_cloud_error_log.csv`: failed county requests with redacted API context.
|
||||||
|
|
||||||
|
The primary calculation uses daylight rows where Clearsky GHI is at least 50 W/m2:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cloudinessIndexPct = 1 - mean(clamped(GHI / Clearsky GHI, 0, 1))
|
||||||
|
```
|
||||||
|
|
||||||
|
The same summary also stores daylight row counts, observed-to-clear-sky ratio, and broad Cloud Type frequency buckets. This is faster than the polygon archive workflow, but it remains a representative-point county approximation.
|
||||||
|
|
||||||
|
Apply the representative-point cloudiness metric to the browser app CSV:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\apply_nsrdb_cloud_metric_to_climate_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The current cloud summary covers the 3,143 county representative points and leaves Puerto Rico rows blank in `data/climate-data.csv`.
|
||||||
|
|
||||||
|
### County-average target: NSRDB polygon cloud archive requests
|
||||||
|
|
||||||
|
For a less noisy county-level cloudiness layer, submit county polygons to the NSRDB archive workflow with `ghi,clearsky_ghi,cloud_type`. This uses the same polygon tiling and pacing logic as the GHI archive workflow, but writes separate cloud manifests, response JSON files, archives, and summaries.
|
||||||
|
|
||||||
|
Start with a dry run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_cloud_archives.py `
|
||||||
|
--dry-run `
|
||||||
|
--skip-site-count `
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then submit a small real batch:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_cloud_archives.py `
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Continue in resumable chunks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_cloud_archives.py `
|
||||||
|
--start 5 `
|
||||||
|
--limit 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Download completed archives:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\download_nsrdb_county_polygon_cloud_archives.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Summarize downloaded archives into area-weighted county cloudiness:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\summarize_nsrdb_county_polygon_cloud_archives.py --reuse-existing-output
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `data/nrel/county_polygon_cloud_request_manifest.csv`: submitted cloud archive requests.
|
||||||
|
- `data/nrel/county_polygon_cloud_error_log.csv`: cloud archive request errors.
|
||||||
|
- `data/nrel/polygon_cloud_request_responses/`: raw NSRDB cloud acknowledgement JSON files.
|
||||||
|
- `data/nrel/polygon_cloud_archives/`: downloaded cloud ZIP archives.
|
||||||
|
- `data/nrel/county_polygon_cloud_summary.csv`: area-weighted county cloudiness summaries.
|
||||||
|
|
||||||
|
Then update the browser app CSV. Polygon area-weighted values are used first when `data/nrel/county_polygon_cloud_summary.csv` exists; representative-point values remain the fallback:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\apply_nsrdb_cloud_metric_to_climate_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### County-average target: NSRDB polygon archive requests
|
||||||
|
|
||||||
|
For the final county-average solar layer, submit county polygons to the NSRDB archive workflow instead of sampling one representative point.
|
||||||
|
The polygon request returns a generated archive URL per county. Download those archives, then summarize them into a county polygon GHI CSV that can replace the representative-point solar values where available.
|
||||||
|
|
||||||
|
Start with a small dry run that does not touch the API:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_ghi_archives.py `
|
||||||
|
--dry-run `
|
||||||
|
--skip-site-count `
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then submit a small real batch:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_ghi_archives.py `
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the first batch looks right, continue in resumable chunks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_ghi_archives.py `
|
||||||
|
--start 5 `
|
||||||
|
--limit 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Or request every county from the current start point:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\request_nsrdb_county_polygon_ghi_archives.py `
|
||||||
|
--all
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `data/nrel/county_polygon_ghi_request_manifest.csv`: one row per submitted county, including NSRDB profile, site count, request weight, response JSON path, and `download_url`.
|
||||||
|
- `data/nrel/county_polygon_ghi_error_log.csv`: counties that need retrying or tiling.
|
||||||
|
- `data/nrel/polygon_request_responses/`: raw API acknowledgement JSON files.
|
||||||
|
- `data/nrel/polygon_archives/`: downloaded county or tiled county ZIP archives.
|
||||||
|
- `data/nrel/county_polygon_ghi_summary.csv`: summarized polygon archive GHI, including `avgSolarGhiKwhM2Day`.
|
||||||
|
|
||||||
|
Download completed GHI archives:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\download_nsrdb_county_polygon_ghi_archives.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses POST requests because county polygon WKT can be long.
|
||||||
|
It checks NSRDB site count by default and skips counties whose estimated request weight exceeds the API maximum.
|
||||||
|
Default pacing is one request every 2.1 seconds, matching the NSRDB archive limit.
|
||||||
|
Use `--include-puerto-rico` if you want to test Puerto Rico polygons too.
|
||||||
|
|
||||||
|
After the archives are downloaded, rebuild or resume the polygon summary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\summarize_nsrdb_county_polygon_archives.py --reuse-existing-output
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the app CSV. Polygon archive GHI is used first; representative-point GHI remains as a fallback for counties without polygon archive data:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\apply_locally_extreme_metric_to_climate_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metric definitions in generated output
|
||||||
|
|
||||||
|
- `koppenZone`: majority class within county polygon from Koppen raster.
|
||||||
|
- `avgTempF`: mean of 12 monthly county mean temperatures, converted C -> F.
|
||||||
|
- `annualPrecipIn`: sum of 12 monthly county mean precipitation totals, converted mm -> inches.
|
||||||
|
- `seasonalityIndex`: coefficient of variation of monthly precipitation totals, scaled to 0-100.
|
||||||
|
- `wettestPrecipMonth`: month with the highest 1991-2020 county mean precipitation total.
|
||||||
|
- `driestPrecipMonth`: month with the lowest 1991-2020 county mean precipitation total.
|
||||||
|
- previous `extremeDays` / `oldExtremeDays`: count of daily-normal or monthly-proxy days where county mean `tmax >= 95F` or `tmin <= 32F` (thresholds configurable in script). This is preserved only as an audit column after the NOAA nClimGrid-Daily metrics are applied.
|
||||||
|
- `avgSolarGhiKwhM2Day`: county mean annual average daily GHI, in `kWh/m2/day`. The current app CSV uses NSRDB polygon archive area-weighted values where available, with representative-point values kept as fallback.
|
||||||
|
- `cloudinessIndexPct`: representative-point NSRDB daylight cloudiness proxy derived from observed GHI divided by Clearsky GHI. Higher values mean observed irradiance is lower relative to modeled clear-sky irradiance. Despite the legacy field name, values are stored on a 0-1 scale.
|
||||||
|
|
||||||
|
## Run the generator
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pip install -r scripts/requirements_county_etl.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/build_county_climate_data.py `
|
||||||
|
--counties-geojson data/geojson-counties-fips.json `
|
||||||
|
--koppen-raster data/koppen_geiger_tif/1991_2020/koppen_geiger_0p00833333.tif `
|
||||||
|
--koppen-legend data/koppen_geiger_tif/legend.txt `
|
||||||
|
--monthly-tavg-nc data/noaa/nclimgrid/nclimgrid_tavg.nc `
|
||||||
|
--monthly-prcp-nc data/noaa/nclimgrid/nclimgrid_prcp.nc `
|
||||||
|
--daily-tmax-nc data/noaa/nclimgrid/nclimgrid_tmax.nc `
|
||||||
|
--daily-tmin-nc data/noaa/nclimgrid/nclimgrid_tmin.nc `
|
||||||
|
--climatology-start-year 1991 `
|
||||||
|
--climatology-end-year 2020 `
|
||||||
|
--extreme-days-mode auto `
|
||||||
|
--solar-ghi-csv data/nrel/county_polygon_ghi_summary.csv `
|
||||||
|
--out data/climate-data.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This computes all counties in your geometry file, not just the sample records.
|
||||||
|
- For counties outside CONUS coverage in NOAA gridded files, fallback values are applied by the script when no valid grid values intersect.
|
||||||
|
- For physically-based daily `extremeDays`, provide true daily grids and set `--extreme-days-mode require-daily`.
|
||||||
|
- If `--counties-geojson` does not exist locally, the script will try to download the county GeoJSON automatically from the Plotly URL above and cache it at that path.
|
||||||
|
- `--solar-ghi-csv` is optional and can load county-keyed solar summaries into `avgSolarGhiKwhM2Day`.
|
||||||
|
- `--solar-ghi-raster` is optional and takes precedence over `--solar-ghi-csv`. If you have a gridded annual GHI raster, add `--solar-ghi-raster path/to/annual_ghi_kwh_m2_day.tif` for a true county-area raster mean.
|
||||||
|
|
||||||
|
## Source 5: NOAA nClimGrid-Daily county area averages (`locallyExtremeDays`)
|
||||||
|
|
||||||
|
- Main product page: [https://www.ncei.noaa.gov/products/land-based-station/nclimgrid-daily](https://www.ncei.noaa.gov/products/land-based-station/nclimgrid-daily)
|
||||||
|
- County/monthly area averages root: [https://www.ncei.noaa.gov/data/nclimgrid-daily/access/averages/](https://www.ncei.noaa.gov/data/nclimgrid-daily/access/averages/)
|
||||||
|
- User guide: [https://www.ncei.noaa.gov/data/nclimgrid-daily/doc/nclimgrid-daily_v1-0-0_user-guide.pdf](https://www.ncei.noaa.gov/data/nclimgrid-daily/doc/nclimgrid-daily_v1-0-0_user-guide.pdf)
|
||||||
|
- NCEI state-code to FIPS crosswalk: [https://www.ncei.noaa.gov/data/nclimgrid-daily/doc/us-state-codes_ncei-to-fips.csv](https://www.ncei.noaa.gov/data/nclimgrid-daily/doc/us-state-codes_ncei-to-fips.csv)
|
||||||
|
- Census boundary change notes: [https://www.census.gov/programs-surveys/geography/technical-documentation/boundary-change-notes.html](https://www.census.gov/programs-surveys/geography/technical-documentation/boundary-change-notes.html)
|
||||||
|
- Census county changes: [https://www.census.gov/programs-surveys/geography/technical-documentation/county-changes.html](https://www.census.gov/programs-surveys/geography/technical-documentation/county-changes.html)
|
||||||
|
|
||||||
|
The locally extreme metric uses county-specific 1991-2020 thresholds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
p95_tmax_c = 95th percentile of county-average daily Tmax
|
||||||
|
p05_tmin_c = 5th percentile of county-average daily Tmin
|
||||||
|
locallyExtremeDays = count(Tmax >= p95_tmax_c OR Tmin <= p05_tmin_c)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same daily county Tmax/Tmin records now also produce an absolute climate-bucket metric:
|
||||||
|
|
||||||
|
```text
|
||||||
|
absoluteExtremeDays = count(Tmax >= 95F OR Tmin <= 0F)
|
||||||
|
```
|
||||||
|
|
||||||
|
The heat threshold follows the EPA extreme-heat example of days at or above 95F.
|
||||||
|
The cold threshold is intentionally stricter than the older 32F freeze bucket because NWS cold guidance treats freezing as a freeze-warning/crop threshold and describes human extreme cold as region-dependent and often wind-chill based. The current NOAA cache does not include wind, so `Tmin <= 0F` is used as a configurable air-temperature proxy.
|
||||||
|
|
||||||
|
Run from the local NOAA CSV cache:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\build_county_locally_extreme_data.py --skip-download
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `data/noaa/county_locally_extreme_thresholds.csv`
|
||||||
|
- `data/noaa/county_locally_extreme_days.csv`
|
||||||
|
- `data/noaa/county_locally_extreme_days_comparison.csv`
|
||||||
|
|
||||||
|
Apply the locally extreme average to the browser app CSV:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe scripts\apply_locally_extreme_metric_to_climate_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps `data/climate-data.csv` compatible with the existing app by writing the locally extreme average into the active `extremeDays` column, while preserving the earlier proxy metric in `oldExtremeDays`. It also adds detail/audit columns:
|
||||||
|
|
||||||
|
- `locallyExtremeDays`
|
||||||
|
- `locallyExtremeHotDays`
|
||||||
|
- `locallyExtremeColdDays`
|
||||||
|
- `absoluteExtremeDays`
|
||||||
|
- `oldExtremeDays`
|
||||||
|
- `locallyExtremeAnalysisYears`
|
||||||
|
- `locallyExtremeSourceFips`
|
||||||
|
- `locallyExtremeFipsAdjustment`
|
||||||
|
|
||||||
|
FIPS/geography-vintage policy:
|
||||||
|
|
||||||
|
- Use current NOAA/Census county-equivalent FIPS for the locally extreme outputs.
|
||||||
|
- Correct NOAA's District of Columbia county row from source region code `18511` to Census FIPS `11001`.
|
||||||
|
- Apply only one-to-one old app comparison concordances, currently `46113 -> 46102` for Shannon/Oglala Lakota and `51515 -> 51019` for Bedford city/Bedford County.
|
||||||
|
- Do not guess split or many-to-one geography changes without a Census relationship file; leave those rows flagged in the comparison output.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download gridMET daily NetCDF files into year-based folders.
|
||||||
|
|
||||||
|
Default layout:
|
||||||
|
data/gridmet/1991/sph.nc
|
||||||
|
data/gridmet/1991/rmax.nc
|
||||||
|
data/gridmet/1991/rmin.nc
|
||||||
|
|
||||||
|
gridMET source:
|
||||||
|
https://www.northwestknowledge.net/metdata/data/{variable}_{year}.nc
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://www.northwestknowledge.net/metdata/data"
|
||||||
|
DEFAULT_VARIABLES = ("sph", "rmax", "rmin")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
start = int(start_text)
|
||||||
|
end = int(end_text)
|
||||||
|
if start > end:
|
||||||
|
raise argparse.ArgumentTypeError(f"Invalid descending year range: {token}")
|
||||||
|
years.update(range(start, end + 1))
|
||||||
|
else:
|
||||||
|
years.add(int(token))
|
||||||
|
if not years:
|
||||||
|
raise argparse.ArgumentTypeError("At least one year is required.")
|
||||||
|
return sorted(years)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_variables(text: str) -> list[str]:
|
||||||
|
variables = [part.strip() for part in text.split(",") if part.strip()]
|
||||||
|
if not variables:
|
||||||
|
raise argparse.ArgumentTypeError("At least one variable is required.")
|
||||||
|
return variables
|
||||||
|
|
||||||
|
|
||||||
|
def _download_file(url: str, destination: Path, overwrite: bool) -> bool:
|
||||||
|
if destination.exists() and not overwrite:
|
||||||
|
print(f"skip existing {destination}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
prefix=destination.stem + ".",
|
||||||
|
suffix=".download",
|
||||||
|
dir=destination.parent,
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"download {url}")
|
||||||
|
with urllib.request.urlopen(url) as response, temp_path.open("wb") as output:
|
||||||
|
total_text = response.headers.get("Content-Length")
|
||||||
|
total = int(total_text) if total_text and total_text.isdigit() else None
|
||||||
|
copied = 0
|
||||||
|
while True:
|
||||||
|
chunk = response.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output.write(chunk)
|
||||||
|
copied += len(chunk)
|
||||||
|
if total:
|
||||||
|
percent = copied / total * 100
|
||||||
|
print(f"\r {copied / 1024 / 1024:,.1f} MiB / {total / 1024 / 1024:,.1f} MiB ({percent:5.1f}%)", end="")
|
||||||
|
if total:
|
||||||
|
print()
|
||||||
|
|
||||||
|
temp_path.replace(destination)
|
||||||
|
print(f"saved {destination}")
|
||||||
|
return True
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as exc:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
print(f"failed {url}: {exc}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download gridMET NetCDF files into data/gridmet/<year>/<variable>.nc."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--years",
|
||||||
|
type=_parse_years,
|
||||||
|
default=_parse_years("1991-2020"),
|
||||||
|
help="Comma-separated years/ranges. Default: 1991-2020.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--variables",
|
||||||
|
type=_parse_variables,
|
||||||
|
default=list(DEFAULT_VARIABLES),
|
||||||
|
help="Comma-separated gridMET variables. Default: sph,rmax,rmin.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("data/gridmet"),
|
||||||
|
help="Root folder for downloaded files. Default: data/gridmet.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
default=DEFAULT_BASE_URL,
|
||||||
|
help=f"Base gridMET download URL. Default: {DEFAULT_BASE_URL}.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--overwrite",
|
||||||
|
action="store_true",
|
||||||
|
help="Redownload files that already exist.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
for year in args.years:
|
||||||
|
for variable in args.variables:
|
||||||
|
url = f"{args.base_url.rstrip('/')}/{variable}_{year}.nc"
|
||||||
|
destination = args.output_dir / str(year) / f"{variable}.nc"
|
||||||
|
if _download_file(url, destination, args.overwrite):
|
||||||
|
downloaded += 1
|
||||||
|
|
||||||
|
print(f"done; downloaded {downloaded} file(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download completed NSRDB county polygon archive ZIP files.
|
||||||
|
|
||||||
|
This shared engine reads JSON acknowledgements produced by the polygon request
|
||||||
|
workflow and downloads each outputs.downloadUrl. Metric-specific wrappers
|
||||||
|
supply the response and archive directories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 300
|
||||||
|
DEFAULT_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveDownloadState(Enum):
|
||||||
|
"""Distinct lifecycle phases for one NSRDB archive download."""
|
||||||
|
|
||||||
|
QUEUED = "queued"
|
||||||
|
CHECKING = "checking"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
PENDING = "pending"
|
||||||
|
DOWNLOADED = "downloaded"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveDownloadEvent(Enum):
|
||||||
|
"""Events that may move an archive download to another lifecycle phase."""
|
||||||
|
|
||||||
|
START = "start"
|
||||||
|
EXISTING_FILE_FOUND = "existing_file_found"
|
||||||
|
DOWNLOAD_STARTED = "download_started"
|
||||||
|
ARCHIVE_NOT_READY = "archive_not_ready"
|
||||||
|
DOWNLOAD_SUCCEEDED = "download_succeeded"
|
||||||
|
DOWNLOAD_FAILED = "download_failed"
|
||||||
|
|
||||||
|
|
||||||
|
ARCHIVE_DOWNLOAD_TRANSITIONS = {
|
||||||
|
(ArchiveDownloadState.QUEUED, ArchiveDownloadEvent.START): ArchiveDownloadState.CHECKING,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.CHECKING,
|
||||||
|
ArchiveDownloadEvent.EXISTING_FILE_FOUND,
|
||||||
|
): ArchiveDownloadState.SKIPPED,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.CHECKING,
|
||||||
|
ArchiveDownloadEvent.DOWNLOAD_STARTED,
|
||||||
|
): ArchiveDownloadState.DOWNLOADING,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.DOWNLOADING,
|
||||||
|
ArchiveDownloadEvent.ARCHIVE_NOT_READY,
|
||||||
|
): ArchiveDownloadState.PENDING,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.DOWNLOADING,
|
||||||
|
ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED,
|
||||||
|
): ArchiveDownloadState.DOWNLOADED,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.QUEUED,
|
||||||
|
ArchiveDownloadEvent.DOWNLOAD_FAILED,
|
||||||
|
): ArchiveDownloadState.FAILED,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.CHECKING,
|
||||||
|
ArchiveDownloadEvent.DOWNLOAD_FAILED,
|
||||||
|
): ArchiveDownloadState.FAILED,
|
||||||
|
(
|
||||||
|
ArchiveDownloadState.DOWNLOADING,
|
||||||
|
ArchiveDownloadEvent.DOWNLOAD_FAILED,
|
||||||
|
): ArchiveDownloadState.FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidArchiveDownloadTransition(RuntimeError):
|
||||||
|
"""Reject an event that is not valid for the current download state."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArchiveDownloadStateMachine:
|
||||||
|
"""Track and validate the lifecycle of one archive download."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
state: ArchiveDownloadState = ArchiveDownloadState.QUEUED
|
||||||
|
history: list[ArchiveDownloadState] = field(
|
||||||
|
default_factory=lambda: [ArchiveDownloadState.QUEUED]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transition(self, event: ArchiveDownloadEvent) -> ArchiveDownloadState:
|
||||||
|
"""Apply one event and return the resulting state."""
|
||||||
|
next_state = ARCHIVE_DOWNLOAD_TRANSITIONS.get((self.state, event))
|
||||||
|
if next_state is None:
|
||||||
|
raise InvalidArchiveDownloadTransition(
|
||||||
|
f"Invalid archive download transition for {self.label}: "
|
||||||
|
f"{event.value} while {self.state.value}."
|
||||||
|
)
|
||||||
|
self.state = next_state
|
||||||
|
self.history.append(next_state)
|
||||||
|
return next_state
|
||||||
|
|
||||||
|
|
||||||
|
class PolygonArchiveDownloadError(RuntimeError):
|
||||||
|
"""Store a failed archive download with useful context."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def is_pending_s3_archive_response(url: str, status_code: int) -> bool:
|
||||||
|
"""Return whether an S3 HTTP response likely means the archive is pending."""
|
||||||
|
if status_code != 403:
|
||||||
|
return False
|
||||||
|
|
||||||
|
host = urllib.parse.urlsplit(url).netloc.lower()
|
||||||
|
return host == "s3.amazonaws.com" or host.endswith(".amazonaws.com")
|
||||||
|
|
||||||
|
|
||||||
|
def read_response(response_path: Path) -> dict[str, Any]:
|
||||||
|
"""Read a saved NSRDB archive response JSON file."""
|
||||||
|
with response_path.open(encoding="utf-8") as handle:
|
||||||
|
response = json.load(handle)
|
||||||
|
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise ValueError("Response JSON must contain an object.")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def download_url_from_response(response: dict[str, Any]) -> str:
|
||||||
|
"""Return the download URL from a saved NSRDB archive response."""
|
||||||
|
outputs = response.get("outputs")
|
||||||
|
if not isinstance(outputs, dict):
|
||||||
|
raise ValueError("Response JSON is missing an outputs object.")
|
||||||
|
|
||||||
|
download_url = outputs.get("downloadUrl")
|
||||||
|
if not isinstance(download_url, str) or not download_url.strip():
|
||||||
|
raise ValueError("Response JSON is missing outputs.downloadUrl.")
|
||||||
|
|
||||||
|
return download_url.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def output_name_for_response(response_path: Path) -> str:
|
||||||
|
"""Convert a response JSON filename into the corresponding ZIP filename."""
|
||||||
|
stem = response_path.stem
|
||||||
|
if stem.endswith("_response"):
|
||||||
|
stem = stem[: -len("_response")]
|
||||||
|
return f"{stem}.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(
|
||||||
|
url: str,
|
||||||
|
output_path: Path,
|
||||||
|
timeout: int,
|
||||||
|
overwrite: bool,
|
||||||
|
machine: ArchiveDownloadStateMachine,
|
||||||
|
) -> ArchiveDownloadState:
|
||||||
|
"""Download a URL to disk and return the terminal download state."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if output_path.exists() and not overwrite:
|
||||||
|
return machine.transition(ArchiveDownloadEvent.EXISTING_FILE_FOUND)
|
||||||
|
|
||||||
|
machine.transition(ArchiveDownloadEvent.DOWNLOAD_STARTED)
|
||||||
|
temp_path = output_path.with_suffix(f"{output_path.suffix}.part")
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "county-climate-explorer/0.1"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
with temp_path.open("wb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = response.read(DEFAULT_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
handle.write(chunk)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
if is_pending_s3_archive_response(url, error.code):
|
||||||
|
return machine.transition(ArchiveDownloadEvent.ARCHIVE_NOT_READY)
|
||||||
|
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
|
||||||
|
raise PolygonArchiveDownloadError(f"HTTP {error.code} {error.reason}", error.code) from error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
|
||||||
|
raise PolygonArchiveDownloadError(str(error.reason)) from error
|
||||||
|
|
||||||
|
temp_path.replace(output_path)
|
||||||
|
return machine.transition(ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED)
|
||||||
|
|
||||||
|
|
||||||
|
def response_paths(response_dir: Path, start: int, limit: int | None) -> list[Path]:
|
||||||
|
"""Return saved response JSON files in deterministic order."""
|
||||||
|
paths = sorted(response_dir.glob("*.json"))
|
||||||
|
|
||||||
|
if start < 0:
|
||||||
|
raise ValueError("--start must be 0 or greater.")
|
||||||
|
if limit is not None and limit < 1:
|
||||||
|
raise ValueError("--limit must be 1 or greater.")
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
return paths[start:]
|
||||||
|
return paths[start : start + limit]
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> None:
|
||||||
|
"""Download all requested NSRDB polygon archives."""
|
||||||
|
paths = response_paths(args.response_dir, args.start, args.limit)
|
||||||
|
if not paths:
|
||||||
|
print(f"No response JSON files found in {args.response_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
ArchiveDownloadState.DOWNLOADED: 0,
|
||||||
|
ArchiveDownloadState.SKIPPED: 0,
|
||||||
|
ArchiveDownloadState.PENDING: 0,
|
||||||
|
ArchiveDownloadState.FAILED: 0,
|
||||||
|
}
|
||||||
|
total = len(paths)
|
||||||
|
|
||||||
|
for index, response_path in enumerate(paths, start=1):
|
||||||
|
output_path = args.output_dir / output_name_for_response(response_path)
|
||||||
|
label = response_path.name
|
||||||
|
machine = ArchiveDownloadStateMachine(label)
|
||||||
|
machine.transition(ArchiveDownloadEvent.START)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = read_response(response_path)
|
||||||
|
download_url = download_url_from_response(response)
|
||||||
|
state = download_file(
|
||||||
|
download_url,
|
||||||
|
output_path,
|
||||||
|
args.timeout,
|
||||||
|
args.overwrite,
|
||||||
|
machine,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, PolygonArchiveDownloadError) as error:
|
||||||
|
if machine.state not in {
|
||||||
|
ArchiveDownloadState.FAILED,
|
||||||
|
ArchiveDownloadState.SKIPPED,
|
||||||
|
ArchiveDownloadState.PENDING,
|
||||||
|
ArchiveDownloadState.DOWNLOADED,
|
||||||
|
}:
|
||||||
|
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
|
||||||
|
counts[machine.state] += 1
|
||||||
|
print(f"[{index}/{total}] Failed {label}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
counts[state] += 1
|
||||||
|
if state is ArchiveDownloadState.SKIPPED:
|
||||||
|
print(f"[{index}/{total}] Skipped existing {output_path}")
|
||||||
|
elif state is ArchiveDownloadState.PENDING:
|
||||||
|
print(
|
||||||
|
f"[{index}/{total}] Pending {label}: archive URL returned "
|
||||||
|
"S3 HTTP 403; retry after NSRDB finishes generating it."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"[{index}/{total}] Downloaded {output_path}")
|
||||||
|
|
||||||
|
if args.delay > 0 and index < total:
|
||||||
|
time.sleep(args.delay)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Finished: downloaded={counts[ArchiveDownloadState.DOWNLOADED]}, "
|
||||||
|
f"skipped={counts[ArchiveDownloadState.SKIPPED]}, "
|
||||||
|
f"pending={counts[ArchiveDownloadState.PENDING]}, "
|
||||||
|
f"failed={counts[ArchiveDownloadState.FAILED]}, output_dir={args.output_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=description or __doc__)
|
||||||
|
parser.add_argument("--response-dir", type=Path)
|
||||||
|
parser.add_argument("--output-dir", type=Path)
|
||||||
|
parser.add_argument("--start", type=int, default=0, help="Zero-based response file offset.")
|
||||||
|
parser.add_argument("--limit", type=int, help="Number of response files to download.")
|
||||||
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Download timeout in seconds.")
|
||||||
|
parser.add_argument("--delay", type=float, default=0.0, help="Seconds to wait between downloads.")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="Download even when the ZIP already exists.")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
option
|
||||||
|
for option, value in {
|
||||||
|
"--response-dir": args.response_dir,
|
||||||
|
"--output-dir": args.output_dir,
|
||||||
|
}.items()
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
parser.error(f"the following arguments are required: {', '.join(missing)}")
|
||||||
|
if args.timeout <= 0:
|
||||||
|
parser.error("--timeout must be greater than 0.")
|
||||||
|
if args.delay < 0:
|
||||||
|
parser.error("--delay must be 0 or greater.")
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: list[str] | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run the archive downloader."""
|
||||||
|
run(parse_args(argv, description))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download completed NSRDB county polygon cloud archive ZIP files.
|
||||||
|
|
||||||
|
This is a cloud-specific wrapper around download_nsrdb_county_polygon_archives.py
|
||||||
|
that reads cloud request responses and writes archives into a separate folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import download_nsrdb_county_polygon_archives as polygon_download
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARGS = [
|
||||||
|
"--response-dir",
|
||||||
|
"data/nrel/polygon_cloud_request_responses",
|
||||||
|
"--output-dir",
|
||||||
|
"data/nrel/polygon_cloud_archives",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the existing polygon archive downloader with cloud-specific defaults."""
|
||||||
|
polygon_download.main(
|
||||||
|
[*DEFAULT_ARGS, *sys.argv[1:]],
|
||||||
|
description=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Download completed NSRDB county polygon GHI archive ZIP files.
|
||||||
|
|
||||||
|
This is a GHI-specific wrapper around download_nsrdb_county_polygon_archives.py.
|
||||||
|
It reuses the shared download state machine with GHI-specific response and
|
||||||
|
archive directories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import download_nsrdb_county_polygon_archives as polygon_download
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARGS = [
|
||||||
|
"--response-dir",
|
||||||
|
"data/nrel/polygon_request_responses",
|
||||||
|
"--output-dir",
|
||||||
|
"data/nrel/polygon_archives",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the shared polygon archive downloader with GHI-specific defaults."""
|
||||||
|
polygon_download.main(
|
||||||
|
[*DEFAULT_ARGS, *sys.argv[1:]],
|
||||||
|
description=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fetch NSRDB representative-point inputs for a county cloud-cover metric.
|
||||||
|
|
||||||
|
This uses the direct single-point NSRDB CSV endpoint rather than polygon archive
|
||||||
|
requests. It is faster for first-pass cloud metrics because it downloads one CSV
|
||||||
|
per county point and can run multiple county requests concurrently.
|
||||||
|
|
||||||
|
Default requested attributes:
|
||||||
|
|
||||||
|
ghi,clearsky_ghi,cloud_type
|
||||||
|
|
||||||
|
The summary metric is based on daylight rows with valid GHI and Clearsky GHI:
|
||||||
|
|
||||||
|
cloudinessIndexPct = 1 - mean(clamped(GHI / Clearsky GHI))
|
||||||
|
|
||||||
|
where the ratio is clamped to [0, 1] so occasional above-clear-sky modeled GHI
|
||||||
|
does not create negative cloudiness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import csv
|
||||||
|
import getpass
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POINTS_CSV = Path("data/nrel/county_representative_points.csv")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_representative_point_cloud_summary.csv")
|
||||||
|
DEFAULT_ERROR_CSV = Path("data/nrel/county_representative_point_cloud_error_log.csv")
|
||||||
|
DEFAULT_CACHE_DIR = Path("data/nrel/representative_point_cloud_csv")
|
||||||
|
DEFAULT_ENDPOINT = "https://developer.nlr.gov/api/nsrdb/v2/solar/nsrdb-GOES-tmy-v4-0-0-download.csv"
|
||||||
|
DEFAULT_POLAR_ENDPOINT = "https://developer.nlr.gov/api/nsrdb/v2/solar/nsrdb-polar-tmy-v4-0-0-download.csv"
|
||||||
|
DEFAULT_TMY_NAME = "tmy-2024"
|
||||||
|
DEFAULT_POLAR_TMY_NAME = "tmy"
|
||||||
|
DEFAULT_ATTRIBUTES = "ghi,clearsky_ghi,cloud_type"
|
||||||
|
DEFAULT_INTERVAL = 60
|
||||||
|
DEFAULT_POLAR_MIN_LATITUDE = 60.0
|
||||||
|
DEFAULT_MIN_CLEARSKY_GHI = 50.0
|
||||||
|
EMAIL_PATTERN = re.compile(r"[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+")
|
||||||
|
SENSITIVE_QUERY_PATTERN = re.compile(r"((?:api_key|email)=)([^&\s,\"']+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
CLOUD_TYPE_LABELS = {
|
||||||
|
"-15": "na",
|
||||||
|
"0": "clear",
|
||||||
|
"1": "probably_clear",
|
||||||
|
"2": "fog",
|
||||||
|
"3": "water",
|
||||||
|
"4": "super_cooled_water",
|
||||||
|
"5": "mixed",
|
||||||
|
"6": "opaque_ice",
|
||||||
|
"7": "cirrus",
|
||||||
|
"8": "overlapping",
|
||||||
|
"9": "overshooting",
|
||||||
|
"10": "unknown",
|
||||||
|
"11": "dust",
|
||||||
|
"12": "smoke",
|
||||||
|
}
|
||||||
|
|
||||||
|
CLOUD_SUMMARY_FIELDS = [
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_fips",
|
||||||
|
"state_abbr",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"cloudinessIndexPct",
|
||||||
|
"avgObservedToClearskyRatio",
|
||||||
|
"daylightRows",
|
||||||
|
"allRows",
|
||||||
|
"clearOrProbablyClearPct",
|
||||||
|
"cloudyOrObscuredPct",
|
||||||
|
"fogPct",
|
||||||
|
"waterCloudPct",
|
||||||
|
"iceCloudPct",
|
||||||
|
"cirrusPct",
|
||||||
|
"unknownCloudTypePct",
|
||||||
|
"ghi_min",
|
||||||
|
"ghi_max",
|
||||||
|
"clearsky_ghi_min",
|
||||||
|
"clearsky_ghi_max",
|
||||||
|
"source",
|
||||||
|
"raw_csv",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_for_secret(prompt: str, current_value: str | None) -> str:
|
||||||
|
"""Prompt for a sensitive value only when it was not supplied."""
|
||||||
|
if current_value:
|
||||||
|
return current_value
|
||||||
|
return getpass.getpass(prompt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_for_text(prompt: str, current_value: str | None) -> str:
|
||||||
|
"""Prompt for normal text only when it was not supplied."""
|
||||||
|
if current_value:
|
||||||
|
return current_value
|
||||||
|
return input(prompt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_county_points(points_csv: Path) -> list[dict[str, str]]:
|
||||||
|
"""Read the county point CSV."""
|
||||||
|
with points_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def select_county_points(
|
||||||
|
rows: list[dict[str, str]],
|
||||||
|
start: int,
|
||||||
|
limit: int | None,
|
||||||
|
completed_fips: set[str],
|
||||||
|
overwrite: bool,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return the requested batch, skipping completed counties unless overwriting."""
|
||||||
|
if start < 0:
|
||||||
|
raise ValueError("--start must be 0 or greater.")
|
||||||
|
if limit is not None and limit < 1:
|
||||||
|
raise ValueError("--limit must be 1 or greater.")
|
||||||
|
|
||||||
|
candidates = rows[start:]
|
||||||
|
if not overwrite:
|
||||||
|
candidates = [
|
||||||
|
row
|
||||||
|
for row in candidates
|
||||||
|
if row.get("county_fips") not in completed_fips
|
||||||
|
]
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
return candidates
|
||||||
|
return candidates[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def read_existing_summary_rows(output_csv: Path) -> dict[str, dict[str, str]]:
|
||||||
|
"""Read already summarized county cloud rows keyed by FIPS."""
|
||||||
|
if not output_csv.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with output_csv.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
|
return {
|
||||||
|
row["county_fips"]: row
|
||||||
|
for row in csv.DictReader(handle)
|
||||||
|
if row.get("county_fips")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_previous_error_fips(error_csv: Path) -> set[str]:
|
||||||
|
"""Read county FIPS values from a previous error log."""
|
||||||
|
if not error_csv.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
with error_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return {
|
||||||
|
row["county_fips"]
|
||||||
|
for row in csv.DictReader(handle)
|
||||||
|
if row.get("county_fips")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_polar_candidate(point: dict[str, str], min_latitude: float) -> bool:
|
||||||
|
"""Return whether a point is in the latitude range for the Polar endpoint."""
|
||||||
|
try:
|
||||||
|
return float(point["lat"]) >= min_latitude
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_nsrdb_url(
|
||||||
|
endpoint: str,
|
||||||
|
api_key: str,
|
||||||
|
email: str,
|
||||||
|
point: dict[str, str],
|
||||||
|
name: str,
|
||||||
|
attributes: str,
|
||||||
|
interval: int,
|
||||||
|
) -> str:
|
||||||
|
"""Build a single-point NSRDB CSV request URL."""
|
||||||
|
wkt = f"POINT({point['lon']} {point['lat']})"
|
||||||
|
query = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"wkt": wkt,
|
||||||
|
"attributes": attributes,
|
||||||
|
"names": name,
|
||||||
|
"utc": "false",
|
||||||
|
"leap_day": "false",
|
||||||
|
"interval": str(interval),
|
||||||
|
"email": email,
|
||||||
|
}
|
||||||
|
return f"{endpoint}?{urllib.parse.urlencode(query)}"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Return a request URL with sensitive query values removed."""
|
||||||
|
parsed_url = urllib.parse.urlsplit(url)
|
||||||
|
query = urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||||
|
redacted_query = [
|
||||||
|
(key, "<redacted>" if key in {"api_key", "email"} else value)
|
||||||
|
for key, value in query
|
||||||
|
]
|
||||||
|
return urllib.parse.urlunsplit(
|
||||||
|
parsed_url._replace(query=urllib.parse.urlencode(redacted_query))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_sensitive_text(text: str) -> str:
|
||||||
|
"""Remove likely credentials and email addresses from log text."""
|
||||||
|
without_query_values = SENSITIVE_QUERY_PATTERN.sub(r"\1<redacted>", text)
|
||||||
|
return EMAIL_PATTERN.sub("<redacted>", without_query_values)
|
||||||
|
|
||||||
|
|
||||||
|
class NsrdDataRequestError(RuntimeError):
|
||||||
|
"""Store a failed NSRDB request with sanitized context for logging."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: int | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
response: str = "",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.url = url
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
|
||||||
|
class RequestRateLimiter:
|
||||||
|
"""Coordinate network request starts across worker threads."""
|
||||||
|
|
||||||
|
def __init__(self, delay: float) -> None:
|
||||||
|
self.delay = max(0.0, delay)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._next_request_at = 0.0
|
||||||
|
|
||||||
|
def wait(self) -> None:
|
||||||
|
"""Wait until the next request can start."""
|
||||||
|
if self.delay <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
wait_seconds = max(0.0, self._next_request_at - now)
|
||||||
|
self._next_request_at = max(now, self._next_request_at) + self.delay
|
||||||
|
|
||||||
|
if wait_seconds:
|
||||||
|
time.sleep(wait_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def build_http_request_error(error: urllib.error.HTTPError, url: str) -> NsrdDataRequestError:
|
||||||
|
"""Create a structured NSRDB request error from an HTTPError."""
|
||||||
|
body = error.read().decode("utf-8-sig", errors="replace").strip()
|
||||||
|
body_excerpt = redact_sensitive_text(body[:1000]) if body else "No response body returned."
|
||||||
|
redacted_url = redact_url(url)
|
||||||
|
retry_after = error.headers.get("Retry-After") if error.headers else None
|
||||||
|
retry_message = f"\n Retry-After: {retry_after}" if retry_after else ""
|
||||||
|
message = f"HTTP {error.code} {error.reason}\n URL: {redacted_url}{retry_message}\n Response: {body_excerpt}"
|
||||||
|
return NsrdDataRequestError(
|
||||||
|
message=message,
|
||||||
|
status_code=error.code,
|
||||||
|
url=redacted_url,
|
||||||
|
response=body_excerpt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_text(url: str, timeout: int, rate_limiter: RequestRateLimiter | None = None) -> str:
|
||||||
|
"""Download text from a URL and return its decoded body."""
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "county-climate-explorer/0.1"})
|
||||||
|
try:
|
||||||
|
if rate_limiter is not None:
|
||||||
|
rate_limiter.wait()
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read().decode("utf-8-sig")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raise build_http_request_error(error, url) from error
|
||||||
|
|
||||||
|
|
||||||
|
def cache_suffix(attributes: str) -> str:
|
||||||
|
"""Return a stable, readable suffix for the cached CSV filename."""
|
||||||
|
normalized = re.sub(r"[^a-z0-9]+", "-", attributes.lower()).strip("-")
|
||||||
|
return normalized or "cloud"
|
||||||
|
|
||||||
|
|
||||||
|
def read_or_download_county_csv(
|
||||||
|
point: dict[str, str],
|
||||||
|
endpoint: str,
|
||||||
|
api_key: str,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
source_key: str,
|
||||||
|
attributes: str,
|
||||||
|
interval: int,
|
||||||
|
cache_dir: Path,
|
||||||
|
timeout: int,
|
||||||
|
overwrite: bool,
|
||||||
|
rate_limiter: RequestRateLimiter | None = None,
|
||||||
|
) -> tuple[str, Path]:
|
||||||
|
"""Return cached NSRDB CSV text, downloading it first when needed."""
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path = cache_dir / f"{point['county_fips']}_{source_key}_{name}_{cache_suffix(attributes)}.csv"
|
||||||
|
|
||||||
|
if cache_path.exists() and not overwrite:
|
||||||
|
return cache_path.read_text(encoding="utf-8-sig"), cache_path
|
||||||
|
|
||||||
|
url = build_nsrdb_url(endpoint, api_key, email, point, name, attributes, interval)
|
||||||
|
csv_text = download_text(url, timeout, rate_limiter)
|
||||||
|
cache_path.write_text(csv_text, encoding="utf-8")
|
||||||
|
return csv_text, cache_path
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_header(value: str) -> str:
|
||||||
|
"""Normalize a CSV header to make NSRDB spelling variations easier to match."""
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def find_column(header: list[str], candidates: set[str]) -> int | None:
|
||||||
|
"""Return the first header index whose normalized name matches a candidate."""
|
||||||
|
normalized_candidates = {normalized_header(candidate) for candidate in candidates}
|
||||||
|
for index, value in enumerate(header):
|
||||||
|
if normalized_header(value) in normalized_candidates:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_data_header(rows: list[list[str]]) -> int | None:
|
||||||
|
"""Return the NSRDB hourly data header row index."""
|
||||||
|
required = {"Year", "Month", "Day", "Hour", "Minute"}
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
if required.issubset(set(row)):
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_optional_float(row: list[str], index: int | None) -> float | None:
|
||||||
|
"""Parse an optional float field from a CSV row."""
|
||||||
|
if index is None or len(row) <= index:
|
||||||
|
return None
|
||||||
|
value = row[index].strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not math.isfinite(parsed):
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cloud_type(row: list[str], index: int | None) -> str:
|
||||||
|
"""Parse an optional cloud type code as a string."""
|
||||||
|
if index is None or len(row) <= index:
|
||||||
|
return ""
|
||||||
|
value = row[index].strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(int(float(value)))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def pct(count: int, total: int) -> float:
|
||||||
|
"""Return a percentage or NaN when there is no denominator."""
|
||||||
|
return count / total * 100 if total else math.nan
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(value: float | None, places: int = 3) -> str:
|
||||||
|
"""Format an optional float for CSV output."""
|
||||||
|
if value is None or not math.isfinite(value):
|
||||||
|
return ""
|
||||||
|
return f"{value:.{places}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_cloud_metrics(
|
||||||
|
point: dict[str, str],
|
||||||
|
csv_text: str,
|
||||||
|
source_file: Path,
|
||||||
|
source_label: str,
|
||||||
|
min_clearsky_ghi: float,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Convert hourly GHI/Clearsky GHI/Cloud Type rows into county metrics."""
|
||||||
|
rows = list(csv.reader(csv_text.splitlines()))
|
||||||
|
header_index = find_data_header(rows)
|
||||||
|
if header_index is None:
|
||||||
|
raise ValueError("Could not find the NSRDB data header row.")
|
||||||
|
|
||||||
|
header = rows[header_index]
|
||||||
|
ghi_index = find_column(header, {"GHI", "ghi"})
|
||||||
|
clearsky_ghi_index = find_column(
|
||||||
|
header,
|
||||||
|
{
|
||||||
|
"Clearsky GHI",
|
||||||
|
"Clear Sky GHI",
|
||||||
|
"Clear-sky GHI",
|
||||||
|
"clearsky_ghi",
|
||||||
|
"clear_sky_ghi",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cloud_type_index = find_column(header, {"Cloud Type", "cloud_type", "cloudtype"})
|
||||||
|
if ghi_index is None:
|
||||||
|
raise ValueError("Could not find a GHI column in the NSRDB response.")
|
||||||
|
if clearsky_ghi_index is None:
|
||||||
|
raise ValueError("Could not find a Clearsky GHI column in the NSRDB response.")
|
||||||
|
if cloud_type_index is None:
|
||||||
|
raise ValueError("Could not find a Cloud Type column in the NSRDB response.")
|
||||||
|
|
||||||
|
all_rows = 0
|
||||||
|
daylight_rows = 0
|
||||||
|
ratio_sum = 0.0
|
||||||
|
ghi_values: list[float] = []
|
||||||
|
clearsky_values: list[float] = []
|
||||||
|
daylight_cloud_type_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
all_rows += 1
|
||||||
|
ghi = parse_optional_float(row, ghi_index)
|
||||||
|
clearsky_ghi = parse_optional_float(row, clearsky_ghi_index)
|
||||||
|
cloud_type = parse_cloud_type(row, cloud_type_index)
|
||||||
|
if ghi is None or clearsky_ghi is None:
|
||||||
|
continue
|
||||||
|
ghi_values.append(ghi)
|
||||||
|
clearsky_values.append(clearsky_ghi)
|
||||||
|
if clearsky_ghi < min_clearsky_ghi:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ratio_sum += min(1.0, max(0.0, ghi / clearsky_ghi))
|
||||||
|
daylight_rows += 1
|
||||||
|
if cloud_type:
|
||||||
|
daylight_cloud_type_counts[cloud_type] = daylight_cloud_type_counts.get(cloud_type, 0) + 1
|
||||||
|
|
||||||
|
if daylight_rows == 0:
|
||||||
|
raise ValueError("No daylight rows with valid GHI and Clearsky GHI were found.")
|
||||||
|
|
||||||
|
avg_ratio = ratio_sum / daylight_rows
|
||||||
|
cloudiness_pct = 1 - avg_ratio
|
||||||
|
clear_or_probably_clear = daylight_cloud_type_counts.get("0", 0) + daylight_cloud_type_counts.get("1", 0)
|
||||||
|
cloudy_or_obscured = sum(
|
||||||
|
daylight_cloud_type_counts.get(code, 0)
|
||||||
|
for code in ["2", "3", "4", "5", "6", "7", "8", "9", "11", "12"]
|
||||||
|
)
|
||||||
|
water_cloud = (
|
||||||
|
daylight_cloud_type_counts.get("3", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("4", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("5", 0)
|
||||||
|
)
|
||||||
|
ice_cloud = (
|
||||||
|
daylight_cloud_type_counts.get("6", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("8", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("9", 0)
|
||||||
|
)
|
||||||
|
unknown_cloud_type = daylight_cloud_type_counts.get("10", 0) + daylight_cloud_type_counts.get("-15", 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": point["county_fips"],
|
||||||
|
"county_name": point["county_name"],
|
||||||
|
"state_fips": point["state_fips"],
|
||||||
|
"state_abbr": point["state_abbr"],
|
||||||
|
"lat": point["lat"],
|
||||||
|
"lon": point["lon"],
|
||||||
|
"cloudinessIndexPct": fmt(cloudiness_pct, 4),
|
||||||
|
"avgObservedToClearskyRatio": fmt(avg_ratio, 4),
|
||||||
|
"daylightRows": str(daylight_rows),
|
||||||
|
"allRows": str(all_rows),
|
||||||
|
"clearOrProbablyClearPct": fmt(pct(clear_or_probably_clear, daylight_rows), 2),
|
||||||
|
"cloudyOrObscuredPct": fmt(pct(cloudy_or_obscured, daylight_rows), 2),
|
||||||
|
"fogPct": fmt(pct(daylight_cloud_type_counts.get("2", 0), daylight_rows), 2),
|
||||||
|
"waterCloudPct": fmt(pct(water_cloud, daylight_rows), 2),
|
||||||
|
"iceCloudPct": fmt(pct(ice_cloud, daylight_rows), 2),
|
||||||
|
"cirrusPct": fmt(pct(daylight_cloud_type_counts.get("7", 0), daylight_rows), 2),
|
||||||
|
"unknownCloudTypePct": fmt(pct(unknown_cloud_type, daylight_rows), 2),
|
||||||
|
"ghi_min": fmt(min(ghi_values), 1) if ghi_values else "",
|
||||||
|
"ghi_max": fmt(max(ghi_values), 1) if ghi_values else "",
|
||||||
|
"clearsky_ghi_min": fmt(min(clearsky_values), 1) if clearsky_values else "",
|
||||||
|
"clearsky_ghi_max": fmt(max(clearsky_values), 1) if clearsky_values else "",
|
||||||
|
"source": f"{source_label} representative point",
|
||||||
|
"raw_csv": str(source_file),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary_csv(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write fetched county cloud summaries to CSV."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=CLOUD_SUMMARY_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def merged_summary_rows(
|
||||||
|
all_points: list[dict[str, str]],
|
||||||
|
existing_rows_by_fips: dict[str, dict[str, str]],
|
||||||
|
new_rows: list[dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return existing and newly fetched rows in county point order."""
|
||||||
|
rows_by_fips = dict(existing_rows_by_fips)
|
||||||
|
for row in new_rows:
|
||||||
|
rows_by_fips[row["county_fips"]] = row
|
||||||
|
|
||||||
|
ordered_rows: list[dict[str, str]] = []
|
||||||
|
seen_fips: set[str] = set()
|
||||||
|
for point in all_points:
|
||||||
|
county_fips = point.get("county_fips", "")
|
||||||
|
row = rows_by_fips.get(county_fips)
|
||||||
|
if row is not None:
|
||||||
|
ordered_rows.append(row)
|
||||||
|
seen_fips.add(county_fips)
|
||||||
|
|
||||||
|
extra_rows = [
|
||||||
|
row
|
||||||
|
for county_fips, row in sorted(rows_by_fips.items())
|
||||||
|
if county_fips not in seen_fips
|
||||||
|
]
|
||||||
|
return ordered_rows + extra_rows
|
||||||
|
|
||||||
|
|
||||||
|
def write_error_csv(rows: list[dict[str, str]], error_csv: Path) -> None:
|
||||||
|
"""Write failed county cloud fetches to CSV."""
|
||||||
|
error_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with error_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_fips",
|
||||||
|
"state_abbr",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"error_type",
|
||||||
|
"status_code",
|
||||||
|
"request_url",
|
||||||
|
"response",
|
||||||
|
"message",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_row(point: dict[str, str], error: Exception) -> dict[str, str]:
|
||||||
|
"""Convert a failed county fetch into a structured CSV row."""
|
||||||
|
status_code = ""
|
||||||
|
request_url = ""
|
||||||
|
response = ""
|
||||||
|
|
||||||
|
if isinstance(error, NsrdDataRequestError):
|
||||||
|
status_code = str(error.status_code or "")
|
||||||
|
request_url = error.url or ""
|
||||||
|
response = error.response
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": point["county_fips"],
|
||||||
|
"county_name": point["county_name"],
|
||||||
|
"state_fips": point["state_fips"],
|
||||||
|
"state_abbr": point["state_abbr"],
|
||||||
|
"lat": point["lat"],
|
||||||
|
"lon": point["lon"],
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"status_code": status_code,
|
||||||
|
"request_url": request_url,
|
||||||
|
"response": redact_sensitive_text(response),
|
||||||
|
"message": redact_sensitive_text(str(error)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_profiles(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
point: dict[str, str],
|
||||||
|
previous_error_fips: set[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Build the ordered list of NSRDB endpoints to try for a county point."""
|
||||||
|
goes_profile = {
|
||||||
|
"source_key": "goes-tmy",
|
||||||
|
"source_label": "NSRDB GOES TMY PSM v4",
|
||||||
|
"endpoint": args.endpoint,
|
||||||
|
"name": args.name,
|
||||||
|
}
|
||||||
|
polar_profile = {
|
||||||
|
"source_key": "polar-tmy",
|
||||||
|
"source_label": "NSRDB Polar TMY PSM v4",
|
||||||
|
"endpoint": args.polar_endpoint,
|
||||||
|
"name": args.polar_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not args.polar_fallback or not is_polar_candidate(point, args.polar_min_latitude):
|
||||||
|
return [goes_profile]
|
||||||
|
if point["county_fips"] in previous_error_fips:
|
||||||
|
return [polar_profile, goes_profile]
|
||||||
|
return [goes_profile, polar_profile]
|
||||||
|
|
||||||
|
|
||||||
|
def should_try_next_profile(error: Exception, profile_index: int, profiles: list[dict[str, str]]) -> bool:
|
||||||
|
"""Return whether another configured endpoint should be tried after this error."""
|
||||||
|
if profile_index >= len(profiles) - 1:
|
||||||
|
return False
|
||||||
|
if not isinstance(error, NsrdDataRequestError):
|
||||||
|
return False
|
||||||
|
return error.status_code == 400 and "No data available at the provided location" in error.response
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_county_summary(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
point: dict[str, str],
|
||||||
|
previous_error_fips: set[str],
|
||||||
|
api_key: str,
|
||||||
|
email: str,
|
||||||
|
rate_limiter: RequestRateLimiter,
|
||||||
|
) -> tuple[dict[str, str] | None, dict[str, str] | None, list[str]]:
|
||||||
|
"""Fetch and summarize one county point."""
|
||||||
|
profiles = build_request_profiles(args, point, previous_error_fips)
|
||||||
|
final_error: Exception | None = None
|
||||||
|
messages: list[str] = []
|
||||||
|
|
||||||
|
for profile_index, profile in enumerate(profiles):
|
||||||
|
try:
|
||||||
|
messages.append(f"Trying {profile['source_label']} ({profile['name']})")
|
||||||
|
csv_text, source_file = read_or_download_county_csv(
|
||||||
|
point=point,
|
||||||
|
endpoint=profile["endpoint"],
|
||||||
|
api_key=api_key,
|
||||||
|
email=email,
|
||||||
|
name=profile["name"],
|
||||||
|
source_key=profile["source_key"],
|
||||||
|
attributes=args.attributes,
|
||||||
|
interval=args.interval,
|
||||||
|
cache_dir=args.cache_dir,
|
||||||
|
timeout=args.timeout,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
rate_limiter=rate_limiter,
|
||||||
|
)
|
||||||
|
summary = summarize_cloud_metrics(
|
||||||
|
point=point,
|
||||||
|
csv_text=csv_text,
|
||||||
|
source_file=source_file,
|
||||||
|
source_label=profile["source_label"],
|
||||||
|
min_clearsky_ghi=args.min_clearsky_ghi,
|
||||||
|
)
|
||||||
|
return summary, None, messages
|
||||||
|
except (OSError, urllib.error.URLError, RuntimeError, ValueError) as error:
|
||||||
|
final_error = error
|
||||||
|
if should_try_next_profile(error, profile_index, profiles):
|
||||||
|
messages.append(f"{profile['source_label']} had no data; trying fallback.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if final_error is None:
|
||||||
|
final_error = RuntimeError("No NSRDB profiles were available for this county.")
|
||||||
|
return None, build_error_row(point, final_error), messages
|
||||||
|
|
||||||
|
|
||||||
|
def log_county_result(
|
||||||
|
index: int,
|
||||||
|
total: int,
|
||||||
|
point: dict[str, str],
|
||||||
|
summary: dict[str, str] | None,
|
||||||
|
error: dict[str, str] | None,
|
||||||
|
messages: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Print progress for one completed county."""
|
||||||
|
label = f"{point['county_fips']} {point['county_name']}, {point['state_abbr']}"
|
||||||
|
status = "Fetched" if summary is not None else "Skipped"
|
||||||
|
print(f"[{index}/{total}] {status} {label}")
|
||||||
|
for message in messages:
|
||||||
|
print(f" {message}")
|
||||||
|
if summary is not None:
|
||||||
|
print(
|
||||||
|
" "
|
||||||
|
f"cloudiness={summary['cloudinessIndexPct']}, "
|
||||||
|
f"clear_ratio={summary['avgObservedToClearskyRatio']}, "
|
||||||
|
f"daylight_rows={summary['daylightRows']}"
|
||||||
|
)
|
||||||
|
if error is not None:
|
||||||
|
print(f" Error: {error['message']}")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_batch(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
all_points: list[dict[str, str]],
|
||||||
|
existing_rows_by_fips: dict[str, dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Fetch and summarize the requested county point batch."""
|
||||||
|
points = select_county_points(
|
||||||
|
rows=all_points,
|
||||||
|
start=args.start,
|
||||||
|
limit=args.limit,
|
||||||
|
completed_fips=set(existing_rows_by_fips),
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
)
|
||||||
|
skipped_count = len(all_points[args.start :]) - len(
|
||||||
|
select_county_points(
|
||||||
|
rows=all_points,
|
||||||
|
start=args.start,
|
||||||
|
limit=None,
|
||||||
|
completed_fips=set(existing_rows_by_fips),
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if skipped_count and not args.overwrite:
|
||||||
|
print(f"Skipping {skipped_count} counties already present in {args.output_csv}.")
|
||||||
|
if not points:
|
||||||
|
write_error_csv([], args.error_csv)
|
||||||
|
print("No missing counties selected for fetch.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
email = prompt_for_text("NLR/NREL API email: ", args.email)
|
||||||
|
api_key = prompt_for_secret("NLR/NREL API key: ", args.api_key)
|
||||||
|
|
||||||
|
previous_error_fips = read_previous_error_fips(args.error_csv)
|
||||||
|
rate_limiter = RequestRateLimiter(args.delay)
|
||||||
|
summaries_by_index: dict[int, dict[str, str]] = {}
|
||||||
|
errors_by_index: dict[int, dict[str, str]] = {}
|
||||||
|
total = len(points)
|
||||||
|
|
||||||
|
if args.workers == 1:
|
||||||
|
for index, point in enumerate(points, start=1):
|
||||||
|
summary, error, messages = fetch_county_summary(
|
||||||
|
args=args,
|
||||||
|
point=point,
|
||||||
|
previous_error_fips=previous_error_fips,
|
||||||
|
api_key=api_key,
|
||||||
|
email=email,
|
||||||
|
rate_limiter=rate_limiter,
|
||||||
|
)
|
||||||
|
if summary is not None:
|
||||||
|
summaries_by_index[index] = summary
|
||||||
|
if error is not None:
|
||||||
|
errors_by_index[index] = error
|
||||||
|
log_county_result(index, total, point, summary, error, messages)
|
||||||
|
else:
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
future_to_context = {
|
||||||
|
executor.submit(
|
||||||
|
fetch_county_summary,
|
||||||
|
args,
|
||||||
|
point,
|
||||||
|
previous_error_fips,
|
||||||
|
api_key,
|
||||||
|
email,
|
||||||
|
rate_limiter,
|
||||||
|
): (index, point)
|
||||||
|
for index, point in enumerate(points, start=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(future_to_context):
|
||||||
|
index, point = future_to_context[future]
|
||||||
|
summary, error, messages = future.result()
|
||||||
|
if summary is not None:
|
||||||
|
summaries_by_index[index] = summary
|
||||||
|
if error is not None:
|
||||||
|
errors_by_index[index] = error
|
||||||
|
log_county_result(index, total, point, summary, error, messages)
|
||||||
|
|
||||||
|
errors = [errors_by_index[index] for index in sorted(errors_by_index)]
|
||||||
|
write_error_csv(errors, args.error_csv)
|
||||||
|
return [summaries_by_index[index] for index in sorted(summaries_by_index)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--points-csv", type=Path, default=DEFAULT_POINTS_CSV)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
parser.add_argument("--error-csv", type=Path, default=DEFAULT_ERROR_CSV)
|
||||||
|
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR)
|
||||||
|
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
|
||||||
|
parser.add_argument("--polar-endpoint", default=DEFAULT_POLAR_ENDPOINT)
|
||||||
|
parser.add_argument("--name", default=DEFAULT_TMY_NAME, help="NSRDB GOES TMY name, such as tmy or tmy-2024.")
|
||||||
|
parser.add_argument("--polar-name", default=DEFAULT_POLAR_TMY_NAME, help="NSRDB Polar TMY name, usually tmy.")
|
||||||
|
parser.add_argument("--attributes", default=DEFAULT_ATTRIBUTES, help="Comma-delimited NSRDB attributes to request.")
|
||||||
|
parser.add_argument("--interval", type=int, default=DEFAULT_INTERVAL, help="NSRDB interval in minutes.")
|
||||||
|
parser.add_argument("--polar-min-latitude", type=float, default=DEFAULT_POLAR_MIN_LATITUDE)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-clearsky-ghi",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_MIN_CLEARSKY_GHI,
|
||||||
|
help="Minimum Clearsky GHI W/m2 for daylight cloudiness ratio rows.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-polar-fallback",
|
||||||
|
action="store_false",
|
||||||
|
dest="polar_fallback",
|
||||||
|
help="Disable retrying failed high-latitude GOES requests against the NSRDB Polar endpoint.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--email", help="Email address registered with the NLR/NREL API.")
|
||||||
|
parser.add_argument("--api-key", help="API key. If omitted, the script prompts securely.")
|
||||||
|
parser.add_argument("--start", type=int, default=0, help="Zero-based row offset in the points CSV.")
|
||||||
|
parser.add_argument("--limit", type=int, default=10, help="Number of counties to fetch. Use --all for all counties.")
|
||||||
|
parser.add_argument("--all", action="store_true", help="Fetch all counties from --start onward.")
|
||||||
|
parser.add_argument("--delay", type=float, default=1.0, help="Minimum seconds between new API requests.")
|
||||||
|
parser.add_argument("--workers", type=int, default=4, help="Number of county jobs to run at once.")
|
||||||
|
parser.add_argument("--timeout", type=int, default=120, help="Request timeout in seconds.")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="Re-download cached county CSV files.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.all:
|
||||||
|
args.limit = None
|
||||||
|
if args.delay < 0:
|
||||||
|
parser.error("--delay must be 0 or greater.")
|
||||||
|
if args.workers < 1:
|
||||||
|
parser.error("--workers must be 1 or greater.")
|
||||||
|
if args.interval <= 0:
|
||||||
|
parser.error("--interval must be greater than 0.")
|
||||||
|
if args.min_clearsky_ghi < 0:
|
||||||
|
parser.error("--min-clearsky-ghi must be 0 or greater.")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the NSRDB representative-point cloud metric fetch."""
|
||||||
|
args = parse_args()
|
||||||
|
all_points = read_county_points(args.points_csv)
|
||||||
|
existing_rows_by_fips = read_existing_summary_rows(args.output_csv)
|
||||||
|
summaries = fetch_batch(args, all_points, existing_rows_by_fips)
|
||||||
|
output_rows = merged_summary_rows(all_points, existing_rows_by_fips, summaries)
|
||||||
|
write_summary_csv(output_rows, args.output_csv)
|
||||||
|
print(
|
||||||
|
f"Wrote {len(output_rows)} county cloud summaries to {args.output_csv} "
|
||||||
|
f"({len(summaries)} newly fetched)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fetch NSRDB GOES TMY GHI data for county representative points.
|
||||||
|
|
||||||
|
This is designed for a small first test batch. It prompts for email and API key
|
||||||
|
when those values are not passed as command-line arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import csv
|
||||||
|
import getpass
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POINTS_CSV = Path("data/nrel/county_representative_points.csv")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_representative_point_ghi_summary.csv")
|
||||||
|
DEFAULT_ERROR_CSV = Path("data/nrel/county_representative_point_ghi_error_log.csv")
|
||||||
|
DEFAULT_CACHE_DIR = Path("data/nrel/representative_point_csv")
|
||||||
|
DEFAULT_ENDPOINT = "https://developer.nlr.gov/api/nsrdb/v2/solar/nsrdb-GOES-tmy-v4-0-0-download.csv"
|
||||||
|
DEFAULT_POLAR_ENDPOINT = "https://developer.nlr.gov/api/nsrdb/v2/solar/nsrdb-polar-tmy-v4-0-0-download.csv"
|
||||||
|
DEFAULT_TMY_NAME = "tmy-2024"
|
||||||
|
DEFAULT_POLAR_TMY_NAME = "tmy"
|
||||||
|
DEFAULT_POLAR_MIN_LATITUDE = 60.0
|
||||||
|
EMAIL_PATTERN = re.compile(r"[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+")
|
||||||
|
SENSITIVE_QUERY_PATTERN = re.compile(r"((?:api_key|email)=)([^&\s,\"']+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_for_secret(prompt: str, current_value: str | None) -> str:
|
||||||
|
"""Prompt for a sensitive value only when it was not supplied."""
|
||||||
|
if current_value:
|
||||||
|
return current_value
|
||||||
|
return getpass.getpass(prompt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_for_text(prompt: str, current_value: str | None) -> str:
|
||||||
|
"""Prompt for a normal text value only when it was not supplied."""
|
||||||
|
if current_value:
|
||||||
|
return current_value
|
||||||
|
return input(prompt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_county_points(points_csv: Path, start: int, limit: int | None) -> list[dict[str, str]]:
|
||||||
|
"""Read the county point CSV and return the requested batch."""
|
||||||
|
with points_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
rows = list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
if start < 0:
|
||||||
|
raise ValueError("--start must be 0 or greater.")
|
||||||
|
|
||||||
|
if limit is None:
|
||||||
|
return rows[start:]
|
||||||
|
|
||||||
|
if limit < 1:
|
||||||
|
raise ValueError("--limit must be 1 or greater.")
|
||||||
|
|
||||||
|
return rows[start : start + limit]
|
||||||
|
|
||||||
|
|
||||||
|
def read_previous_error_fips(error_csv: Path) -> set[str]:
|
||||||
|
"""Read county FIPS values from a previous error log."""
|
||||||
|
if not error_csv.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
with error_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return {
|
||||||
|
row["county_fips"]
|
||||||
|
for row in csv.DictReader(handle)
|
||||||
|
if row.get("county_fips")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_polar_candidate(point: dict[str, str], min_latitude: float) -> bool:
|
||||||
|
"""Return whether a point is in the latitude range for the Polar NSRDB endpoint."""
|
||||||
|
try:
|
||||||
|
return float(point["lat"]) >= min_latitude
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_nsrdb_url(endpoint: str, api_key: str, email: str, point: dict[str, str], name: str) -> str:
|
||||||
|
"""Build a single-point NSRDB CSV request URL."""
|
||||||
|
wkt = f"POINT({point['lon']} {point['lat']})"
|
||||||
|
query = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"wkt": wkt,
|
||||||
|
"attributes": "ghi",
|
||||||
|
"names": name,
|
||||||
|
"utc": "false",
|
||||||
|
"leap_day": "false",
|
||||||
|
"interval": "60",
|
||||||
|
"email": email,
|
||||||
|
}
|
||||||
|
return f"{endpoint}?{urllib.parse.urlencode(query)}"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Return a request URL with sensitive query values removed."""
|
||||||
|
parsed_url = urllib.parse.urlsplit(url)
|
||||||
|
query = urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||||
|
redacted_query = [
|
||||||
|
(key, "<redacted>" if key in {"api_key", "email"} else value)
|
||||||
|
for key, value in query
|
||||||
|
]
|
||||||
|
return urllib.parse.urlunsplit(
|
||||||
|
parsed_url._replace(query=urllib.parse.urlencode(redacted_query))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_sensitive_text(text: str) -> str:
|
||||||
|
"""Remove likely credentials and email addresses from log text."""
|
||||||
|
without_query_values = SENSITIVE_QUERY_PATTERN.sub(r"\1<redacted>", text)
|
||||||
|
return EMAIL_PATTERN.sub("<redacted>", without_query_values)
|
||||||
|
|
||||||
|
|
||||||
|
class NsrdDataRequestError(RuntimeError):
|
||||||
|
"""Store a failed NSRDB request with sanitized context for logging."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None, url: str | None = None, response: str = "") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.url = url
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
|
||||||
|
class RequestRateLimiter:
|
||||||
|
"""Coordinate network request starts across worker threads."""
|
||||||
|
|
||||||
|
def __init__(self, delay: float) -> None:
|
||||||
|
self.delay = max(0.0, delay)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._next_request_at = 0.0
|
||||||
|
|
||||||
|
def wait(self) -> None:
|
||||||
|
"""Wait until the next request can start."""
|
||||||
|
if self.delay <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
wait_seconds = max(0.0, self._next_request_at - now)
|
||||||
|
self._next_request_at = max(now, self._next_request_at) + self.delay
|
||||||
|
|
||||||
|
if wait_seconds:
|
||||||
|
time.sleep(wait_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def build_http_request_error(error: urllib.error.HTTPError, url: str) -> NsrdDataRequestError:
|
||||||
|
"""Create a structured NSRDB request error from an HTTPError."""
|
||||||
|
body = error.read().decode("utf-8-sig", errors="replace").strip()
|
||||||
|
body_excerpt = redact_sensitive_text(body[:1000]) if body else "No response body returned."
|
||||||
|
redacted_url = redact_url(url)
|
||||||
|
retry_after = error.headers.get("Retry-After") if error.headers else None
|
||||||
|
retry_message = f"\n Retry-After: {retry_after}" if retry_after else ""
|
||||||
|
message = f"HTTP {error.code} {error.reason}\n URL: {redacted_url}{retry_message}\n Response: {body_excerpt}"
|
||||||
|
return NsrdDataRequestError(
|
||||||
|
message=message,
|
||||||
|
status_code=error.code,
|
||||||
|
url=redacted_url,
|
||||||
|
response=body_excerpt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_text(url: str, timeout: int, rate_limiter: RequestRateLimiter | None = None) -> str:
|
||||||
|
"""Download text from a URL and return its decoded body."""
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "county-climate-explorer/0.1"})
|
||||||
|
try:
|
||||||
|
if rate_limiter is not None:
|
||||||
|
rate_limiter.wait()
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read().decode("utf-8-sig")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raise build_http_request_error(error, url) from error
|
||||||
|
|
||||||
|
|
||||||
|
def read_or_download_county_csv(
|
||||||
|
point: dict[str, str],
|
||||||
|
endpoint: str,
|
||||||
|
api_key: str,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
source_key: str,
|
||||||
|
cache_dir: Path,
|
||||||
|
timeout: int,
|
||||||
|
overwrite: bool,
|
||||||
|
rate_limiter: RequestRateLimiter | None = None,
|
||||||
|
) -> tuple[str, Path]:
|
||||||
|
"""Return cached NSRDB CSV text, downloading it first when needed."""
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path = cache_dir / f"{point['county_fips']}_{source_key}_{name}_ghi.csv"
|
||||||
|
|
||||||
|
if cache_path.exists() and not overwrite:
|
||||||
|
return cache_path.read_text(encoding="utf-8-sig"), cache_path
|
||||||
|
|
||||||
|
url = build_nsrdb_url(endpoint, api_key, email, point, name)
|
||||||
|
csv_text = download_text(url, timeout, rate_limiter)
|
||||||
|
cache_path.write_text(csv_text, encoding="utf-8")
|
||||||
|
return csv_text, cache_path
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ghi_values(csv_text: str) -> list[float]:
|
||||||
|
"""Extract numeric GHI values from an NSRDB CSV response."""
|
||||||
|
rows = list(csv.reader(csv_text.splitlines()))
|
||||||
|
header_index = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, row in enumerate(rows)
|
||||||
|
if {"Year", "Month", "Day", "Hour", "Minute", "GHI"}.issubset(set(row))
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if header_index is None:
|
||||||
|
raise ValueError("Could not find the NSRDB data header row with a GHI column.")
|
||||||
|
|
||||||
|
ghi_index = rows[header_index].index("GHI")
|
||||||
|
ghi_values: list[float] = []
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
if len(row) <= ghi_index or not row[ghi_index].strip():
|
||||||
|
continue
|
||||||
|
ghi_values.append(float(row[ghi_index]))
|
||||||
|
|
||||||
|
if not ghi_values:
|
||||||
|
raise ValueError("No GHI values were found in the NSRDB response.")
|
||||||
|
|
||||||
|
return ghi_values
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_ghi(point: dict[str, str], csv_text: str, source_file: Path, source_label: str) -> dict[str, str]:
|
||||||
|
"""Convert hourly GHI values into average daily kWh/m2/day."""
|
||||||
|
ghi_values = extract_ghi_values(csv_text)
|
||||||
|
ghi_sum = sum(ghi_values)
|
||||||
|
avg_daily_ghi = ghi_sum / 1000 / 365
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": point["county_fips"],
|
||||||
|
"county_name": point["county_name"],
|
||||||
|
"state_fips": point["state_fips"],
|
||||||
|
"state_abbr": point["state_abbr"],
|
||||||
|
"lat": point["lat"],
|
||||||
|
"lon": point["lon"],
|
||||||
|
"avgSolarGhiKwhM2Day": f"{avg_daily_ghi:.3f}",
|
||||||
|
"ghi_rows": str(len(ghi_values)),
|
||||||
|
"ghi_min": f"{min(ghi_values):.1f}",
|
||||||
|
"ghi_max": f"{max(ghi_values):.1f}",
|
||||||
|
"source": f"{source_label} representative point",
|
||||||
|
"raw_csv": str(source_file),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary_csv(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write fetched county solar summaries to CSV."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_fips",
|
||||||
|
"state_abbr",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"avgSolarGhiKwhM2Day",
|
||||||
|
"ghi_rows",
|
||||||
|
"ghi_min",
|
||||||
|
"ghi_max",
|
||||||
|
"source",
|
||||||
|
"raw_csv",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def write_error_csv(rows: list[dict[str, str]], error_csv: Path) -> None:
|
||||||
|
"""Write failed county solar fetches to CSV."""
|
||||||
|
error_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with error_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_fips",
|
||||||
|
"state_abbr",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"error_type",
|
||||||
|
"status_code",
|
||||||
|
"request_url",
|
||||||
|
"response",
|
||||||
|
"message",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def build_error_row(point: dict[str, str], error: Exception) -> dict[str, str]:
|
||||||
|
"""Convert a failed county fetch into a structured CSV row."""
|
||||||
|
status_code = ""
|
||||||
|
request_url = ""
|
||||||
|
response = ""
|
||||||
|
|
||||||
|
if isinstance(error, NsrdDataRequestError):
|
||||||
|
status_code = str(error.status_code or "")
|
||||||
|
request_url = error.url or ""
|
||||||
|
response = error.response
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": point["county_fips"],
|
||||||
|
"county_name": point["county_name"],
|
||||||
|
"state_fips": point["state_fips"],
|
||||||
|
"state_abbr": point["state_abbr"],
|
||||||
|
"lat": point["lat"],
|
||||||
|
"lon": point["lon"],
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"status_code": status_code,
|
||||||
|
"request_url": request_url,
|
||||||
|
"response": redact_sensitive_text(response),
|
||||||
|
"message": redact_sensitive_text(str(error)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_profiles(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
point: dict[str, str],
|
||||||
|
previous_error_fips: set[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Build the ordered list of NSRDB endpoints to try for a county point."""
|
||||||
|
goes_profile = {
|
||||||
|
"source_key": "goes-tmy",
|
||||||
|
"source_label": "NSRDB GOES TMY PSM v4",
|
||||||
|
"endpoint": args.endpoint,
|
||||||
|
"name": args.name,
|
||||||
|
}
|
||||||
|
polar_profile = {
|
||||||
|
"source_key": "polar-tmy",
|
||||||
|
"source_label": "NSRDB Polar TMY PSM v4",
|
||||||
|
"endpoint": args.polar_endpoint,
|
||||||
|
"name": args.polar_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not args.polar_fallback or not is_polar_candidate(point, args.polar_min_latitude):
|
||||||
|
return [goes_profile]
|
||||||
|
|
||||||
|
if point["county_fips"] in previous_error_fips:
|
||||||
|
return [polar_profile, goes_profile]
|
||||||
|
|
||||||
|
return [goes_profile, polar_profile]
|
||||||
|
|
||||||
|
|
||||||
|
def should_try_next_profile(error: Exception, profile_index: int, profiles: list[dict[str, str]]) -> bool:
|
||||||
|
"""Return whether another configured endpoint should be tried after this error."""
|
||||||
|
if profile_index >= len(profiles) - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(error, NsrdDataRequestError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return error.status_code == 400 and "No data available at the provided location" in error.response
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_county_summary(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
point: dict[str, str],
|
||||||
|
previous_error_fips: set[str],
|
||||||
|
api_key: str,
|
||||||
|
email: str,
|
||||||
|
rate_limiter: RequestRateLimiter,
|
||||||
|
) -> tuple[dict[str, str] | None, dict[str, str] | None, list[str]]:
|
||||||
|
"""Fetch and summarize one county point."""
|
||||||
|
profiles = build_request_profiles(args, point, previous_error_fips)
|
||||||
|
final_error: Exception | None = None
|
||||||
|
messages: list[str] = []
|
||||||
|
|
||||||
|
for profile_index, profile in enumerate(profiles):
|
||||||
|
try:
|
||||||
|
messages.append(f"Trying {profile['source_label']} ({profile['name']})")
|
||||||
|
csv_text, source_file = read_or_download_county_csv(
|
||||||
|
point=point,
|
||||||
|
endpoint=profile["endpoint"],
|
||||||
|
api_key=api_key,
|
||||||
|
email=email,
|
||||||
|
name=profile["name"],
|
||||||
|
source_key=profile["source_key"],
|
||||||
|
cache_dir=args.cache_dir,
|
||||||
|
timeout=args.timeout,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
rate_limiter=rate_limiter,
|
||||||
|
)
|
||||||
|
summary = summarize_ghi(point, csv_text, source_file, profile["source_label"])
|
||||||
|
return summary, None, messages
|
||||||
|
except (OSError, urllib.error.URLError, RuntimeError, ValueError) as error:
|
||||||
|
final_error = error
|
||||||
|
if should_try_next_profile(error, profile_index, profiles):
|
||||||
|
messages.append(f"{profile['source_label']} had no data; trying fallback.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if final_error is None:
|
||||||
|
final_error = RuntimeError("No NSRDB profiles were available for this county.")
|
||||||
|
|
||||||
|
return None, build_error_row(point, final_error), messages
|
||||||
|
|
||||||
|
|
||||||
|
def log_county_result(
|
||||||
|
index: int,
|
||||||
|
total: int,
|
||||||
|
point: dict[str, str],
|
||||||
|
summary: dict[str, str] | None,
|
||||||
|
error: dict[str, str] | None,
|
||||||
|
messages: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Print progress for one completed county."""
|
||||||
|
label = f"{point['county_fips']} {point['county_name']}, {point['state_abbr']}"
|
||||||
|
status = "Fetched" if summary is not None else "Skipped"
|
||||||
|
print(f"[{index}/{total}] {status} {label}")
|
||||||
|
for message in messages:
|
||||||
|
print(f" {message}")
|
||||||
|
if error is not None:
|
||||||
|
print(f" Error: {error['message']}")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_batch(args: argparse.Namespace) -> list[dict[str, str]]:
|
||||||
|
"""Fetch and summarize the requested county point batch."""
|
||||||
|
email = prompt_for_text("NLR/NREL API email: ", args.email)
|
||||||
|
api_key = prompt_for_secret("NLR/NREL API key: ", args.api_key)
|
||||||
|
|
||||||
|
points = read_county_points(args.points_csv, args.start, args.limit)
|
||||||
|
previous_error_fips = read_previous_error_fips(args.error_csv)
|
||||||
|
rate_limiter = RequestRateLimiter(args.delay)
|
||||||
|
summaries_by_index: dict[int, dict[str, str]] = {}
|
||||||
|
errors_by_index: dict[int, dict[str, str]] = {}
|
||||||
|
total = len(points)
|
||||||
|
|
||||||
|
if args.workers == 1:
|
||||||
|
for index, point in enumerate(points, start=1):
|
||||||
|
summary, error, messages = fetch_county_summary(
|
||||||
|
args=args,
|
||||||
|
point=point,
|
||||||
|
previous_error_fips=previous_error_fips,
|
||||||
|
api_key=api_key,
|
||||||
|
email=email,
|
||||||
|
rate_limiter=rate_limiter,
|
||||||
|
)
|
||||||
|
if summary is not None:
|
||||||
|
summaries_by_index[index] = summary
|
||||||
|
if error is not None:
|
||||||
|
errors_by_index[index] = error
|
||||||
|
log_county_result(index, total, point, summary, error, messages)
|
||||||
|
else:
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
future_to_context = {
|
||||||
|
executor.submit(
|
||||||
|
fetch_county_summary,
|
||||||
|
args,
|
||||||
|
point,
|
||||||
|
previous_error_fips,
|
||||||
|
api_key,
|
||||||
|
email,
|
||||||
|
rate_limiter,
|
||||||
|
): (index, point)
|
||||||
|
for index, point in enumerate(points, start=1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(future_to_context):
|
||||||
|
index, point = future_to_context[future]
|
||||||
|
summary, error, messages = future.result()
|
||||||
|
if summary is not None:
|
||||||
|
summaries_by_index[index] = summary
|
||||||
|
if error is not None:
|
||||||
|
errors_by_index[index] = error
|
||||||
|
log_county_result(index, total, point, summary, error, messages)
|
||||||
|
|
||||||
|
errors = [errors_by_index[index] for index in sorted(errors_by_index)]
|
||||||
|
write_error_csv(errors, args.error_csv)
|
||||||
|
return [summaries_by_index[index] for index in sorted(summaries_by_index)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--points-csv", type=Path, default=DEFAULT_POINTS_CSV)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
parser.add_argument("--error-csv", type=Path, default=DEFAULT_ERROR_CSV)
|
||||||
|
parser.add_argument("--representative-point-csv-dir", "--cache-dir", dest="cache_dir", type=Path, default=DEFAULT_CACHE_DIR)
|
||||||
|
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
|
||||||
|
parser.add_argument("--polar-endpoint", default=DEFAULT_POLAR_ENDPOINT)
|
||||||
|
parser.add_argument("--name", default=DEFAULT_TMY_NAME, help="NSRDB TMY name, such as tmy or tmy-2024.")
|
||||||
|
parser.add_argument("--polar-name", default=DEFAULT_POLAR_TMY_NAME, help="NSRDB Polar TMY name, usually tmy.")
|
||||||
|
parser.add_argument("--polar-min-latitude", type=float, default=DEFAULT_POLAR_MIN_LATITUDE)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-polar-fallback",
|
||||||
|
action="store_false",
|
||||||
|
dest="polar_fallback",
|
||||||
|
help="Disable retrying failed high-latitude GOES requests against the NSRDB Polar endpoint.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--email", help="Email address registered with the NLR/NREL API.")
|
||||||
|
parser.add_argument("--api-key", help="API key. If omitted, the script prompts securely.")
|
||||||
|
parser.add_argument("--start", type=int, default=0, help="Zero-based row offset in the points CSV.")
|
||||||
|
parser.add_argument("--limit", type=int, default=10, help="Number of counties to fetch for the test batch.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--delay",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="Minimum seconds between new API requests. Cached files do not wait.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workers",
|
||||||
|
type=int,
|
||||||
|
default=4,
|
||||||
|
help="Number of county jobs to run at once. Set to 1 for the old sequential behavior.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=int, default=120, help="Request timeout in seconds.")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="Re-download cached county CSV files.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.delay < 0:
|
||||||
|
parser.error("--delay must be 0 or greater.")
|
||||||
|
if args.workers < 1:
|
||||||
|
parser.error("--workers must be 1 or greater.")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the NSRDB point-fetch test batch."""
|
||||||
|
args = parse_args()
|
||||||
|
summaries = fetch_batch(args)
|
||||||
|
write_summary_csv(summaries, args.output_csv)
|
||||||
|
print(f"Wrote {len(summaries)} solar summaries to {args.output_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Rebuild county representative-point GHI summaries from cached NSRDB raw CSV responses.
|
||||||
|
|
||||||
|
This does not call the NSRDB API. It reads data/nrel/representative_point_csv/*.csv, computes:
|
||||||
|
|
||||||
|
avgSolarGhiKwhM2Day = sum(hourly GHI) / 1000 / 365
|
||||||
|
|
||||||
|
and writes a county-keyed CSV compatible with build_county_climate_data.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POINTS_CSV = Path("data/nrel/county_representative_points.csv")
|
||||||
|
DEFAULT_REPRESENTATIVE_POINT_CSV_DIR = Path("data/nrel/representative_point_csv")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_representative_point_ghi_summary.csv")
|
||||||
|
|
||||||
|
|
||||||
|
def read_county_points(points_csv: Path) -> list[dict[str, str]]:
|
||||||
|
"""Read county representative points keyed by county FIPS."""
|
||||||
|
with points_csv.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ghi_values(csv_path: Path) -> list[float]:
|
||||||
|
"""Extract numeric hourly GHI values from a cached NSRDB CSV response."""
|
||||||
|
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
|
reader = csv.reader(handle)
|
||||||
|
ghi_index: int | None = None
|
||||||
|
values: list[float] = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
if ghi_index is None:
|
||||||
|
if {"Year", "Month", "Day", "Hour", "Minute", "GHI"}.issubset(set(row)):
|
||||||
|
ghi_index = row.index("GHI")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(row) <= ghi_index or not row[ghi_index].strip():
|
||||||
|
continue
|
||||||
|
values.append(float(row[ghi_index]))
|
||||||
|
|
||||||
|
if ghi_index is None:
|
||||||
|
raise ValueError(f"Could not find NSRDB data header with a GHI column in {csv_path}.")
|
||||||
|
if not values:
|
||||||
|
raise ValueError(f"No GHI values were found in {csv_path}.")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def select_raw_csv(representative_point_csv_dir: Path, county_fips: str) -> Path | None:
|
||||||
|
"""Return the cached raw CSV for a county, preferring polar data when both exist."""
|
||||||
|
matches = sorted(representative_point_csv_dir.glob(f"{county_fips}_*_ghi.csv"))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
polar_matches = [path for path in matches if "_polar-tmy_" in path.name]
|
||||||
|
return polar_matches[0] if polar_matches else matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def source_label(raw_csv: Path) -> str:
|
||||||
|
"""Return a readable source label from the cached filename."""
|
||||||
|
if "_polar-tmy_" in raw_csv.name:
|
||||||
|
return "NSRDB Polar TMY PSM v4 representative point"
|
||||||
|
if "_goes-tmy_" in raw_csv.name:
|
||||||
|
return "NSRDB GOES TMY PSM v4 representative point"
|
||||||
|
return "NSRDB TMY representative point"
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_county(point: dict[str, str], raw_csv: Path) -> dict[str, str]:
|
||||||
|
"""Summarize one county raw CSV into the app solar metric."""
|
||||||
|
ghi_values = extract_ghi_values(raw_csv)
|
||||||
|
avg_daily_ghi = sum(ghi_values) / 1000 / 365
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": point["county_fips"],
|
||||||
|
"county_name": point["county_name"],
|
||||||
|
"state_fips": point["state_fips"],
|
||||||
|
"state_abbr": point["state_abbr"],
|
||||||
|
"lat": point["lat"],
|
||||||
|
"lon": point["lon"],
|
||||||
|
"avgSolarGhiKwhM2Day": f"{avg_daily_ghi:.3f}",
|
||||||
|
"ghi_rows": str(len(ghi_values)),
|
||||||
|
"ghi_min": f"{min(ghi_values):.1f}",
|
||||||
|
"ghi_max": f"{max(ghi_values):.1f}",
|
||||||
|
"source": source_label(raw_csv),
|
||||||
|
"raw_csv": str(raw_csv),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary_csv(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write rebuilt county solar summaries."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_fips",
|
||||||
|
"state_abbr",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"avgSolarGhiKwhM2Day",
|
||||||
|
"ghi_rows",
|
||||||
|
"ghi_min",
|
||||||
|
"ghi_max",
|
||||||
|
"source",
|
||||||
|
"raw_csv",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--points-csv", type=Path, default=DEFAULT_POINTS_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--representative-point-csv-dir",
|
||||||
|
dest="representative_point_csv_dir",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_REPRESENTATIVE_POINT_CSV_DIR,
|
||||||
|
)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the cached raw CSV summarizer."""
|
||||||
|
args = parse_args()
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
failed: list[str] = []
|
||||||
|
|
||||||
|
for point in read_county_points(args.points_csv):
|
||||||
|
county_fips = point["county_fips"]
|
||||||
|
raw_csv = select_raw_csv(args.representative_point_csv_dir, county_fips)
|
||||||
|
if raw_csv is None:
|
||||||
|
missing.append(county_fips)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows.append(summarize_county(point, raw_csv))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
failed.append(f"{county_fips}: {exc}")
|
||||||
|
|
||||||
|
write_summary_csv(rows, args.output_csv)
|
||||||
|
print(f"Wrote {len(rows)} county representative-point solar summaries to {args.output_csv}")
|
||||||
|
if missing:
|
||||||
|
print(f"Missing raw CSVs for {len(missing)} counties: {', '.join(missing[:20])}")
|
||||||
|
if failed:
|
||||||
|
print(f"Failed to summarize {len(failed)} counties:")
|
||||||
|
for message in failed[:20]:
|
||||||
|
print(f" {message}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Submit NSRDB polygon archive requests for county-average cloudiness inputs.
|
||||||
|
|
||||||
|
This is a cloud-specific wrapper around request_nsrdb_county_polygon_archives.py.
|
||||||
|
It reuses the existing polygon tiling, site-count, Polar fallback, pacing, and
|
||||||
|
manifest logic, but writes to separate cloud request/response files and requests:
|
||||||
|
|
||||||
|
ghi,clearsky_ghi,cloud_type
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import request_nsrdb_county_polygon_archives as polygon_request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARGS = [
|
||||||
|
"--requests-csv",
|
||||||
|
"data/nrel/county_polygon_cloud_request_manifest.csv",
|
||||||
|
"--error-csv",
|
||||||
|
"data/nrel/county_polygon_cloud_error_log.csv",
|
||||||
|
"--response-dir",
|
||||||
|
"data/nrel/polygon_cloud_request_responses",
|
||||||
|
"--artifact-label",
|
||||||
|
"cloud",
|
||||||
|
"--legacy-artifact-label",
|
||||||
|
"ghi",
|
||||||
|
"--attributes",
|
||||||
|
"ghi,clearsky_ghi,cloud_type",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the existing polygon requester with cloud-specific defaults."""
|
||||||
|
polygon_request.main(
|
||||||
|
[*DEFAULT_ARGS, *sys.argv[1:]],
|
||||||
|
description=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Submit NSRDB polygon archive requests for county-average GHI.
|
||||||
|
|
||||||
|
This is a GHI-specific wrapper around request_nsrdb_county_polygon_archives.py.
|
||||||
|
It reuses the shared state machine, polygon tiling, site-count, Polar fallback,
|
||||||
|
pacing, and manifest logic with GHI-specific defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import request_nsrdb_county_polygon_archives as polygon_request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARGS = [
|
||||||
|
"--requests-csv",
|
||||||
|
"data/nrel/county_polygon_ghi_request_manifest.csv",
|
||||||
|
"--error-csv",
|
||||||
|
"data/nrel/county_polygon_ghi_error_log.csv",
|
||||||
|
"--response-dir",
|
||||||
|
"data/nrel/polygon_request_responses",
|
||||||
|
"--artifact-label",
|
||||||
|
"ghi",
|
||||||
|
"--attributes",
|
||||||
|
"ghi",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the shared polygon requester with GHI-specific defaults."""
|
||||||
|
polygon_request.main(
|
||||||
|
[*DEFAULT_ARGS, *sys.argv[1:]],
|
||||||
|
description=__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
geopandas>=0.14
|
||||||
|
numpy>=1.26
|
||||||
|
pandas>=2.2
|
||||||
|
xarray>=2024.2.0
|
||||||
|
affine>=2.4
|
||||||
|
rasterio>=1.5
|
||||||
|
netcdf4>=1.7
|
||||||
|
h5netcdf>=1.6
|
||||||
|
h5py>=3.12
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Summarize downloaded NSRDB county polygon GHI archives.
|
||||||
|
|
||||||
|
Each downloaded polygon archive contains one CSV per NSRDB site that intersects
|
||||||
|
the county polygon or tile. This script computes area-weighted county-level
|
||||||
|
average daily GHI values across those site CSVs, combining multiple tile
|
||||||
|
archives back into one county summary when present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARCHIVE_DIR = Path("data/nrel/polygon_archives")
|
||||||
|
DEFAULT_COUNTIES_GEOJSON = Path("data/geojson-counties-fips.json")
|
||||||
|
DEFAULT_REQUESTS_CSV = Path("data/nrel/county_polygon_ghi_request_manifest.csv")
|
||||||
|
DEFAULT_REPRESENTATIVE_POINT_CSV = Path("data/nrel/county_representative_point_ghi_summary.csv")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_polygon_ghi_summary.csv")
|
||||||
|
DEFAULT_CELL_SIZE_M = 4000.0
|
||||||
|
DEFAULT_ARCHIVE_GLOB = "*_ghi.zip"
|
||||||
|
EARTH_RADIUS_M = 6_371_008.8
|
||||||
|
|
||||||
|
SUMMARY_FIELDS = [
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_abbr",
|
||||||
|
"archive_zip",
|
||||||
|
"polygon_sites",
|
||||||
|
"request_site_count",
|
||||||
|
"ghi_rows_per_site_min",
|
||||||
|
"ghi_rows_per_site_max",
|
||||||
|
"avgSolarGhiKwhM2Day",
|
||||||
|
"areaWeightedAvgSolarGhiKwhM2Day",
|
||||||
|
"areaWeightedSites",
|
||||||
|
"weightedCellAreaKm2",
|
||||||
|
"countyAreaKm2",
|
||||||
|
"site_avg_min",
|
||||||
|
"site_avg_max",
|
||||||
|
"site_avg_stddev",
|
||||||
|
"representativePointAvgSolarGhiKwhM2Day",
|
||||||
|
"areaWeightedMinusRepresentativePoint",
|
||||||
|
"areaWeightedPctDiffFromRepresentativePoint",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_lookup(csv_path: Path, key_field: str) -> dict[str, dict[str, str]]:
|
||||||
|
"""Read a CSV into a dictionary keyed by one field."""
|
||||||
|
if not csv_path.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
|
return {
|
||||||
|
row[key_field]: row
|
||||||
|
for row in csv.DictReader(handle)
|
||||||
|
if row.get(key_field)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_grouped_lookup(csv_path: Path, key_field: str) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""Read a CSV into lists of rows keyed by one field."""
|
||||||
|
rows_by_key: dict[str, list[dict[str, str]]] = {}
|
||||||
|
if not csv_path.exists():
|
||||||
|
return rows_by_key
|
||||||
|
|
||||||
|
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
|
for row in csv.DictReader(handle):
|
||||||
|
key = row.get(key_field)
|
||||||
|
if key:
|
||||||
|
rows_by_key.setdefault(key, []).append(row)
|
||||||
|
return rows_by_key
|
||||||
|
|
||||||
|
|
||||||
|
def county_fips_from_archive(path: Path) -> str:
|
||||||
|
"""Extract the county FIPS prefix from an archive filename."""
|
||||||
|
return path.name.split("_", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def archive_groups_by_county(archives: list[Path]) -> dict[str, list[Path]]:
|
||||||
|
"""Group archive ZIP paths by county FIPS."""
|
||||||
|
grouped: dict[str, list[Path]] = {}
|
||||||
|
for archive in archives:
|
||||||
|
grouped.setdefault(county_fips_from_archive(archive), []).append(archive)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fips(value: object, width: int) -> str:
|
||||||
|
"""Return a zero-padded FIPS string from a mixed text or numeric value."""
|
||||||
|
digits = "".join(character for character in str(value).strip() if character.isdigit())
|
||||||
|
return digits.zfill(width)[-width:] if digits else ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_county_geometries(counties_geojson: Path) -> dict[str, list[list[list[tuple[float, float]]]]]:
|
||||||
|
"""Load county polygon rings from GeoJSON without third-party GIS dependencies."""
|
||||||
|
with counties_geojson.open(encoding="utf-8") as handle:
|
||||||
|
geojson = json.load(handle)
|
||||||
|
|
||||||
|
county_geometries: dict[str, list[list[list[tuple[float, float]]]]] = {}
|
||||||
|
for feature in geojson.get("features", []):
|
||||||
|
properties = feature.get("properties", {})
|
||||||
|
county_fips = normalize_fips(properties.get("id"), 5)
|
||||||
|
if not county_fips:
|
||||||
|
county_fips = normalize_fips(properties.get("STATE"), 2) + normalize_fips(properties.get("COUNTY"), 3)
|
||||||
|
geometry = feature.get("geometry") or {}
|
||||||
|
polygons = geometry_polygons(geometry)
|
||||||
|
if county_fips and polygons:
|
||||||
|
county_geometries[county_fips] = polygons
|
||||||
|
|
||||||
|
return county_geometries
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_polygons(geometry: dict[str, object]) -> list[list[list[tuple[float, float]]]]:
|
||||||
|
"""Return GeoJSON Polygon/MultiPolygon coordinates as polygon rings."""
|
||||||
|
geometry_type = geometry.get("type")
|
||||||
|
coordinates = geometry.get("coordinates")
|
||||||
|
if not isinstance(coordinates, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if geometry_type == "Polygon":
|
||||||
|
return [coordinates_to_polygon(coordinates)]
|
||||||
|
if geometry_type == "MultiPolygon":
|
||||||
|
return [coordinates_to_polygon(polygon) for polygon in coordinates]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def coordinates_to_polygon(coordinates: list[object]) -> list[list[tuple[float, float]]]:
|
||||||
|
"""Convert raw GeoJSON polygon coordinates to typed rings."""
|
||||||
|
polygon: list[list[tuple[float, float]]] = []
|
||||||
|
for ring in coordinates:
|
||||||
|
polygon.append([(float(point[0]), float(point[1])) for point in ring])
|
||||||
|
return polygon
|
||||||
|
|
||||||
|
|
||||||
|
def extract_site_lon_lat(csv_text: str) -> tuple[float, float]:
|
||||||
|
"""Extract the NSRDB site longitude and latitude from CSV metadata rows."""
|
||||||
|
rows = list(csv.reader(csv_text.splitlines()))
|
||||||
|
if len(rows) < 2:
|
||||||
|
raise ValueError("Could not read NSRDB metadata rows.")
|
||||||
|
|
||||||
|
metadata = {key.strip().lower(): value.strip() for key, value in zip(rows[0], rows[1])}
|
||||||
|
try:
|
||||||
|
lon = float(metadata["longitude"])
|
||||||
|
lat = float(metadata["latitude"])
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError("Could not find Longitude/Latitude metadata in NSRDB CSV.") from error
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("NSRDB Longitude/Latitude metadata is not numeric.") from error
|
||||||
|
|
||||||
|
return lon, lat
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ghi_values(csv_text: str) -> list[float]:
|
||||||
|
"""Extract numeric GHI values from an NSRDB CSV response."""
|
||||||
|
rows = list(csv.reader(csv_text.splitlines()))
|
||||||
|
header_index = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, row in enumerate(rows)
|
||||||
|
if {"Year", "Month", "Day", "Hour", "Minute", "GHI"}.issubset(set(row))
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if header_index is None:
|
||||||
|
raise ValueError("Could not find the NSRDB data header row with a GHI column.")
|
||||||
|
|
||||||
|
ghi_index = rows[header_index].index("GHI")
|
||||||
|
ghi_values: list[float] = []
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
if len(row) <= ghi_index or not row[ghi_index].strip():
|
||||||
|
continue
|
||||||
|
ghi_values.append(float(row[ghi_index]))
|
||||||
|
|
||||||
|
if not ghi_values:
|
||||||
|
raise ValueError("No GHI values were found in the NSRDB response.")
|
||||||
|
|
||||||
|
return ghi_values
|
||||||
|
|
||||||
|
|
||||||
|
def site_average_daily_ghi(csv_text: str) -> tuple[float, int]:
|
||||||
|
"""Return average daily GHI in kWh/m2/day and number of GHI rows."""
|
||||||
|
ghi_values = extract_ghi_values(csv_text)
|
||||||
|
return sum(ghi_values) / 1000 / 365, len(ghi_values)
|
||||||
|
|
||||||
|
|
||||||
|
def average_geometry_latitude(county_geometry: list[list[list[tuple[float, float]]]]) -> float:
|
||||||
|
"""Return a representative latitude for local meter projection."""
|
||||||
|
latitudes = [
|
||||||
|
lat
|
||||||
|
for polygon in county_geometry
|
||||||
|
for ring in polygon[:1]
|
||||||
|
for _lon, lat in ring
|
||||||
|
]
|
||||||
|
if not latitudes:
|
||||||
|
return 0.0
|
||||||
|
return statistics.fmean(latitudes)
|
||||||
|
|
||||||
|
|
||||||
|
def project_lon_lat(lon: float, lat: float, reference_lat: float) -> tuple[float, float]:
|
||||||
|
"""Project lon/lat to local meters with an equirectangular approximation."""
|
||||||
|
reference_lat_radians = math.radians(reference_lat)
|
||||||
|
x = EARTH_RADIUS_M * math.radians(lon) * math.cos(reference_lat_radians)
|
||||||
|
y = EARTH_RADIUS_M * math.radians(lat)
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def project_county_geometry(
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]],
|
||||||
|
reference_lat: float,
|
||||||
|
) -> list[list[list[tuple[float, float]]]]:
|
||||||
|
"""Project county polygon rings to local meter coordinates."""
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
[project_lon_lat(lon, lat, reference_lat) for lon, lat in ring]
|
||||||
|
for ring in polygon
|
||||||
|
]
|
||||||
|
for polygon in county_geometry
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def polygon_area(points: list[tuple[float, float]]) -> float:
|
||||||
|
"""Return absolute polygon area using the shoelace formula."""
|
||||||
|
if len(points) < 3:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
area = 0.0
|
||||||
|
for index, (x1, y1) in enumerate(points):
|
||||||
|
x2, y2 = points[(index + 1) % len(points)]
|
||||||
|
area += x1 * y2 - x2 * y1
|
||||||
|
return abs(area) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def remove_closing_point(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||||
|
"""Remove duplicate final point before polygon clipping."""
|
||||||
|
if len(points) > 1 and points[0] == points[-1]:
|
||||||
|
return points[:-1]
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def clip_ring_to_rectangle(
|
||||||
|
ring: list[tuple[float, float]],
|
||||||
|
min_x: float,
|
||||||
|
min_y: float,
|
||||||
|
max_x: float,
|
||||||
|
max_y: float,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Clip a polygon ring to an axis-aligned rectangle."""
|
||||||
|
points = remove_closing_point(ring)
|
||||||
|
|
||||||
|
def clip_edge(
|
||||||
|
input_points: list[tuple[float, float]],
|
||||||
|
inside: object,
|
||||||
|
intersect: object,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
if not input_points:
|
||||||
|
return []
|
||||||
|
|
||||||
|
output_points: list[tuple[float, float]] = []
|
||||||
|
previous = input_points[-1]
|
||||||
|
previous_inside = inside(previous)
|
||||||
|
for current in input_points:
|
||||||
|
current_inside = inside(current)
|
||||||
|
if current_inside:
|
||||||
|
if not previous_inside:
|
||||||
|
output_points.append(intersect(previous, current))
|
||||||
|
output_points.append(current)
|
||||||
|
elif previous_inside:
|
||||||
|
output_points.append(intersect(previous, current))
|
||||||
|
previous = current
|
||||||
|
previous_inside = current_inside
|
||||||
|
return output_points
|
||||||
|
|
||||||
|
def vertical_intersection(x_value: float, start: tuple[float, float], end: tuple[float, float]) -> tuple[float, float]:
|
||||||
|
x1, y1 = start
|
||||||
|
x2, y2 = end
|
||||||
|
if x2 == x1:
|
||||||
|
return x_value, y1
|
||||||
|
ratio = (x_value - x1) / (x2 - x1)
|
||||||
|
return x_value, y1 + ratio * (y2 - y1)
|
||||||
|
|
||||||
|
def horizontal_intersection(y_value: float, start: tuple[float, float], end: tuple[float, float]) -> tuple[float, float]:
|
||||||
|
x1, y1 = start
|
||||||
|
x2, y2 = end
|
||||||
|
if y2 == y1:
|
||||||
|
return x1, y_value
|
||||||
|
ratio = (y_value - y1) / (y2 - y1)
|
||||||
|
return x1 + ratio * (x2 - x1), y_value
|
||||||
|
|
||||||
|
points = clip_edge(points, lambda point: point[0] >= min_x, lambda start, end: vertical_intersection(min_x, start, end))
|
||||||
|
points = clip_edge(points, lambda point: point[0] <= max_x, lambda start, end: vertical_intersection(max_x, start, end))
|
||||||
|
points = clip_edge(points, lambda point: point[1] >= min_y, lambda start, end: horizontal_intersection(min_y, start, end))
|
||||||
|
points = clip_edge(points, lambda point: point[1] <= max_y, lambda start, end: horizontal_intersection(max_y, start, end))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def projected_polygon_area(polygon: list[list[tuple[float, float]]]) -> float:
|
||||||
|
"""Return projected polygon area, subtracting interior rings."""
|
||||||
|
if not polygon:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
area = polygon_area(polygon[0])
|
||||||
|
for hole in polygon[1:]:
|
||||||
|
area -= polygon_area(hole)
|
||||||
|
return max(0.0, area)
|
||||||
|
|
||||||
|
|
||||||
|
def clipped_projected_polygon_area(
|
||||||
|
polygon: list[list[tuple[float, float]]],
|
||||||
|
min_x: float,
|
||||||
|
min_y: float,
|
||||||
|
max_x: float,
|
||||||
|
max_y: float,
|
||||||
|
) -> float:
|
||||||
|
"""Return projected polygon area inside a rectangle, subtracting holes."""
|
||||||
|
if not polygon:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
clipped_exterior = clip_ring_to_rectangle(polygon[0], min_x, min_y, max_x, max_y)
|
||||||
|
area = polygon_area(clipped_exterior)
|
||||||
|
for hole in polygon[1:]:
|
||||||
|
clipped_hole = clip_ring_to_rectangle(hole, min_x, min_y, max_x, max_y)
|
||||||
|
area -= polygon_area(clipped_hole)
|
||||||
|
return max(0.0, area)
|
||||||
|
|
||||||
|
|
||||||
|
def area_weighted_average(
|
||||||
|
site_averages: list[float],
|
||||||
|
site_lon_lats: list[tuple[float, float]],
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]] | None,
|
||||||
|
cell_size_m: float,
|
||||||
|
) -> dict[str, float | int]:
|
||||||
|
"""Compute a county average weighted by estimated grid-cell overlap area."""
|
||||||
|
if county_geometry is None:
|
||||||
|
return {
|
||||||
|
"area_weighted_avg": math.nan,
|
||||||
|
"area_weighted_sites": 0,
|
||||||
|
"weighted_cell_area_km2": math.nan,
|
||||||
|
"county_area_km2": math.nan,
|
||||||
|
}
|
||||||
|
|
||||||
|
reference_lat = average_geometry_latitude(county_geometry)
|
||||||
|
projected_county = project_county_geometry(county_geometry, reference_lat)
|
||||||
|
half_cell = cell_size_m / 2
|
||||||
|
weighted_sum = 0.0
|
||||||
|
total_weight = 0.0
|
||||||
|
weighted_sites = 0
|
||||||
|
county_area = sum(projected_polygon_area(polygon) for polygon in projected_county)
|
||||||
|
|
||||||
|
for site_average, (lon, lat) in zip(site_averages, site_lon_lats):
|
||||||
|
x, y = project_lon_lat(lon, lat, reference_lat)
|
||||||
|
min_x = x - half_cell
|
||||||
|
min_y = y - half_cell
|
||||||
|
max_x = x + half_cell
|
||||||
|
max_y = y + half_cell
|
||||||
|
overlap_area = sum(
|
||||||
|
clipped_projected_polygon_area(polygon, min_x, min_y, max_x, max_y)
|
||||||
|
for polygon in projected_county
|
||||||
|
)
|
||||||
|
if overlap_area <= 0:
|
||||||
|
continue
|
||||||
|
weighted_sum += site_average * overlap_area
|
||||||
|
total_weight += overlap_area
|
||||||
|
weighted_sites += 1
|
||||||
|
|
||||||
|
area_weighted_avg = weighted_sum / total_weight if total_weight else math.nan
|
||||||
|
return {
|
||||||
|
"area_weighted_avg": area_weighted_avg,
|
||||||
|
"area_weighted_sites": weighted_sites,
|
||||||
|
"weighted_cell_area_km2": total_weight / 1_000_000,
|
||||||
|
"county_area_km2": county_area / 1_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_archives(
|
||||||
|
paths: list[Path],
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]] | None,
|
||||||
|
cell_size_m: float,
|
||||||
|
) -> dict[str, float | int | str]:
|
||||||
|
"""Summarize all NSRDB site CSVs in one or more ZIP archives."""
|
||||||
|
site_averages: list[float] = []
|
||||||
|
site_lon_lats: list[tuple[float, float]] = []
|
||||||
|
row_counts: list[int] = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
csv_names = sorted(name for name in archive.namelist() if name.lower().endswith(".csv"))
|
||||||
|
for csv_name in csv_names:
|
||||||
|
csv_text = archive.read(csv_name).decode("utf-8-sig")
|
||||||
|
site_average, row_count = site_average_daily_ghi(csv_text)
|
||||||
|
site_lon_lat = extract_site_lon_lat(csv_text)
|
||||||
|
site_averages.append(site_average)
|
||||||
|
site_lon_lats.append(site_lon_lat)
|
||||||
|
row_counts.append(row_count)
|
||||||
|
|
||||||
|
if not site_averages:
|
||||||
|
raise ValueError(f"No CSV files found in {', '.join(str(path) for path in paths)}")
|
||||||
|
|
||||||
|
weighted = area_weighted_average(site_averages, site_lon_lats, county_geometry, cell_size_m)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"polygon_sites": len(site_averages),
|
||||||
|
"ghi_rows_per_site_min": min(row_counts),
|
||||||
|
"ghi_rows_per_site_max": max(row_counts),
|
||||||
|
"avgSolarGhiKwhM2Day": weighted["area_weighted_avg"],
|
||||||
|
"area_weighted_avg": weighted["area_weighted_avg"],
|
||||||
|
"area_weighted_sites": weighted["area_weighted_sites"],
|
||||||
|
"weighted_cell_area_km2": weighted["weighted_cell_area_km2"],
|
||||||
|
"county_area_km2": weighted["county_area_km2"],
|
||||||
|
"site_avg_min": min(site_averages),
|
||||||
|
"site_avg_max": max(site_averages),
|
||||||
|
"site_avg_stddev": statistics.pstdev(site_averages) if len(site_averages) > 1 else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_archive(
|
||||||
|
path: Path,
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]] | None,
|
||||||
|
cell_size_m: float,
|
||||||
|
) -> dict[str, float | int | str]:
|
||||||
|
"""Summarize all NSRDB site CSVs in one ZIP archive."""
|
||||||
|
return summarize_archives([path], county_geometry, cell_size_m)
|
||||||
|
|
||||||
|
|
||||||
|
def format_float(value: float | None, places: int = 3) -> str:
|
||||||
|
"""Format an optional float for CSV output."""
|
||||||
|
if value is None or math.isnan(value):
|
||||||
|
return ""
|
||||||
|
return f"{value:.{places}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_row(
|
||||||
|
archive_paths: list[Path],
|
||||||
|
polygon_summary: dict[str, float | int | str],
|
||||||
|
request_rows: list[dict[str, str]],
|
||||||
|
representative_point_row: dict[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build one output row from polygon and optional representative-point summaries."""
|
||||||
|
county_fips = county_fips_from_archive(archive_paths[0])
|
||||||
|
request_row = request_rows[0] if request_rows else {}
|
||||||
|
request_site_counts = [
|
||||||
|
int(row["site_count"])
|
||||||
|
for row in request_rows
|
||||||
|
if str(row.get("site_count", "")).strip().isdigit()
|
||||||
|
]
|
||||||
|
archive_zip = ";".join(str(path) for path in archive_paths)
|
||||||
|
final_avg = float(polygon_summary["avgSolarGhiKwhM2Day"])
|
||||||
|
area_weighted_avg = float(polygon_summary["area_weighted_avg"])
|
||||||
|
representative_point_avg = (
|
||||||
|
float(representative_point_row["avgSolarGhiKwhM2Day"])
|
||||||
|
if representative_point_row.get("avgSolarGhiKwhM2Day")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
weighted_minus_representative_point = (
|
||||||
|
area_weighted_avg - representative_point_avg if representative_point_avg is not None else None
|
||||||
|
)
|
||||||
|
weighted_pct_diff_representative_point = (
|
||||||
|
weighted_minus_representative_point / representative_point_avg * 100
|
||||||
|
if representative_point_avg not in (None, 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": county_fips,
|
||||||
|
"county_name": request_row.get("county_name") or representative_point_row.get("county_name", ""),
|
||||||
|
"state_abbr": request_row.get("state_abbr") or representative_point_row.get("state_abbr", ""),
|
||||||
|
"archive_zip": archive_zip,
|
||||||
|
"polygon_sites": str(polygon_summary["polygon_sites"]),
|
||||||
|
"request_site_count": str(sum(request_site_counts)) if request_site_counts else request_row.get("site_count", ""),
|
||||||
|
"ghi_rows_per_site_min": str(polygon_summary["ghi_rows_per_site_min"]),
|
||||||
|
"ghi_rows_per_site_max": str(polygon_summary["ghi_rows_per_site_max"]),
|
||||||
|
"avgSolarGhiKwhM2Day": format_float(final_avg),
|
||||||
|
"areaWeightedAvgSolarGhiKwhM2Day": format_float(area_weighted_avg),
|
||||||
|
"areaWeightedSites": str(polygon_summary["area_weighted_sites"]),
|
||||||
|
"weightedCellAreaKm2": format_float(float(polygon_summary["weighted_cell_area_km2"]), 1),
|
||||||
|
"countyAreaKm2": format_float(float(polygon_summary["county_area_km2"]), 1),
|
||||||
|
"site_avg_min": format_float(float(polygon_summary["site_avg_min"])),
|
||||||
|
"site_avg_max": format_float(float(polygon_summary["site_avg_max"])),
|
||||||
|
"site_avg_stddev": format_float(float(polygon_summary["site_avg_stddev"])),
|
||||||
|
"representativePointAvgSolarGhiKwhM2Day": format_float(representative_point_avg),
|
||||||
|
"areaWeightedMinusRepresentativePoint": format_float(weighted_minus_representative_point),
|
||||||
|
"areaWeightedPctDiffFromRepresentativePoint": format_float(weighted_pct_diff_representative_point),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_signature(paths: list[Path]) -> set[str]:
|
||||||
|
"""Return archive filenames for comparing existing summary rows."""
|
||||||
|
return {path.name for path in paths}
|
||||||
|
|
||||||
|
|
||||||
|
def existing_archive_signature(row: dict[str, str]) -> set[str]:
|
||||||
|
"""Return archive filenames recorded in an existing summary row."""
|
||||||
|
return {
|
||||||
|
Path(path_text).name
|
||||||
|
for path_text in row.get("archive_zip", "").split(";")
|
||||||
|
if path_text.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write polygon GHI summary rows to disk."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=SUMMARY_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> None:
|
||||||
|
"""Summarize downloaded polygon archives."""
|
||||||
|
county_geometries = load_county_geometries(args.counties_geojson)
|
||||||
|
request_rows = read_grouped_lookup(args.requests_csv, "county_fips")
|
||||||
|
representative_point_rows = read_lookup(args.representative_point_csv, "county_fips")
|
||||||
|
existing_rows = (
|
||||||
|
read_lookup(args.output_csv, "county_fips")
|
||||||
|
if args.reuse_existing_output
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
archives = sorted(args.archive_dir.glob(args.archive_glob))
|
||||||
|
|
||||||
|
if not archives:
|
||||||
|
print(f"No archives matched {args.archive_dir / args.archive_glob}")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
archive_groups = archive_groups_by_county(archives)
|
||||||
|
total = len(archive_groups)
|
||||||
|
reused_count = 0
|
||||||
|
for index, county_fips in enumerate(sorted(archive_groups), start=1):
|
||||||
|
archive_paths = sorted(archive_groups[county_fips])
|
||||||
|
existing_row = existing_rows.get(county_fips)
|
||||||
|
if existing_row and existing_archive_signature(existing_row) == archive_signature(archive_paths):
|
||||||
|
rows.append({field: existing_row.get(field, "") for field in SUMMARY_FIELDS})
|
||||||
|
reused_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
polygon_summary = summarize_archives(
|
||||||
|
archive_paths,
|
||||||
|
county_geometries.get(county_fips),
|
||||||
|
args.cell_size_m,
|
||||||
|
)
|
||||||
|
row = build_summary_row(
|
||||||
|
archive_paths,
|
||||||
|
polygon_summary,
|
||||||
|
request_rows.get(county_fips, []),
|
||||||
|
representative_point_rows.get(county_fips, {}),
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
except (OSError, ValueError, zipfile.BadZipFile) as error:
|
||||||
|
print(f"[{index}/{total}] Failed {county_fips}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tile_note = f", archives={len(archive_paths)}" if len(archive_paths) > 1 else ""
|
||||||
|
print(
|
||||||
|
f"[{index}/{total}] {county_fips}: "
|
||||||
|
f"sites={row['polygon_sites']}, weighted={row['areaWeightedAvgSolarGhiKwhM2Day']}, "
|
||||||
|
f"representative_point={row['representativePointAvgSolarGhiKwhM2Day'] or 'n/a'}"
|
||||||
|
f"{tile_note}"
|
||||||
|
)
|
||||||
|
|
||||||
|
write_summary(rows, args.output_csv)
|
||||||
|
if reused_count:
|
||||||
|
print(f"Reused {reused_count} existing rows whose archive sets still match.")
|
||||||
|
print(f"Wrote {len(rows)} rows to {args.output_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--archive-dir", type=Path, default=DEFAULT_ARCHIVE_DIR)
|
||||||
|
parser.add_argument("--archive-glob", default=DEFAULT_ARCHIVE_GLOB)
|
||||||
|
parser.add_argument("--counties-geojson", type=Path, default=DEFAULT_COUNTIES_GEOJSON)
|
||||||
|
parser.add_argument("--requests-csv", type=Path, default=DEFAULT_REQUESTS_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--representative-point-csv",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_REPRESENTATIVE_POINT_CSV,
|
||||||
|
help="County representative-point GHI summary CSV.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cell-size-m",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_CELL_SIZE_M,
|
||||||
|
help="Estimated NSRDB grid-cell side length in meters. GOES TMY is 4 km.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reuse-existing-output",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Reuse rows already present in --output-csv when the recorded archive filenames "
|
||||||
|
"match the current archive directory, and summarize only missing or changed counties."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.cell_size_m <= 0:
|
||||||
|
parser.error("--cell-size-m must be greater than 0.")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the polygon GHI summarizer."""
|
||||||
|
run(parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Summarize downloaded NSRDB county polygon cloud archives.
|
||||||
|
|
||||||
|
Each downloaded polygon archive contains one CSV per NSRDB grid site that
|
||||||
|
intersects the county polygon or tile. This script computes area-weighted
|
||||||
|
county-level cloudiness metrics across those site CSVs, combining multiple tile
|
||||||
|
archives back into one county summary when present.
|
||||||
|
|
||||||
|
Primary metric:
|
||||||
|
|
||||||
|
cloudinessIndexPct = 1 - mean(clamped(GHI / Clearsky GHI, 0, 1))
|
||||||
|
|
||||||
|
Despite the legacy "Pct" field name, the primary index is stored on a 0-1 scale.
|
||||||
|
Cloud Type bucket fields are stored as percentages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from summarize_nsrdb_county_polygon_archives import (
|
||||||
|
area_weighted_average,
|
||||||
|
archive_groups_by_county,
|
||||||
|
archive_signature,
|
||||||
|
county_fips_from_archive,
|
||||||
|
existing_archive_signature,
|
||||||
|
extract_site_lon_lat,
|
||||||
|
format_float,
|
||||||
|
load_county_geometries,
|
||||||
|
read_grouped_lookup,
|
||||||
|
read_lookup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARCHIVE_DIR = Path("data/nrel/polygon_cloud_archives")
|
||||||
|
DEFAULT_COUNTIES_GEOJSON = Path("data/geojson-counties-fips.json")
|
||||||
|
DEFAULT_REQUESTS_CSV = Path("data/nrel/county_polygon_cloud_request_manifest.csv")
|
||||||
|
DEFAULT_REPRESENTATIVE_POINT_CSV = Path("data/nrel/county_representative_point_cloud_summary.csv")
|
||||||
|
DEFAULT_OUTPUT_CSV = Path("data/nrel/county_polygon_cloud_summary.csv")
|
||||||
|
DEFAULT_CELL_SIZE_M = 4000.0
|
||||||
|
DEFAULT_ARCHIVE_GLOB = "*.zip"
|
||||||
|
DEFAULT_MIN_CLEARSKY_GHI = 50.0
|
||||||
|
|
||||||
|
SUMMARY_FIELDS = [
|
||||||
|
"county_fips",
|
||||||
|
"county_name",
|
||||||
|
"state_abbr",
|
||||||
|
"archive_zip",
|
||||||
|
"polygon_sites",
|
||||||
|
"request_site_count",
|
||||||
|
"rows_per_site_min",
|
||||||
|
"rows_per_site_max",
|
||||||
|
"daylight_rows_per_site_min",
|
||||||
|
"daylight_rows_per_site_max",
|
||||||
|
"cloudinessIndexPct",
|
||||||
|
"areaWeightedCloudinessIndexPct",
|
||||||
|
"areaWeightedAvgObservedToClearskyRatio",
|
||||||
|
"areaWeightedSites",
|
||||||
|
"weightedCellAreaKm2",
|
||||||
|
"countyAreaKm2",
|
||||||
|
"site_cloudiness_min",
|
||||||
|
"site_cloudiness_max",
|
||||||
|
"site_cloudiness_stddev",
|
||||||
|
"clearOrProbablyClearPct",
|
||||||
|
"cloudyOrObscuredPct",
|
||||||
|
"fogPct",
|
||||||
|
"waterCloudPct",
|
||||||
|
"iceCloudPct",
|
||||||
|
"cirrusPct",
|
||||||
|
"unknownCloudTypePct",
|
||||||
|
"representativePointCloudinessIndexPct",
|
||||||
|
"areaWeightedMinusRepresentativePoint",
|
||||||
|
"areaWeightedPctDiffFromRepresentativePoint",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_header(value: str) -> str:
|
||||||
|
"""Normalize a CSV header to make NSRDB spelling variations easier to match."""
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def find_column(header: list[str], candidates: set[str]) -> int | None:
|
||||||
|
"""Return the first header index whose normalized name matches a candidate."""
|
||||||
|
normalized_candidates = {normalized_header(candidate) for candidate in candidates}
|
||||||
|
for index, value in enumerate(header):
|
||||||
|
if normalized_header(value) in normalized_candidates:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_data_header(rows: list[list[str]]) -> int | None:
|
||||||
|
"""Return the NSRDB hourly data header row index."""
|
||||||
|
required = {"Year", "Month", "Day", "Hour", "Minute"}
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
if required.issubset(set(row)):
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_optional_float(row: list[str], index: int | None) -> float | None:
|
||||||
|
"""Parse an optional float field from a CSV row."""
|
||||||
|
if index is None or len(row) <= index:
|
||||||
|
return None
|
||||||
|
value = row[index].strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed if math.isfinite(parsed) else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cloud_type(row: list[str], index: int | None) -> str:
|
||||||
|
"""Parse an optional Cloud Type code as a stable string."""
|
||||||
|
if index is None or len(row) <= index:
|
||||||
|
return ""
|
||||||
|
value = row[index].strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(int(float(value)))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def pct(count: int, total: int) -> float:
|
||||||
|
"""Return a percentage or NaN when there is no denominator."""
|
||||||
|
return count / total * 100 if total else math.nan
|
||||||
|
|
||||||
|
|
||||||
|
def site_cloud_metrics(csv_text: str, min_clearsky_ghi: float) -> dict[str, float | int]:
|
||||||
|
"""Return site-level cloudiness metrics from one NSRDB CSV response."""
|
||||||
|
rows = list(csv.reader(csv_text.splitlines()))
|
||||||
|
header_index = find_data_header(rows)
|
||||||
|
if header_index is None:
|
||||||
|
raise ValueError("Could not find the NSRDB data header row.")
|
||||||
|
|
||||||
|
header = rows[header_index]
|
||||||
|
ghi_index = find_column(header, {"GHI", "ghi"})
|
||||||
|
clearsky_ghi_index = find_column(
|
||||||
|
header,
|
||||||
|
{
|
||||||
|
"Clearsky GHI",
|
||||||
|
"Clear Sky GHI",
|
||||||
|
"Clear-sky GHI",
|
||||||
|
"clearsky_ghi",
|
||||||
|
"clear_sky_ghi",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cloud_type_index = find_column(header, {"Cloud Type", "cloud_type", "cloudtype"})
|
||||||
|
if ghi_index is None:
|
||||||
|
raise ValueError("Could not find a GHI column in the NSRDB response.")
|
||||||
|
if clearsky_ghi_index is None:
|
||||||
|
raise ValueError("Could not find a Clearsky GHI column in the NSRDB response.")
|
||||||
|
if cloud_type_index is None:
|
||||||
|
raise ValueError("Could not find a Cloud Type column in the NSRDB response.")
|
||||||
|
|
||||||
|
all_rows = 0
|
||||||
|
daylight_rows = 0
|
||||||
|
ratio_sum = 0.0
|
||||||
|
daylight_cloud_type_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
for row in rows[header_index + 1 :]:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
all_rows += 1
|
||||||
|
ghi = parse_optional_float(row, ghi_index)
|
||||||
|
clearsky_ghi = parse_optional_float(row, clearsky_ghi_index)
|
||||||
|
cloud_type = parse_cloud_type(row, cloud_type_index)
|
||||||
|
if ghi is None or clearsky_ghi is None or clearsky_ghi < min_clearsky_ghi:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ratio_sum += min(1.0, max(0.0, ghi / clearsky_ghi))
|
||||||
|
daylight_rows += 1
|
||||||
|
if cloud_type:
|
||||||
|
daylight_cloud_type_counts[cloud_type] = daylight_cloud_type_counts.get(cloud_type, 0) + 1
|
||||||
|
|
||||||
|
if daylight_rows == 0:
|
||||||
|
raise ValueError("No daylight rows with valid GHI and Clearsky GHI were found.")
|
||||||
|
|
||||||
|
avg_ratio = ratio_sum / daylight_rows
|
||||||
|
cloudiness = 1 - avg_ratio
|
||||||
|
clear_or_probably_clear = daylight_cloud_type_counts.get("0", 0) + daylight_cloud_type_counts.get("1", 0)
|
||||||
|
cloudy_or_obscured = sum(
|
||||||
|
daylight_cloud_type_counts.get(code, 0)
|
||||||
|
for code in ["2", "3", "4", "5", "6", "7", "8", "9", "11", "12"]
|
||||||
|
)
|
||||||
|
water_cloud = (
|
||||||
|
daylight_cloud_type_counts.get("3", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("4", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("5", 0)
|
||||||
|
)
|
||||||
|
ice_cloud = (
|
||||||
|
daylight_cloud_type_counts.get("6", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("8", 0)
|
||||||
|
+ daylight_cloud_type_counts.get("9", 0)
|
||||||
|
)
|
||||||
|
unknown_cloud_type = daylight_cloud_type_counts.get("10", 0) + daylight_cloud_type_counts.get("-15", 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cloudiness": cloudiness,
|
||||||
|
"avg_ratio": avg_ratio,
|
||||||
|
"all_rows": all_rows,
|
||||||
|
"daylight_rows": daylight_rows,
|
||||||
|
"clear_or_probably_clear_pct": pct(clear_or_probably_clear, daylight_rows),
|
||||||
|
"cloudy_or_obscured_pct": pct(cloudy_or_obscured, daylight_rows),
|
||||||
|
"fog_pct": pct(daylight_cloud_type_counts.get("2", 0), daylight_rows),
|
||||||
|
"water_cloud_pct": pct(water_cloud, daylight_rows),
|
||||||
|
"ice_cloud_pct": pct(ice_cloud, daylight_rows),
|
||||||
|
"cirrus_pct": pct(daylight_cloud_type_counts.get("7", 0), daylight_rows),
|
||||||
|
"unknown_cloud_type_pct": pct(unknown_cloud_type, daylight_rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_metric(
|
||||||
|
site_metrics: list[dict[str, float | int]],
|
||||||
|
metric_key: str,
|
||||||
|
site_lon_lats: list[tuple[float, float]],
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]] | None,
|
||||||
|
cell_size_m: float,
|
||||||
|
) -> dict[str, float | int]:
|
||||||
|
"""Area-weight one metric across sites using estimated grid-cell overlap."""
|
||||||
|
values = [float(metrics[metric_key]) for metrics in site_metrics]
|
||||||
|
return area_weighted_average(values, site_lon_lats, county_geometry, cell_size_m)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_archives(
|
||||||
|
paths: list[Path],
|
||||||
|
county_geometry: list[list[list[tuple[float, float]]]] | None,
|
||||||
|
cell_size_m: float,
|
||||||
|
min_clearsky_ghi: float,
|
||||||
|
) -> dict[str, float | int]:
|
||||||
|
"""Summarize all NSRDB site CSVs in one or more ZIP archives."""
|
||||||
|
site_metrics: list[dict[str, float | int]] = []
|
||||||
|
site_lon_lats: list[tuple[float, float]] = []
|
||||||
|
empty_csv_members: list[str] = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
bad_member = archive.testzip()
|
||||||
|
if bad_member:
|
||||||
|
raise zipfile.BadZipFile(
|
||||||
|
f"CRC check failed for {bad_member} in {path.name}"
|
||||||
|
)
|
||||||
|
csv_names = sorted(name for name in archive.namelist() if name.lower().endswith(".csv"))
|
||||||
|
for csv_name in csv_names:
|
||||||
|
csv_text = archive.read(csv_name).decode("utf-8-sig")
|
||||||
|
if not csv_text.strip():
|
||||||
|
empty_csv_members.append(f"{path.name}:{csv_name}")
|
||||||
|
continue
|
||||||
|
site_metrics.append(site_cloud_metrics(csv_text, min_clearsky_ghi))
|
||||||
|
site_lon_lats.append(extract_site_lon_lat(csv_text))
|
||||||
|
|
||||||
|
if not site_metrics:
|
||||||
|
raise ValueError(f"No CSV files found in {', '.join(str(path) for path in paths)}")
|
||||||
|
|
||||||
|
cloudiness_values = [float(metrics["cloudiness"]) for metrics in site_metrics]
|
||||||
|
row_counts = [int(metrics["all_rows"]) for metrics in site_metrics]
|
||||||
|
daylight_row_counts = [int(metrics["daylight_rows"]) for metrics in site_metrics]
|
||||||
|
|
||||||
|
weighted_cloudiness = weighted_metric(site_metrics, "cloudiness", site_lon_lats, county_geometry, cell_size_m)
|
||||||
|
weighted_avg_ratio = weighted_metric(site_metrics, "avg_ratio", site_lon_lats, county_geometry, cell_size_m)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"polygon_sites": len(site_metrics),
|
||||||
|
"rows_per_site_min": min(row_counts),
|
||||||
|
"rows_per_site_max": max(row_counts),
|
||||||
|
"daylight_rows_per_site_min": min(daylight_row_counts),
|
||||||
|
"daylight_rows_per_site_max": max(daylight_row_counts),
|
||||||
|
"area_weighted_cloudiness": weighted_cloudiness["area_weighted_avg"],
|
||||||
|
"area_weighted_avg_ratio": weighted_avg_ratio["area_weighted_avg"],
|
||||||
|
"area_weighted_sites": weighted_cloudiness["area_weighted_sites"],
|
||||||
|
"weighted_cell_area_km2": weighted_cloudiness["weighted_cell_area_km2"],
|
||||||
|
"county_area_km2": weighted_cloudiness["county_area_km2"],
|
||||||
|
"site_cloudiness_min": min(cloudiness_values),
|
||||||
|
"site_cloudiness_max": max(cloudiness_values),
|
||||||
|
"site_cloudiness_stddev": statistics.pstdev(cloudiness_values) if len(cloudiness_values) > 1 else 0.0,
|
||||||
|
"clear_or_probably_clear_pct": weighted_metric(
|
||||||
|
site_metrics, "clear_or_probably_clear_pct", site_lon_lats, county_geometry, cell_size_m
|
||||||
|
)["area_weighted_avg"],
|
||||||
|
"cloudy_or_obscured_pct": weighted_metric(
|
||||||
|
site_metrics, "cloudy_or_obscured_pct", site_lon_lats, county_geometry, cell_size_m
|
||||||
|
)["area_weighted_avg"],
|
||||||
|
"fog_pct": weighted_metric(site_metrics, "fog_pct", site_lon_lats, county_geometry, cell_size_m)[
|
||||||
|
"area_weighted_avg"
|
||||||
|
],
|
||||||
|
"water_cloud_pct": weighted_metric(
|
||||||
|
site_metrics, "water_cloud_pct", site_lon_lats, county_geometry, cell_size_m
|
||||||
|
)["area_weighted_avg"],
|
||||||
|
"ice_cloud_pct": weighted_metric(site_metrics, "ice_cloud_pct", site_lon_lats, county_geometry, cell_size_m)[
|
||||||
|
"area_weighted_avg"
|
||||||
|
],
|
||||||
|
"cirrus_pct": weighted_metric(site_metrics, "cirrus_pct", site_lon_lats, county_geometry, cell_size_m)[
|
||||||
|
"area_weighted_avg"
|
||||||
|
],
|
||||||
|
"unknown_cloud_type_pct": weighted_metric(
|
||||||
|
site_metrics, "unknown_cloud_type_pct", site_lon_lats, county_geometry, cell_size_m
|
||||||
|
)["area_weighted_avg"],
|
||||||
|
"empty_csv_members": len(empty_csv_members),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_part_id(path: Path) -> str:
|
||||||
|
"""Return county or tileNNN for one downloaded archive filename."""
|
||||||
|
parts = path.stem.split("_")
|
||||||
|
return parts[1] if len(parts) > 1 and parts[1].startswith("tile") else "county"
|
||||||
|
|
||||||
|
|
||||||
|
def missing_archive_parts(
|
||||||
|
archive_paths: list[Path],
|
||||||
|
request_rows: list[dict[str, str]],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return requested county/tile archive parts that are not downloaded."""
|
||||||
|
expected = {
|
||||||
|
row.get("tile_id", "").strip() or "county"
|
||||||
|
for row in request_rows
|
||||||
|
if row.get("download_url", "").strip()
|
||||||
|
}
|
||||||
|
actual = {archive_part_id(path) for path in archive_paths}
|
||||||
|
return sorted(expected - actual)
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_row(
|
||||||
|
archive_paths: list[Path],
|
||||||
|
polygon_summary: dict[str, float | int],
|
||||||
|
request_rows: list[dict[str, str]],
|
||||||
|
representative_point_row: dict[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Build one output row from polygon and optional representative-point summaries."""
|
||||||
|
county_fips = county_fips_from_archive(archive_paths[0])
|
||||||
|
request_row = request_rows[0] if request_rows else {}
|
||||||
|
request_site_counts = [
|
||||||
|
int(row["site_count"])
|
||||||
|
for row in request_rows
|
||||||
|
if str(row.get("site_count", "")).strip().isdigit()
|
||||||
|
]
|
||||||
|
archive_zip = ";".join(str(path) for path in archive_paths)
|
||||||
|
area_weighted_cloudiness = float(polygon_summary["area_weighted_cloudiness"])
|
||||||
|
representative_point_cloudiness = (
|
||||||
|
float(representative_point_row["cloudinessIndexPct"])
|
||||||
|
if representative_point_row.get("cloudinessIndexPct")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
weighted_minus_representative_point = (
|
||||||
|
area_weighted_cloudiness - representative_point_cloudiness
|
||||||
|
if representative_point_cloudiness is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
weighted_pct_diff_representative_point = (
|
||||||
|
weighted_minus_representative_point / representative_point_cloudiness * 100
|
||||||
|
if representative_point_cloudiness not in (None, 0)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"county_fips": county_fips,
|
||||||
|
"county_name": request_row.get("county_name") or representative_point_row.get("county_name", ""),
|
||||||
|
"state_abbr": request_row.get("state_abbr") or representative_point_row.get("state_abbr", ""),
|
||||||
|
"archive_zip": archive_zip,
|
||||||
|
"polygon_sites": str(polygon_summary["polygon_sites"]),
|
||||||
|
"request_site_count": str(sum(request_site_counts)) if request_site_counts else request_row.get("site_count", ""),
|
||||||
|
"rows_per_site_min": str(polygon_summary["rows_per_site_min"]),
|
||||||
|
"rows_per_site_max": str(polygon_summary["rows_per_site_max"]),
|
||||||
|
"daylight_rows_per_site_min": str(polygon_summary["daylight_rows_per_site_min"]),
|
||||||
|
"daylight_rows_per_site_max": str(polygon_summary["daylight_rows_per_site_max"]),
|
||||||
|
"cloudinessIndexPct": format_float(area_weighted_cloudiness, 4),
|
||||||
|
"areaWeightedCloudinessIndexPct": format_float(area_weighted_cloudiness, 4),
|
||||||
|
"areaWeightedAvgObservedToClearskyRatio": format_float(float(polygon_summary["area_weighted_avg_ratio"]), 4),
|
||||||
|
"areaWeightedSites": str(polygon_summary["area_weighted_sites"]),
|
||||||
|
"weightedCellAreaKm2": format_float(float(polygon_summary["weighted_cell_area_km2"]), 1),
|
||||||
|
"countyAreaKm2": format_float(float(polygon_summary["county_area_km2"]), 1),
|
||||||
|
"site_cloudiness_min": format_float(float(polygon_summary["site_cloudiness_min"]), 4),
|
||||||
|
"site_cloudiness_max": format_float(float(polygon_summary["site_cloudiness_max"]), 4),
|
||||||
|
"site_cloudiness_stddev": format_float(float(polygon_summary["site_cloudiness_stddev"]), 4),
|
||||||
|
"clearOrProbablyClearPct": format_float(float(polygon_summary["clear_or_probably_clear_pct"]), 2),
|
||||||
|
"cloudyOrObscuredPct": format_float(float(polygon_summary["cloudy_or_obscured_pct"]), 2),
|
||||||
|
"fogPct": format_float(float(polygon_summary["fog_pct"]), 2),
|
||||||
|
"waterCloudPct": format_float(float(polygon_summary["water_cloud_pct"]), 2),
|
||||||
|
"iceCloudPct": format_float(float(polygon_summary["ice_cloud_pct"]), 2),
|
||||||
|
"cirrusPct": format_float(float(polygon_summary["cirrus_pct"]), 2),
|
||||||
|
"unknownCloudTypePct": format_float(float(polygon_summary["unknown_cloud_type_pct"]), 2),
|
||||||
|
"representativePointCloudinessIndexPct": format_float(representative_point_cloudiness, 4),
|
||||||
|
"areaWeightedMinusRepresentativePoint": format_float(weighted_minus_representative_point, 4),
|
||||||
|
"areaWeightedPctDiffFromRepresentativePoint": format_float(weighted_pct_diff_representative_point),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(rows: list[dict[str, str]], output_csv: Path) -> None:
|
||||||
|
"""Write polygon cloud summary rows to disk."""
|
||||||
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with output_csv.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=SUMMARY_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> None:
|
||||||
|
"""Summarize downloaded polygon cloud archives."""
|
||||||
|
county_geometries = load_county_geometries(args.counties_geojson)
|
||||||
|
request_rows = read_grouped_lookup(args.requests_csv, "county_fips")
|
||||||
|
representative_point_rows = read_lookup(args.representative_point_csv, "county_fips")
|
||||||
|
existing_rows = (
|
||||||
|
read_lookup(args.output_csv, "county_fips")
|
||||||
|
if args.reuse_existing_output
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
archives = sorted(args.archive_dir.glob(args.archive_glob))
|
||||||
|
|
||||||
|
if not archives:
|
||||||
|
print(f"No archives matched {args.archive_dir / args.archive_glob}")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
archive_groups = archive_groups_by_county(archives)
|
||||||
|
total = len(archive_groups)
|
||||||
|
reused_count = 0
|
||||||
|
for index, county_fips in enumerate(sorted(archive_groups), start=1):
|
||||||
|
archive_paths = sorted(archive_groups[county_fips])
|
||||||
|
county_request_rows = request_rows.get(county_fips, [])
|
||||||
|
missing_parts = missing_archive_parts(archive_paths, county_request_rows)
|
||||||
|
if missing_parts:
|
||||||
|
print(
|
||||||
|
f"[{index}/{total}] Skipped {county_fips}: incomplete archive set; "
|
||||||
|
f"missing {', '.join(missing_parts)}."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_row = existing_rows.get(county_fips)
|
||||||
|
if existing_row and existing_archive_signature(existing_row) == archive_signature(archive_paths):
|
||||||
|
rows.append({field: existing_row.get(field, "") for field in SUMMARY_FIELDS})
|
||||||
|
reused_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
polygon_summary = summarize_archives(
|
||||||
|
archive_paths,
|
||||||
|
county_geometries.get(county_fips),
|
||||||
|
args.cell_size_m,
|
||||||
|
args.min_clearsky_ghi,
|
||||||
|
)
|
||||||
|
row = build_summary_row(
|
||||||
|
archive_paths,
|
||||||
|
polygon_summary,
|
||||||
|
county_request_rows,
|
||||||
|
representative_point_rows.get(county_fips, {}),
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
except (OSError, ValueError, zipfile.BadZipFile) as error:
|
||||||
|
print(f"[{index}/{total}] Failed {county_fips}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tile_note = f", archives={len(archive_paths)}" if len(archive_paths) > 1 else ""
|
||||||
|
empty_note = (
|
||||||
|
f", skipped_empty_csvs={polygon_summary['empty_csv_members']}"
|
||||||
|
if polygon_summary["empty_csv_members"]
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{index}/{total}] {county_fips}: "
|
||||||
|
f"sites={row['polygon_sites']}, weighted={row['areaWeightedCloudinessIndexPct']}, "
|
||||||
|
f"representative_point={row['representativePointCloudinessIndexPct'] or 'n/a'}"
|
||||||
|
f"{tile_note}{empty_note}"
|
||||||
|
)
|
||||||
|
|
||||||
|
write_summary(rows, args.output_csv)
|
||||||
|
if reused_count:
|
||||||
|
print(f"Reused {reused_count} existing rows whose archive sets still match.")
|
||||||
|
print(f"Wrote {len(rows)} rows to {args.output_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--archive-dir", type=Path, default=DEFAULT_ARCHIVE_DIR)
|
||||||
|
parser.add_argument("--archive-glob", default=DEFAULT_ARCHIVE_GLOB)
|
||||||
|
parser.add_argument("--counties-geojson", type=Path, default=DEFAULT_COUNTIES_GEOJSON)
|
||||||
|
parser.add_argument("--requests-csv", type=Path, default=DEFAULT_REQUESTS_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--representative-point-csv",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_REPRESENTATIVE_POINT_CSV,
|
||||||
|
help="County representative-point cloud summary CSV.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output-csv", type=Path, default=DEFAULT_OUTPUT_CSV)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cell-size-m",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_CELL_SIZE_M,
|
||||||
|
help="Estimated NSRDB grid-cell side length in meters. GOES TMY is 4 km.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-clearsky-ghi",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_MIN_CLEARSKY_GHI,
|
||||||
|
help="Minimum Clearsky GHI W/m2 for daylight cloudiness ratio rows.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reuse-existing-output",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Reuse rows already present in --output-csv when the recorded archive filenames "
|
||||||
|
"match the current archive directory, and summarize only missing or changed counties."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.cell_size_m <= 0:
|
||||||
|
parser.error("--cell-size-m must be greater than 0.")
|
||||||
|
if args.min_clearsky_ghi < 0:
|
||||||
|
parser.error("--min-clearsky-ghi must be 0 or greater.")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the polygon cloud summarizer."""
|
||||||
|
run(parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs a local HTTP server for the US County Climate Explorer.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Starts Python's built-in HTTP server from the project root so the browser can
|
||||||
|
load data/climate-data.csv and related static assets over http://localhost.
|
||||||
|
|
||||||
|
.PARAMETER Port
|
||||||
|
The localhost port to bind. Defaults to 8000.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\serve.ps1
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[int]$Port = 8000
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$VenvPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
||||||
|
$PythonCommand = if (Test-Path $VenvPython) { $VenvPython } else { "python" }
|
||||||
|
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
Write-Host "Serving US County Climate Explorer at http://localhost:$Port/"
|
||||||
|
Write-Host "Press Ctrl+C to stop."
|
||||||
|
|
||||||
|
& $PythonCommand -m http.server $Port --bind 127.0.0.1
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
:root {
|
||||||
|
--bg-1: #eef4ff;
|
||||||
|
--bg-2: #c2dfff;
|
||||||
|
--ink: #0f1f38;
|
||||||
|
--ink-soft: #33496b;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-shadow: 0 18px 40px rgba(12, 42, 88, 0.14);
|
||||||
|
--line: #cad8ef;
|
||||||
|
--accent: #1f67d2;
|
||||||
|
--accent-soft: #4e8df2;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Source Sans 3", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 10%, rgba(117, 180, 255, 0.38), transparent 34%),
|
||||||
|
radial-gradient(circle at 90% 30%, rgba(255, 255, 255, 0.9), transparent 40%),
|
||||||
|
linear-gradient(140deg, var(--bg-1), var(--bg-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-sources-modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 340px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
min-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="range"],
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-select-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
filter: brightness(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend,
|
||||||
|
.state-details {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7faff;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-scale {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #bccff3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-koppen-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-koppen-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-koppen-row-selected {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-koppen-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(20, 45, 84, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-details p {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-control {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(31, 103, 210, 0.36);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-dialog {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(940px, calc(100vw - 2rem));
|
||||||
|
max-height: min(82vh, 760px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(202, 216, 239, 0.95);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 28px 70px rgba(7, 24, 51, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-dialog-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-close-button {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 8px 20px rgba(12, 42, 88, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-modal-content {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-entry {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.95rem 0 1rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-entry:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-entry h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-entry p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-entry a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-panel {
|
||||||
|
min-height: 70vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 70vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(17, 52, 99, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel,
|
||||||
|
.legend-info-panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 500;
|
||||||
|
width: min(320px, calc(100% - 2rem));
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel {
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-info-panel {
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-toggle,
|
||||||
|
.legend-info-toggle,
|
||||||
|
.metric-info-panel .state-details,
|
||||||
|
.legend-info-panel .legend {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-toggle,
|
||||||
|
.legend-info-toggle {
|
||||||
|
justify-self: end;
|
||||||
|
width: auto;
|
||||||
|
min-width: 148px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 20px rgba(12, 42, 88, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-info-toggle {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel.is-collapsed .state-details,
|
||||||
|
.legend-info-panel.is-collapsed .legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel .state-details,
|
||||||
|
.legend-info-panel .legend {
|
||||||
|
max-height: min(58vh, 430px);
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-attribution * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-attribution {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-map-dragging .leaflet-tooltip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-map-dragging .leaflet-interactive:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
order: 2;
|
||||||
|
min-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-panel {
|
||||||
|
order: 1;
|
||||||
|
min-height: 62vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
min-height: 62vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel,
|
||||||
|
.legend-info-panel {
|
||||||
|
top: 0.75rem;
|
||||||
|
width: min(300px, calc(100% - 1.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info-panel {
|
||||||
|
right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-info-panel {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-dialog {
|
||||||
|
width: calc(100vw - 1rem);
|
||||||
|
max-height: calc(100vh - 1rem);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-dialog-header,
|
||||||
|
.sources-modal-content {
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
padding-right: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from download_nsrdb_county_polygon_archives import ( # noqa: E402
|
||||||
|
ArchiveDownloadEvent,
|
||||||
|
ArchiveDownloadState,
|
||||||
|
ArchiveDownloadStateMachine,
|
||||||
|
InvalidArchiveDownloadTransition,
|
||||||
|
PolygonArchiveDownloadError,
|
||||||
|
download_file,
|
||||||
|
output_name_for_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, content: bytes) -> None:
|
||||||
|
self.content = content
|
||||||
|
self.offset = 0
|
||||||
|
|
||||||
|
def __enter__(self) -> FakeResponse:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self, size: int) -> bytes:
|
||||||
|
chunk = self.content[self.offset : self.offset + size]
|
||||||
|
self.offset += len(chunk)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveDownloadStateMachineTests(unittest.TestCase):
|
||||||
|
def test_legacy_ghi_response_name_keeps_existing_archive_name(self) -> None:
|
||||||
|
response_path = Path("01001_goes-tmy_tmy-2024_ghi_response.json")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
output_name_for_response(response_path),
|
||||||
|
"01001_goes-tmy_tmy-2024_ghi.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_successful_download_lifecycle(self) -> None:
|
||||||
|
machine = ArchiveDownloadStateMachine("01001_response.json")
|
||||||
|
machine.transition(ArchiveDownloadEvent.START)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
output_path = Path(temp_dir) / "01001.zip"
|
||||||
|
with patch(
|
||||||
|
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
|
||||||
|
return_value=FakeResponse(b"archive"),
|
||||||
|
):
|
||||||
|
state = download_file(
|
||||||
|
"https://example.com/01001.zip",
|
||||||
|
output_path,
|
||||||
|
timeout=30,
|
||||||
|
overwrite=False,
|
||||||
|
machine=machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(output_path.read_bytes(), b"archive")
|
||||||
|
|
||||||
|
self.assertEqual(state, ArchiveDownloadState.DOWNLOADED)
|
||||||
|
self.assertEqual(
|
||||||
|
machine.history,
|
||||||
|
[
|
||||||
|
ArchiveDownloadState.QUEUED,
|
||||||
|
ArchiveDownloadState.CHECKING,
|
||||||
|
ArchiveDownloadState.DOWNLOADING,
|
||||||
|
ArchiveDownloadState.DOWNLOADED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_archive_is_skipped(self) -> None:
|
||||||
|
machine = ArchiveDownloadStateMachine("01001_response.json")
|
||||||
|
machine.transition(ArchiveDownloadEvent.START)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
output_path = Path(temp_dir) / "01001.zip"
|
||||||
|
output_path.write_bytes(b"existing")
|
||||||
|
state = download_file(
|
||||||
|
"https://example.com/01001.zip",
|
||||||
|
output_path,
|
||||||
|
timeout=30,
|
||||||
|
overwrite=False,
|
||||||
|
machine=machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(state, ArchiveDownloadState.SKIPPED)
|
||||||
|
|
||||||
|
def test_pending_s3_archive_enters_pending_state(self) -> None:
|
||||||
|
machine = ArchiveDownloadStateMachine("01001_response.json")
|
||||||
|
machine.transition(ArchiveDownloadEvent.START)
|
||||||
|
error = urllib.error.HTTPError(
|
||||||
|
"https://bucket.s3.amazonaws.com/01001.zip",
|
||||||
|
403,
|
||||||
|
"Forbidden",
|
||||||
|
{},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
with patch(
|
||||||
|
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
|
||||||
|
side_effect=error,
|
||||||
|
):
|
||||||
|
state = download_file(
|
||||||
|
error.url,
|
||||||
|
Path(temp_dir) / "01001.zip",
|
||||||
|
timeout=30,
|
||||||
|
overwrite=False,
|
||||||
|
machine=machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(state, ArchiveDownloadState.PENDING)
|
||||||
|
|
||||||
|
def test_http_error_enters_failed_state(self) -> None:
|
||||||
|
machine = ArchiveDownloadStateMachine("01001_response.json")
|
||||||
|
machine.transition(ArchiveDownloadEvent.START)
|
||||||
|
error = urllib.error.HTTPError(
|
||||||
|
"https://example.com/01001.zip",
|
||||||
|
500,
|
||||||
|
"Server Error",
|
||||||
|
{},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
with patch(
|
||||||
|
"download_nsrdb_county_polygon_archives.urllib.request.urlopen",
|
||||||
|
side_effect=error,
|
||||||
|
):
|
||||||
|
with self.assertRaises(PolygonArchiveDownloadError):
|
||||||
|
download_file(
|
||||||
|
error.url,
|
||||||
|
Path(temp_dir) / "01001.zip",
|
||||||
|
timeout=30,
|
||||||
|
overwrite=False,
|
||||||
|
machine=machine,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(machine.state, ArchiveDownloadState.FAILED)
|
||||||
|
|
||||||
|
def test_invalid_transition_is_rejected(self) -> None:
|
||||||
|
machine = ArchiveDownloadStateMachine("01001_response.json")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
InvalidArchiveDownloadTransition,
|
||||||
|
"download_succeeded while queued",
|
||||||
|
):
|
||||||
|
machine.transition(ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import download_nsrdb_county_polygon_cloud_archives as cloud_download # noqa: E402
|
||||||
|
import download_nsrdb_county_polygon_ghi_archives as ghi_download # noqa: E402
|
||||||
|
import request_nsrdb_county_polygon_cloud_archives as cloud_request # noqa: E402
|
||||||
|
import request_nsrdb_county_polygon_ghi_archives as ghi_request # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class PolygonCloudWrapperTests(unittest.TestCase):
|
||||||
|
def test_request_uses_cloud_artifact_label(self) -> None:
|
||||||
|
args = cloud_request.polygon_request.parse_args(cloud_request.DEFAULT_ARGS)
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
args.response_dir = Path(temp_dir)
|
||||||
|
path = cloud_request.polygon_request.response_path(
|
||||||
|
args,
|
||||||
|
"01001",
|
||||||
|
"goes-tmy",
|
||||||
|
"tmy-2024",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
path.name,
|
||||||
|
"01001_goes-tmy_tmy-2024_cloud_response.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_reuses_legacy_cloud_response_name(self) -> None:
|
||||||
|
args = cloud_request.polygon_request.parse_args(cloud_request.DEFAULT_ARGS)
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
args.response_dir = Path(temp_dir)
|
||||||
|
legacy_path = args.response_dir / "01001_goes-tmy_tmy-2024_ghi_response.json"
|
||||||
|
legacy_path.touch()
|
||||||
|
|
||||||
|
path = cloud_request.polygon_request.response_path(
|
||||||
|
args,
|
||||||
|
"01001",
|
||||||
|
"goes-tmy",
|
||||||
|
"tmy-2024",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(path, legacy_path)
|
||||||
|
|
||||||
|
def test_request_user_arguments_override_cloud_defaults(self) -> None:
|
||||||
|
args = cloud_request.polygon_request.parse_args(
|
||||||
|
[
|
||||||
|
*cloud_request.DEFAULT_ARGS,
|
||||||
|
"--attributes",
|
||||||
|
"ghi",
|
||||||
|
"--requests-csv",
|
||||||
|
"custom.csv",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(args.attributes, "ghi")
|
||||||
|
self.assertEqual(args.requests_csv, Path("custom.csv"))
|
||||||
|
|
||||||
|
def test_request_wrapper_passes_cloud_defaults_and_user_arguments(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "argv", ["cloud-request", "--limit", "12"]),
|
||||||
|
patch.object(cloud_request.polygon_request, "main") as shared_main,
|
||||||
|
):
|
||||||
|
cloud_request.main()
|
||||||
|
|
||||||
|
shared_main.assert_called_once_with(
|
||||||
|
[*cloud_request.DEFAULT_ARGS, "--limit", "12"],
|
||||||
|
description=cloud_request.__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_download_wrapper_passes_cloud_defaults_and_user_arguments(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "argv", ["cloud-download", "--overwrite"]),
|
||||||
|
patch.object(cloud_download.polygon_download, "main") as shared_main,
|
||||||
|
):
|
||||||
|
cloud_download.main()
|
||||||
|
|
||||||
|
shared_main.assert_called_once_with(
|
||||||
|
[*cloud_download.DEFAULT_ARGS, "--overwrite"],
|
||||||
|
description=cloud_download.__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_download_user_arguments_override_cloud_defaults(self) -> None:
|
||||||
|
args = cloud_download.polygon_download.parse_args(
|
||||||
|
[
|
||||||
|
*cloud_download.DEFAULT_ARGS,
|
||||||
|
"--output-dir",
|
||||||
|
"custom-archives",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(args.output_dir, Path("custom-archives"))
|
||||||
|
|
||||||
|
|
||||||
|
class PolygonGhiWrapperTests(unittest.TestCase):
|
||||||
|
def test_request_uses_ghi_artifact_label(self) -> None:
|
||||||
|
args = ghi_request.polygon_request.parse_args(ghi_request.DEFAULT_ARGS)
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
args.response_dir = Path(temp_dir)
|
||||||
|
path = ghi_request.polygon_request.response_path(
|
||||||
|
args,
|
||||||
|
"01001",
|
||||||
|
"goes-tmy",
|
||||||
|
"tmy-2024",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
path.name,
|
||||||
|
"01001_goes-tmy_tmy-2024_ghi_response.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_wrapper_passes_ghi_defaults_and_user_arguments(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "argv", ["ghi-request", "--limit", "12"]),
|
||||||
|
patch.object(ghi_request.polygon_request, "main") as shared_main,
|
||||||
|
):
|
||||||
|
ghi_request.main()
|
||||||
|
|
||||||
|
shared_main.assert_called_once_with(
|
||||||
|
[*ghi_request.DEFAULT_ARGS, "--limit", "12"],
|
||||||
|
description=ghi_request.__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_user_arguments_override_ghi_defaults(self) -> None:
|
||||||
|
args = ghi_request.polygon_request.parse_args(
|
||||||
|
[
|
||||||
|
*ghi_request.DEFAULT_ARGS,
|
||||||
|
"--attributes",
|
||||||
|
"ghi,clearsky_ghi",
|
||||||
|
"--requests-csv",
|
||||||
|
"custom.csv",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(args.attributes, "ghi,clearsky_ghi")
|
||||||
|
self.assertEqual(args.requests_csv, Path("custom.csv"))
|
||||||
|
|
||||||
|
def test_download_wrapper_passes_ghi_defaults_and_user_arguments(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "argv", ["ghi-download", "--overwrite"]),
|
||||||
|
patch.object(ghi_download.polygon_download, "main") as shared_main,
|
||||||
|
):
|
||||||
|
ghi_download.main()
|
||||||
|
|
||||||
|
shared_main.assert_called_once_with(
|
||||||
|
[*ghi_download.DEFAULT_ARGS, "--overwrite"],
|
||||||
|
description=ghi_download.__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_download_user_arguments_override_ghi_defaults(self) -> None:
|
||||||
|
args = ghi_download.polygon_download.parse_args(
|
||||||
|
[
|
||||||
|
*ghi_download.DEFAULT_ARGS,
|
||||||
|
"--output-dir",
|
||||||
|
"custom-archives",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(args.output_dir, Path("custom-archives"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import csv
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from request_nsrdb_county_polygon_archives import ( # noqa: E402
|
||||||
|
ArchiveQueueMonitor,
|
||||||
|
CountyRequestEvent,
|
||||||
|
CountyRequestState,
|
||||||
|
CountyRequestStateMachine,
|
||||||
|
EXCEPTIONAL_POLYGON_WAIT,
|
||||||
|
InvalidCountyRequestTransition,
|
||||||
|
LARGE_POLYGON_WAIT,
|
||||||
|
LocalQueueCapacityError,
|
||||||
|
MEDIUM_POLYGON_WAIT,
|
||||||
|
SMALL_POLYGON_WAIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CountyRequestStateMachineTests(unittest.TestCase):
|
||||||
|
def test_successful_request_lifecycle(self) -> None:
|
||||||
|
machine = CountyRequestStateMachine("01001 Autauga County, AL")
|
||||||
|
|
||||||
|
machine.transition(CountyRequestEvent.START)
|
||||||
|
machine.transition(CountyRequestEvent.REQUEST_SUCCEEDED)
|
||||||
|
|
||||||
|
self.assertEqual(machine.state, CountyRequestState.REQUESTED)
|
||||||
|
self.assertEqual(
|
||||||
|
machine.history,
|
||||||
|
[
|
||||||
|
CountyRequestState.PENDING,
|
||||||
|
CountyRequestState.SUBMITTING,
|
||||||
|
CountyRequestState.REQUESTED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retry_returns_to_submitting(self) -> None:
|
||||||
|
machine = CountyRequestStateMachine("01001 Autauga County, AL")
|
||||||
|
|
||||||
|
machine.transition(CountyRequestEvent.START)
|
||||||
|
machine.transition(CountyRequestEvent.RETRY_REQUIRED)
|
||||||
|
machine.transition(CountyRequestEvent.RETRY_STARTED)
|
||||||
|
|
||||||
|
self.assertEqual(machine.state, CountyRequestState.SUBMITTING)
|
||||||
|
|
||||||
|
def test_existing_request_is_skipped(self) -> None:
|
||||||
|
machine = CountyRequestStateMachine("01001 Autauga County, AL")
|
||||||
|
|
||||||
|
machine.transition(CountyRequestEvent.EXISTING_REQUEST_FOUND)
|
||||||
|
|
||||||
|
self.assertEqual(machine.state, CountyRequestState.SKIPPED)
|
||||||
|
|
||||||
|
def test_invalid_transition_is_rejected(self) -> None:
|
||||||
|
machine = CountyRequestStateMachine("01001 Autauga County, AL")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
InvalidCountyRequestTransition,
|
||||||
|
"request_succeeded while pending",
|
||||||
|
):
|
||||||
|
machine.transition(CountyRequestEvent.REQUEST_SUCCEEDED)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveQueueMonitorTests(unittest.TestCase):
|
||||||
|
def make_monitor(self) -> ArchiveQueueMonitor:
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
self.addCleanup(temp_dir.cleanup)
|
||||||
|
return ArchiveQueueMonitor(Path(temp_dir.name), timeout=1, workers=1)
|
||||||
|
|
||||||
|
def test_dynamic_wait_uses_largest_tracked_polygon(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
|
||||||
|
for site_count, expected_wait in (
|
||||||
|
(110, SMALL_POLYGON_WAIT),
|
||||||
|
(111, MEDIUM_POLYGON_WAIT),
|
||||||
|
(250, MEDIUM_POLYGON_WAIT),
|
||||||
|
(251, LARGE_POLYGON_WAIT),
|
||||||
|
(550, LARGE_POLYGON_WAIT),
|
||||||
|
(551, EXCEPTIONAL_POLYGON_WAIT),
|
||||||
|
):
|
||||||
|
monitor.tracked_site_counts = {"https://example.com/job.zip": site_count}
|
||||||
|
self.assertEqual(monitor.dynamic_wait_seconds(), expected_wait)
|
||||||
|
|
||||||
|
def test_downloaded_archive_is_not_reloaded_as_pending(self) -> None:
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
self.addCleanup(temp_dir.cleanup)
|
||||||
|
manifest_dir = Path(temp_dir.name)
|
||||||
|
response_dir = manifest_dir / "polygon_cloud_request_responses"
|
||||||
|
archive_dir = manifest_dir / "polygon_cloud_archives"
|
||||||
|
response_dir.mkdir()
|
||||||
|
archive_dir.mkdir()
|
||||||
|
response_path = response_dir / "01001_goes-tmy_tmy-2024_cloud_response.json"
|
||||||
|
archive_path = archive_dir / "01001_goes-tmy_tmy-2024_cloud.zip"
|
||||||
|
response_path.write_text("{}", encoding="utf-8")
|
||||||
|
archive_path.write_bytes(b"downloaded")
|
||||||
|
|
||||||
|
manifest_path = manifest_dir / "county_polygon_cloud_request_manifest.csv"
|
||||||
|
with manifest_path.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
handle,
|
||||||
|
fieldnames=[
|
||||||
|
"county_fips",
|
||||||
|
"site_count",
|
||||||
|
"download_url",
|
||||||
|
"response_json",
|
||||||
|
"submitted_at_utc",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"county_fips": "01001",
|
||||||
|
"site_count": "100",
|
||||||
|
"download_url": "https://example.amazonaws.com/job.zip",
|
||||||
|
"response_json": str(response_path),
|
||||||
|
"submitted_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monitor = ArchiveQueueMonitor(manifest_dir, timeout=1, workers=1)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.archive_download_status",
|
||||||
|
return_value="pending",
|
||||||
|
) as status_mock:
|
||||||
|
counts = monitor.summarize()
|
||||||
|
|
||||||
|
self.assertEqual(counts["pending"], 0)
|
||||||
|
self.assertEqual(counts["total"], 0)
|
||||||
|
status_mock.assert_not_called()
|
||||||
|
|
||||||
|
def test_historical_s3_403_does_not_consume_a_queue_slot(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
url = "https://example.amazonaws.com/historical.zip"
|
||||||
|
monitor.jobs[url] = "01001"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.archive_download_status",
|
||||||
|
return_value="pending",
|
||||||
|
):
|
||||||
|
counts = monitor.summarize()
|
||||||
|
|
||||||
|
self.assertEqual(counts["pending"], 0)
|
||||||
|
self.assertEqual(counts["ambiguous_403"], 1)
|
||||||
|
|
||||||
|
def test_current_process_s3_403_remains_pending(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
url = "https://example.amazonaws.com/current.zip"
|
||||||
|
monitor.record_submission(
|
||||||
|
{
|
||||||
|
"county_fips": "01001",
|
||||||
|
"tile_id": "",
|
||||||
|
"site_count": "100",
|
||||||
|
"download_url": url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.archive_download_status",
|
||||||
|
return_value="pending",
|
||||||
|
):
|
||||||
|
counts = monitor.summarize()
|
||||||
|
|
||||||
|
self.assertEqual(counts["pending"], 1)
|
||||||
|
self.assertEqual(counts["ambiguous_403"], 0)
|
||||||
|
|
||||||
|
def test_site_count_expires_after_recent_download_window(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
url = "https://example.com/job.zip"
|
||||||
|
monitor.jobs[url] = "01001"
|
||||||
|
monitor.pending_urls.add(url)
|
||||||
|
monitor.tracked_site_counts[url] = 551
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.archive_download_status",
|
||||||
|
return_value="ready",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.time.monotonic",
|
||||||
|
side_effect=[100.0, 100.0, 401.0],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
first_counts = monitor.summarize()
|
||||||
|
second_counts = monitor.summarize()
|
||||||
|
|
||||||
|
self.assertEqual(first_counts["recently_downloaded"], 1)
|
||||||
|
self.assertEqual(second_counts["recently_downloaded"], 0)
|
||||||
|
self.assertNotIn(url, monitor.tracked_site_counts)
|
||||||
|
|
||||||
|
def test_fifteen_visible_slots_bypass_pacing_until_queue_is_full(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
monitor.paced_submissions = 1
|
||||||
|
counts_with_fifteen_slots = {
|
||||||
|
"pending": 5,
|
||||||
|
"recently_downloaded": 2,
|
||||||
|
"unobserved": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"total": 7,
|
||||||
|
}
|
||||||
|
counts_with_fourteen_slots = {
|
||||||
|
**counts_with_fifteen_slots,
|
||||||
|
"pending": 6,
|
||||||
|
}
|
||||||
|
full_queue_counts = {
|
||||||
|
**counts_with_fifteen_slots,
|
||||||
|
"pending": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
monitor,
|
||||||
|
"summarize",
|
||||||
|
side_effect=[
|
||||||
|
counts_with_fifteen_slots,
|
||||||
|
counts_with_fourteen_slots,
|
||||||
|
full_queue_counts,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.wait_for_queue",
|
||||||
|
) as wait_for_queue_mock,
|
||||||
|
):
|
||||||
|
monitor.wait_for_capacity("first", retries=0)
|
||||||
|
monitor.wait_for_capacity("second", retries=0)
|
||||||
|
with self.assertRaisesRegex(LocalQueueCapacityError, "20 possible"):
|
||||||
|
monitor.wait_for_capacity("third", retries=0)
|
||||||
|
|
||||||
|
wait_for_queue_mock.assert_not_called()
|
||||||
|
self.assertFalse(monitor.filling_open_queue)
|
||||||
|
|
||||||
|
def test_fourteen_visible_slots_still_use_recent_download_pacing(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
monitor.paced_submissions = 1
|
||||||
|
counts = {
|
||||||
|
"pending": 6,
|
||||||
|
"recently_downloaded": 2,
|
||||||
|
"unobserved": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"total": 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(monitor, "summarize", return_value=counts),
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.wait_for_queue",
|
||||||
|
) as wait_for_queue_mock,
|
||||||
|
):
|
||||||
|
monitor.wait_for_capacity("next", retries=0)
|
||||||
|
|
||||||
|
wait_for_queue_mock.assert_called_once_with(
|
||||||
|
SMALL_POLYGON_WAIT,
|
||||||
|
"Pacing wait complete; refreshing archive status.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_six_minute_wait_checks_queue_after_three_minutes(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
counts = {
|
||||||
|
"pending": 6,
|
||||||
|
"recently_downloaded": 0,
|
||||||
|
"unobserved": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"total": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(monitor, "summarize", return_value=counts) as summarize_mock,
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.time.sleep",
|
||||||
|
) as sleep_mock,
|
||||||
|
):
|
||||||
|
entered_fill_mode = monitor.wait_for_dynamic_queue(
|
||||||
|
LARGE_POLYGON_WAIT,
|
||||||
|
"Queue wait complete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(entered_fill_mode)
|
||||||
|
self.assertEqual(sleep_mock.call_args_list, [call(180.0)] * 2)
|
||||||
|
summarize_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_six_minute_wait_enters_fill_mode_at_checkpoint(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
counts = {
|
||||||
|
"pending": 5,
|
||||||
|
"recently_downloaded": 0,
|
||||||
|
"unobserved": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"total": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(monitor, "summarize", return_value=counts),
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.time.sleep",
|
||||||
|
) as sleep_mock,
|
||||||
|
):
|
||||||
|
entered_fill_mode = monitor.wait_for_dynamic_queue(
|
||||||
|
LARGE_POLYGON_WAIT,
|
||||||
|
"Queue wait complete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(entered_fill_mode)
|
||||||
|
self.assertTrue(monitor.filling_open_queue)
|
||||||
|
sleep_mock.assert_called_once_with(180.0)
|
||||||
|
|
||||||
|
def test_fifteen_minute_wait_checks_at_five_and_ten_minutes(self) -> None:
|
||||||
|
monitor = self.make_monitor()
|
||||||
|
counts_with_fourteen_slots = {
|
||||||
|
"pending": 6,
|
||||||
|
"recently_downloaded": 0,
|
||||||
|
"unobserved": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"total": 6,
|
||||||
|
}
|
||||||
|
counts_with_fifteen_slots = {
|
||||||
|
**counts_with_fourteen_slots,
|
||||||
|
"pending": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
monitor,
|
||||||
|
"summarize",
|
||||||
|
side_effect=[counts_with_fourteen_slots, counts_with_fifteen_slots],
|
||||||
|
) as summarize_mock,
|
||||||
|
patch(
|
||||||
|
"request_nsrdb_county_polygon_archives.time.sleep",
|
||||||
|
) as sleep_mock,
|
||||||
|
):
|
||||||
|
entered_fill_mode = monitor.wait_for_dynamic_queue(
|
||||||
|
EXCEPTIONAL_POLYGON_WAIT,
|
||||||
|
"Queue wait complete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(entered_fill_mode)
|
||||||
|
self.assertTrue(monitor.filling_open_queue)
|
||||||
|
self.assertEqual(sleep_mock.call_args_list, [call(300.0)] * 2)
|
||||||
|
self.assertEqual(summarize_mock.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user