diff --git a/.gitattributes b/.gitattributes
index 4a0d86f..a0398de 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
-scripts/* linguist-documentation
+tests/fixtures/** -text
+*.db binary
diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
new file mode 100644
index 0000000..40b36eb
--- /dev/null
+++ b/.github/workflows/canary.yml
@@ -0,0 +1,52 @@
+name: Live-source canary
+
+# The test suite runs fully offline against captured payloads, so upstream
+# format or availability changes are invisible to CI. This canary hits the
+# real services on a schedule and fails loudly when they drift.
+
+on:
+ schedule:
+ - cron: "0 5 8 * *"
+ workflow_dispatch:
+
+jobs:
+ canary:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install package
+ run: pip install -e .
+ - name: Fetch live observations and validate shape
+ run: |
+ python - << 'PY'
+ from eeweather.sources.ghcnh.source import GHCNhSource
+ from eeweather.build.refresh import STATION_LIST_URL, INVENTORY_URL
+ import requests
+
+ # one real station-year through the live NCEI api
+ df = GHCNhSource().fetch_year(
+ "USW00023234", 2024, ("temperature", "relative_humidity")
+ )
+ assert len(df) > 8000, "unexpectedly sparse response: {} rows".format(len(df))
+ assert list(df.columns) == ["temperature", "relative_humidity"]
+ mean = float(df.temperature.mean())
+ assert 5 < mean < 25, "implausible SFO mean temperature: {}".format(mean)
+
+ # refresh inputs
+ for url in (STATION_LIST_URL, INVENTORY_URL):
+ response = requests.get(url, stream=True, timeout=120)
+ response.raise_for_status()
+
+ # one archive file per normals source
+ from eeweather.sources.tmy3.source import TMY3Source
+ from eeweather.sources.cz2010.source import CZ2010Source
+ for source in (TMY3Source(), CZ2010Source()):
+ ts = source.fetch("USW00023152")
+ assert len(ts) == 8760, "{}: unexpected typical year length".format(
+ source.name
+ )
+ print("canary green")
+ PY
diff --git a/.github/workflows/refresh-registry.yml b/.github/workflows/refresh-registry.yml
new file mode 100644
index 0000000..bf956cd
--- /dev/null
+++ b/.github/workflows/refresh-registry.yml
@@ -0,0 +1,56 @@
+name: Refresh registry
+
+on:
+ schedule:
+ # monthly; GHCNh inventory updates continuously, ratings decay yearly
+ - cron: "0 6 2 * *"
+ workflow_dispatch:
+
+jobs:
+ refresh:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install package
+ run: pip install -e .[dev]
+ - name: Refresh live registry content
+ run: python -m eeweather.build
+ # refresh PRs opened with GITHUB_TOKEN do not trigger pull_request
+ # workflows, so the suite must pass here, against the refreshed data
+ - name: Run the test suite against the refreshed data
+ run: pytest tests/ -o addopts=
+ # clients' auto-update downloads these assets (CDN-served) instead
+ # of each rebuilding from NOAA; the wheel snapshot updates via PR
+ - name: Publish refreshed data to the rolling release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create registry-latest \
+ --title "Latest registry data" \
+ --notes "Refreshed registry data, published by the scheduled workflow. Client auto-update downloads these files when local data is stale." \
+ || true
+ gh release upload registry-latest \
+ eeweather/sources/ghcnh/ghcnh.db \
+ eeweather/registry/identifiers.db \
+ --clobber
+ - name: Open pull request with the refreshed registry
+ uses: peter-evans/create-pull-request@v6
+ with:
+ commit-message: Refresh registry inventory and quality ratings
+ title: Refresh registry inventory and quality ratings
+ body: |
+ Scheduled refresh of the packaged registry database: GHCNh
+ observation inventory, newly listed stations, and build-time
+ quality ratings. Static content (zones, places, identifiers,
+ normals) is untouched.
+ branch: automation/refresh-registry
+ delete-branch: true
+ add-paths: |
+ eeweather/sources/ghcnh/ghcnh.db
+ eeweather/registry/identifiers.db
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c656ae6..9176f62 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,6 +14,30 @@ on:
- cron: "0 0 1 * *"
jobs:
+ coverage:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install tox
+ run: pip install tox
+ - name: Enforce the coverage floor
+ run: tox -e coverage
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install ruff
+ run: pip install ruff
+ - name: Lint (logic checks only)
+ run: ruff check eeweather tests
+
build:
strategy:
fail-fast: false
diff --git a/.gitignore b/.gitignore
index 5a85b27..6daa530 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@ __pycache__/
_build/
# packaging and distribution
-build/
+/build/
dist/
# coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b874398..e9305ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,59 +4,124 @@ Changelog
Development
-----------
+This release is a redesign; the public API is not compatible with 0.3.x.
+
* Observed weather data is served from NOAA GHCNh; ISD and GSOD stopped
- receiving data 2025-08-27. Overlap validation against ISD history shows
- median hourly deviation of 0.0001 degrees C
- (`scripts/validate_ghcnh_against_isd.py`).
-* New api: `load_data(usaf_id, start, end, frequency, variables)` returns a
- DataFrame and warnings, replacing the `load_isd_*`/`load_gsod_*` family;
- `ISDStation` is renamed `WeatherStation`. GHCNh variables beyond
- temperature (dew point, relative humidity, wind speed, ...) are available
- through `variables`.
-* Station registry maps each station to its GHCNh id
- (`get_ghcn_id(usaf_id)` for one station, `get_ghcn_ids()` for the
- registry); 349 stations with no
- GHCNh counterpart are removed. Each station records the first and last
- year its GHCNh record has observations (`ghcn_first_year`,
- `ghcn_last_year`); about a fifth of the registry, nearly all low
- quality, has no observations after 2023.
-* Station quality ratings are computed from the GHCNh inventory (same
- rule as before: every month of the last five full years above 600
- observations is high, above 360 is medium). Tiers move for about a
- fifth of stations, mostly upgrades of stations the retired ISD
- inventory undercounted: 1858 high / 393 medium / 2246 low.
-* Quality can be rated for the period being requested:
- `get_station_quality(usaf_id, start, end)` and
- `rank_stations(..., rating_period=(start, end))` rate stations over
- the five calendar years ending two years after the period's last
- date (sliding back to end no later than the last full year), so
- historical requests rank stations by their reliability in that era.
- The packaged database carries the monthly counts (`ghcn_inventory`).
-* Caching uses new ghcnh-* keys; ISD-era cache entries are never served.
-* Deleted: FTP fetch code, ISD/GSOD parsing, filename helpers and CLI
- commands, sphinx docs (documentation moves to opendsm.energy).
-* Switched to https api over FTP for temperature data fetching
-* Remove deprecated `typing` dependency
-* Switch from snapshottest to syrupy for snapshot testing.
-* Update internal database (2025-03-12).
-* Modernize packaging: pyproject.toml with hatchling replaces setup.py,
- Pipfile, and MANIFEST.in; python >=3.10.
-* Add test workflow (python/os matrix) and tox environments for current
- python versions.
-* Rewrite key-value cache on stdlib sqlite3; sqlalchemy is no longer used
- or required. Cache urls must have the sqlite:/// form.
-* Replace retry and attrs dependencies with stdlib equivalents; fix
- pkg_resources import broken on setuptools >=81.
-* Warn when loaded data is empty, truncated, or contains a multi-day
- internal gap. Requests ending after a station's last available
- observation previously returned NaN-padded series silently.
-* Fix crash (`No objects to concatenate`) when all years in a requested
- range are missing and `error_on_missing_years=False`.
-* Loaders always return a series covering the full requested range.
-* Add `error_on_missing_years` to daily ISD/GSOD loaders; default is True
- for all loaders, matching the ISDStation method default.
-* Tests run fully offline against captured NCEI access api payloads;
- add coverage floor.
+ receiving data 2025-08-27. Overlap validation against ISD history
+ showed a median hourly deviation of 0.0001 degrees C.
+* The primary entry point is `WeatherLocation(latitude, longitude)`
+ (construct from a ZIP code with `from_place("zcta", code)`): weather at
+ a point, estimated by configurable sources. `location.candidates()`
+ ranks nearby stations and `location.zones` gives its climate zones.
+ Provenance is the reproducibility mechanism: a location pins itself to
+ the stations resolved at first load, and `to_dict`/`to_json` with
+ `from_dict`/`from_json` carry that state across processes, so later
+ loads (e.g. a reporting period after a baseline) use the same station
+ per source.
+* `WeatherStation` is keyed by the station's GHCN id. Other identifier
+ systems are aliases that translate to it: `from_usaf`,
+ `from_wban` (most-recent mapping wins), `from_icao`, and the generic
+ `from_id(namespace, external_id)`; `WeatherStation.translate(ids,
+ from_namespace, to_namespace)` converts in bulk and
+ `WeatherStation.search(country=, subdivision=, has_sources=)`
+ enumerates the registry as a DataFrame.
+* One loading verb: `load_data(start, end, frequency, variables,
+ source=)` serves observations and typical years alike, returning
+ `(DataFrame, warnings)` with per-source provenance on the frame's
+ attrs and on the station/location object. Each requested variable
+ routes to the first configured source that serves it; typical-year
+ sources (`"tmy3"`, `"cz2010"`) are reachable only by explicit
+ `source=` pin and tile onto requested calendar years by
+ month-day-hour with NaN leap days. Partial coverage returns NaN plus
+ warnings; a pinned source with nothing at all raises
+ `DataNotAvailableError`.
+* Variables have a canonical curated vocabulary with fixed units
+ (temperature, dew point temperature [degC], relative humidity [%],
+ wind speed [m/s], station-level pressure [hPa], visibility [km]) and
+ a per-variable aggregation governing resampling. `load_data`'s
+ `frequency` uses pandas' own offset nomenclature, bound by delegation
+ (pandas parses the alias, so its spellings and deprecations apply
+ automatically): coarser-than-hourly frequencies (`D`, `W`, `MS`,
+ `YS`, ...) aggregate each variable by its declared aggregation —
+ point-in-time variables average, accumulation variables
+ (`aggregation="sum"`) add up within each period and are never
+ gap-interpolated — and finer-than-hourly frequencies (`30min`,
+ `20min`, ...; they must divide the hour evenly) interpolate
+ point-in-time variables linearly between hourly values and spread
+ accumulations evenly, never crossing a missing hour. `eeweather.sources.variables()` lists them
+ and which sources serve each. Arbitrary native GHCNh dataTypes are no
+ longer passed through.
+* Custom data plugs in through public protocols: station-keyed feeds
+ (`eeweather.sources.Feed` — keyed by any translatable id system, with
+ opt-out caching), typical-year sources (`eeweather.sources.
+ NormalsSource`), and location-keyed gridded sources
+ (`eeweather.sources.Source`). `eeweather.sources.register(source)`
+ makes a custom feed or normals source nameable — usable as a string
+ in preference tuples and `source=` pins, and rebuildable by location
+ serialization — and accepts vocabulary entries
+ (`register(..., vocabulary=(Variable(name, unit, description,
+ aggregation),))`)
+ for variables the canonical vocabulary lacks, which then route and
+ validate like canonical ones. Canonical names are reserved and
+ definitions must agree across sources, so a registered variable's
+ unit is frozen at first registration.
+* Packaged data is split by ownership: the registry holds the
+ identifier crosswalk (`identifiers.db`) and regional geography packs
+ (`geography_us.db`: zone geometries, ZCTA places, zone assignments);
+ each source packages its own facts beside its adapter (the GHCNh
+ station catalog with zone assignments, observation inventory, and
+ quality ratings; TMY3/CZ2010 archive station lists). 349 ISD
+ stations with no GHCNh counterpart were removed during the migration.
+* The live packaged data (station catalog, observation inventory,
+ quality ratings, identifier aliases) keeps itself current without
+ package releases: when it is more than six months old — quality
+ rating windows advance when a calendar year completes, so six months
+ caps the lag behind that yearly step — loading data starts a
+ background update into the platform user data directory, which takes
+ precedence over the wheel's copies in new processes
+ (`EEWEATHER_AUTO_UPDATE=0` disables; `python -m
+ eeweather.registry.update` updates on demand, `--clear` reverts).
+ Updates prefer the ready-made pack a scheduled workflow publishes to
+ the repository's rolling release — CDN-served, so fleets of any size
+ cost NOAA nothing and clients skip the local rebuild — and fall back
+ to rebuilding from the live NOAA files when the channel is
+ unreachable, implausible, stale, or no newer than the local data.
+ Concurrent workers coordinate through an atomic claim file (one
+ attempt per machine per day) and the update thread defers network
+ traffic for a minute, so short-lived pipeline workers exit without
+ fetching anything.
+ New stations must sit within 10 km of a known zone geometry and show
+ recent observations to be appended; implausibly small upstream files
+ abort an update, leaving the previous data in place. A scheduled
+ workflow keeps the wheel's snapshot current via the same refresh.
+* Station quality ratings come from the GHCNh inventory (every month of
+ the rating window above 600 observations is high, above 360 medium);
+ `rank_stations(..., rating_period=(start, end))` rates stations over
+ the five calendar years ending two years after the period's last date
+ (sliding back to the last full year), so historical requests rank
+ stations by their reliability in that era.
+* Exceptions live in `eeweather.exceptions` (not re-exported at the
+ root), carry their messages through `str()`, and cover distinct
+ recovery paths: `UnrecognizedStationError`, `UnrecognizedPlaceError`,
+ `AmbiguousIdentifierError`, `DataNotAvailableError`,
+ `NoQualifiedStationError`. Non-UTC datetimes raise `ValueError`.
+* The GHCNh fetch reuses one session, retries only connection and
+ server errors with backoff, and raises client errors immediately.
+ Loads warn when data is empty, starts late, ends early, or contains a
+ multi-day internal gap.
+* The shared cache is a stdlib-sqlite store at the platform user cache
+ dir by default (`EEWEATHER_CACHE_URL` env var and
+ `eeweather.cache.set_path()` override; `eeweather.cache.clear()`
+ empties it). ISD-era and 0.3.x cache entries are never served.
+* Public type hints ship with a `py.typed` marker.
+* Modernized packaging: pyproject.toml with hatchling replaces setup.py,
+ Pipfile, and MANIFEST.in; python >=3.10; sqlalchemy, click, and pytz
+ are no longer dependencies; platformdirs and shapely are. The CLI,
+ plotting helpers, sphinx docs (documentation moves to opendsm.energy),
+ and FTP-era fetch code are deleted.
+* Tests run fully offline against captured NCEI access api payloads,
+ with a python/os matrix workflow, tox environments, ruff lint, and a
+ 97% coverage floor.
0.3.29
------
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e271246..ad28b1b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -5,6 +5,8 @@ Guidelines
----------
* Make sure you follow PEP 008 style guide conventions.
+* Adding a weather-data source: see `docs/adding-a-source.md`.
+* Adding a variable to the vocabulary: see `docs/expanding-the-vocabulary.md`.
* Commit messages should start with a capital letter ("Updated models", not "updated models").
* Write new tests and run old tests! Make sure that % test coverage does not decrease.
@@ -30,26 +32,19 @@ Releasing
Updating the internal db
------------------------
-The interal source file database needs to be periodically updated.
-
-TODO: automate this!
-
-Here are is the current process to use to update the internal db.
+The scheduled `refresh-registry` workflow updates the live parts of the
+packaged registry (GHCNh station list, observation inventory, quality
+ratings) monthly and opens a pull request; review its diff, run the test
+suite (some registry pins move as the registry ages), update snapshots as
+warranted, and merge. To run the refresh by hand:
```
-git checkout master
-git pull
-git checkout -b feature/update-db-YYYY-MM-DD
-
-# Rebuild the internal DB
-docker-compose build
-docker-compose run --rm eeweather rebuild-db
-git commit -am "Rebuild db YYYY-MM-DD"
-vim CHANGELOG.md # write "Update internal database (YYYY-MM-DD)."
-
-# Update the tests and snapshots if everything looks good
-docker-compose run --rm test
-docker-compose run --rm test --snapshot-update
+python -m eeweather.build
+pytest
```
-Then make a PR and review as normal. Rinse and repeat as necessary.
+Static content (zone geometries and assignments, places, archive
+station lists, identifier mappings) is carried forward from previously
+packaged data; `python -m eeweather.build --migrate OLD DESTDIR` builds
+the per-ownership data files (identifiers, geography pack, one per
+source) from a historical single-file database.
diff --git a/Dockerfile b/Dockerfile
index f548d42..448ba54 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,22 +1,23 @@
-FROM python:3.12-bookworm
+# syntax=docker/dockerfile:1.7
+FROM python:3.12-slim AS app
-RUN apt-get update \
- && apt-get install -yqq \
- # sphinxcontrib-spelling dependency
- libenchant-2-dev \
- # geo libraries
- binutils libproj-dev gdal-bin libgeos-dev \
- # unzip for rebuilding metadata.db
- unzip \
- # node for mapshaper
- nodejs npm \
- # for access to metadata.db
- sqlite3 libsqlite3-dev
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
-RUN npm install -g mapshaper
+# uv (fast installer)
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
-COPY pyproject.toml README.md LICENSE /app/
-COPY eeweather/ /app/eeweather
-RUN set -ex && pip install -e /app[dev]
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \
+ PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
+
+# deps layer (cacheable): resolve and install dependencies only
+COPY pyproject.toml README.md LICENSE /app/
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv pip compile pyproject.toml --extra dev -o /tmp/requirements.txt && \
+ uv pip install --system -r /tmp/requirements.txt
+
+COPY eeweather/ /app/eeweather
+RUN uv pip install --system --no-deps -e /app
diff --git a/README.md b/README.md
index 4640579..8f8faff 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,71 @@
-# EEweather: Weather station wrangling for EEmeter
+# EEweather: Weather for energy-efficiency modeling
[](https://github.com/opendsm/eeweather)
[](https://pypi.python.org/pypi/eeweather)
---
-**EEweather** — tools for matching to and fetching data from NCEI GHCNh, TMY3, or CZ2010 weather stations.
+**EEweather** answers "what was the weather here?" — it matches locations to
+weather stations, fetches observed and typical-year data, and returns
+analysis-ready frames with warnings and provenance.
-EEweather comes with a database of weather station metadata, ZCTA metadata, and GIS data that makes it easier to find the right weather station to use for a particular ZIP code or lat/long coordinate.
-
-[Documentation lives at opendsm.energy.](https://opendsm.energy)
+Documentation lives in [docs/](docs/) (publishing to
+[opendsm.energy](https://opendsm.energy)).
## Usage
```python
-import datetime
+from datetime import datetime, timezone
+
import eeweather
-station = eeweather.WeatherStation("722880")
+location = eeweather.WeatherLocation(34.2, -118.4) # or .from_place("zcta", "91104")
+df, warnings = location.load_data(
+ datetime(2024, 1, 1, tzinfo=timezone.utc),
+ datetime(2024, 12, 31, tzinfo=timezone.utc),
+)
+location.provenance # which station served each variable, and how far away
+
+saved = location.to_json() # coordinates, sources, and the stations resolved at load
+location = eeweather.WeatherLocation.from_json(saved) # later loads (e.g. a reporting
+ # period) reuse the same stations
+
+station = eeweather.WeatherStation("USW00023152") # or by registry id
+station = eeweather.WeatherStation.from_usaf("722880") # or by historical ids
df, warnings = station.load_data(
- datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
- datetime.datetime(2024, 12, 31, tzinfo=datetime.timezone.utc),
- frequency="hourly",
- variables=("temperature",),
+ datetime(2024, 1, 1, tzinfo=timezone.utc),
+ datetime(2024, 12, 31, tzinfo=timezone.utc),
+ variables=("temperature", "relative_humidity"),
+)
+typical, _ = station.load_data(
+ datetime(2024, 1, 1, tzinfo=timezone.utc),
+ datetime(2024, 12, 31, tzinfo=timezone.utc),
+ source="tmy3", # typical-year data by explicit pin
)
```
## Installation
-EEweather is a python package and can be installed with pip.
-
```
$ pip install eeweather
```
-## Supported Sources of Weather Data
-
-- NOAA Global Historical Climatology Network hourly (GHCNh)
-- NREL Typical Meteorological Year 3 (TMY3)
-- California Energy Commission 1998-2009 Weather Normals (CZ2010)
-
## Features
-- Match by ZIP code (ZCTA) or by lat/long coordinates
-- Use user-supplied weather station mappings
-- Match within climate zones
- - IECC Climate Zones
- - IECC Moisture Regimes
- - Building America Climate Zones
- - California Building Climate Zone Areas
-- User-friendly SQLite database of metadata compiled from primary sources
- - US Census Bureau (ZCTAs, county shapefiles)
- - Building America climate zone county lists
- - NOAA NCEI Integrated Surface Database Station History
- - NOAA GHCNh station list
- - NREL TMY3 site
-- Plot maps of outputs
+- One loading verb over pluggable sources: GHCNh observations (NOAA), TMY3
+ (NREL) and CZ2010 (CEC) typical years; every load returns an aligned UTC
+ frame plus data-quality warnings and per-source provenance
+- A packaged station registry with opaque ids and identifier translation
+ (USAF, WBAN, ICAO), precomputed climate-zone assignments (IECC, Building
+ America, California), ZCTA place codes, and per-source observation
+ inventory
+- Station matching: ranked candidates for any point with zone, quality,
+ distance, and availability filters (`location.candidates()`), plus
+ data-sufficiency selection
+- Extension protocols for custom data: station-keyed feeds (e.g. a BigQuery
+ table keyed by any translatable id system) and location-keyed gridded
+ sources
+- A shared sqlite weather cache (`eeweather.cache.set_path`/`clear`)
## Contributing
@@ -79,33 +89,38 @@ Run tests on multiple python versions:
$ tox
```
-## Use with Docker
-
-To use with docker-compose, use the following:
-
-Run a tutorial notebook (copy link w/ token, open tutorial.ipynb):
+Or with Docker:
```
-$ docker-compose up jupyter
+$ docker compose run --rm test
```
-Open a shell:
+## Registry updates
-```
-$ docker-compose run --rm shell
-```
-
-Run tests:
+The packaged registry (GHCNh station list, observation inventory, quality
+ratings, identifier aliases) keeps itself current: when the live data is
+more than six months old, loading data starts a background update into
+the platform user data directory, and those copies take precedence over
+the wheel's in new processes. Updates download the ready-made files a
+scheduled workflow publishes to the repository's rolling release
+(CDN-served, so any number of clients costs NOAA nothing and no client
+rebuilds locally) and fall back to rebuilding directly from the live
+NOAA files when that channel is unreachable or stale, so they keep
+working even if the repository goes dormant. Concurrent workers on a
+machine coordinate through an atomic claim file (one attempt per day,
+however many race), and the update thread waits a minute before touching
+the network, so short-lived pipeline workers exit without generating
+traffic. Set `EEWEATHER_AUTO_UPDATE=0` to suppress the traffic entirely,
+or manage it directly with:
```
-$ docker-compose run --rm test
+$ python -m eeweather.registry.update # refresh on demand
+$ python -m eeweather.registry.update --clear # revert to packaged data
```
-Run the CLI:
-
-```
-$ docker-compose run --rm eeweather --help
-```
+A scheduled workflow runs the same refresh monthly and opens a pull
+request so the wheel's snapshot stays current; maintainers can rebuild
+all packaged data with `python -m eeweather.build`.
## Notice Regarding CZ2010 Data
@@ -115,11 +130,3 @@ The non-U.S. data cannot be redistributed for commercial purposes.
Re-distribution of these data by others must provide this same notification.
See [further explanation](http://weather.whiteboxtechnologies.com/faq#Q12/) here.
-
-## Metadata Yearly Updates
-
-Every year, the metadata database needs to be updated. This can be done by running:
-
-```
-docker-compose run --rm eeweather rebuild-db
-```
diff --git a/docker-compose.yml b/docker-compose.yml
index a1c706d..dcda298 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,7 @@
-version: '3'
services:
shell:
build: .
- image: eeweather_shell
+ image: eeweather
stdin_open: true
tty: true
entrypoint: /bin/sh
@@ -10,26 +9,14 @@ services:
- .:/app
test:
- image: eeweather_shell
+ image: eeweather
entrypoint: py.test -n0
volumes:
- .:/app
- /app/tests/__pycache__/
- eeweather:
- image: eeweather_shell
- entrypoint: eeweather
- volumes:
- - .:/app
-
- python:
- image: eeweather_shell
- entrypoint: python
- volumes:
- - .:/app
-
- blacken:
- image: eeweather_shell
- entrypoint: black .
+ uv:
+ image: eeweather
+ entrypoint: uv
volumes:
- .:/app
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..29ed91b
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,21 @@
+# Documentation
+
+User documentation (source material for the eeweather section of
+[opendsm.energy](https://opendsm.energy/)):
+
+- [methodology.md](methodology.md) — motivation and how the library works:
+ locations and stations, sources and routing, the variable vocabulary,
+ provenance and reproducibility, the packaged registry and its updates.
+- [matching.md](matching.md) — station matching and data quality: ranking,
+ filters, the distance cap and disqualification, quality ratings and rating
+ periods.
+- [example.md](example.md) — an end-to-end walkthrough with real outputs.
+- [api.md](api.md) — the public API, surface by surface.
+- [references.md](references.md) — data sources, methods, related projects.
+
+Contributor documentation:
+
+- [adding-a-source.md](adding-a-source.md) — the source contracts, built-in
+ package anatomy, and the no-fork path for external sources.
+- [expanding-the-vocabulary.md](expanding-the-vocabulary.md) — adding a
+ canonical variable.
diff --git a/docs/adding-a-source.md b/docs/adding-a-source.md
new file mode 100644
index 0000000..9620275
--- /dev/null
+++ b/docs/adding-a-source.md
@@ -0,0 +1,164 @@
+# Adding a weather-data source
+
+A *source* is a provider of weather data. This guide covers the three
+kinds the engine understands, the package anatomy a built-in source
+follows, and the contracts every source must honor. `sources/ghcnh/`,
+`sources/tmy3/`, and `sources/cz2010/` are the reference
+implementations.
+
+## Pick the kind
+
+| kind | contract (in `sources/base.py`) | when |
+|---|---|---|
+| observations | `Feed` — `fetch_year(external_id, year, variables)` | station-keyed time series (an API, a warehouse table, a file mirror) |
+| normals | `NormalsSource` — `fetch(station_id)` returning one typical year | typical-year products; served only by explicit `source=` pin, never routed |
+| grid | `Source` — `estimate(latitude, longitude, start, end, ...)` | gridded/reanalysis products sampled at a point; no station involved. A grid source declares `name`, `kind`, and `variables` and is registerable via `eeweather.sources.register`, making it nameable in preference tuples, `source=` pins, and `to_dict`/`from_dict`; it participates in routing when `kind == "observations"`. Its `Provenance` carries `kind`, `source`, and `variables` with an open `payload` dict and no `station_id`. No built-in grid adapter ships yet, and `candidates()` remains station-only |
+
+The engine supplies everything around the fetch: per-year caching with
+variable-union refresh, typical-year tiling (Feb 29 is NaN), range
+alignment to an identical UTC index per request, resampling to any
+pandas frequency by each variable's declared aggregation, gap warnings,
+provenance records, and variable routing. A source
+implements transport and parsing, nothing else.
+
+## The contracts every source honors
+
+- **Canonical variables and units.** Users never see native names or
+ units. Declare the canonical variables you serve (see
+ `sources/vocabulary.py`: name, unit, definition, aggregation) and
+ translate both
+ directions at ingest — request canonical → your native names, response
+ native → canonical columns in canonical units. If you serve a variable
+ the vocabulary lacks, add the vocabulary entry (one `Variable` line)
+ in the same change. Unit validation against real payloads is part of
+ acceptance.
+- **Station identity.** Station ids are GHCN ids; every other id system
+ is an alias in the identifier crosswalk (`registry/identifiers.db`).
+ Never parse an id's format. If your data is keyed by another system,
+ declare `id_namespace` and the engine translates per fetch; if it uses
+ a system the crosswalk lacks, add alias rows for it. A source's
+ catalog, inventory, quality, and availability tables are keyed by
+ station id alone, and enumeration unions every registered catalog, so
+ a source covering a disjoint set of stations from the built-ins needs
+ no repo change to integrate.
+- **Frames** are indexed by UTC timestamps; values the station did not
+ report are NaN; duplicate timestamps are averaged (accumulation
+ variables are instead summed within the hour).
+
+## Anatomy of a built-in source package
+
+```
+sources//
+ __init__.py docstring, DATA_PATH, one register_attachment call,
+ re-export of the class (no classes or functions here)
+ source.py class Source()
+ .db packaged sqlite data, if the source ships any
+```
+
+Registration attaches the data file to every registry connection under
+the source's name, with roles describing its tables:
+
+- `catalog=True` — a `stations` table of station facts (station_id,
+ name, latitude, longitude, elevation, country, subdivision) and a
+ `station_zone` table of zone assignments. Facts for a station are
+ read from the first registered catalog that lists it, and enumeration
+ is the union across catalogs; both behaviors are pinned in
+ `tests/registry/test_db.py`.
+- `inventory=True` — an `inventory` table of monthly observation counts
+ per (station_id, year); powers quality ratings.
+- `quality=True` — a `quality` table of (station_id, rating).
+- `availability=True` — a `stations` table listing which stations the
+ source can serve (archive lists); powers the `has_sources` ranking
+ filter, exposed as an `is_` column.
+
+Invariant (pinned in `tests/registry/test_schema.py`): every station id
+in the crosswalk appears in at least one catalog. If your source
+introduces stations the crosswalk lacks, its build step must insert
+their identity rows and catalog facts together.
+
+## Steps
+
+1. Create the package with the anatomy above; subclass the kind's
+ contract and declare only what is distinctive (compare
+ `GHCNhSource`: `name`, `id_namespace`, `variables`; the normals
+ classes: `name` and an archive `_url`).
+2. Register the built-in: add the class to `BUILTIN_SOURCES` in
+ `sources/engine.py` so the string name resolves.
+3. Data: extend `build/schema.py` and `build/migrate.py` (or your own
+ build step) to produce `.db`; if its content decays, extend
+ `build/refresh.py` and the `refresh-registry` workflow's `add-paths`.
+4. Vocabulary: add any new canonical variables, with units and
+ aggregation, to `sources/vocabulary.py`.
+5. Tests, mirroring the package layout (`tests/sources/test_.py`):
+ transport-level tests against real captured payloads (fixtures in
+ `tests/fixtures/`, recorded from the live service — never
+ synthesized; synthetic data is for edge cases only), unit pins with
+ hardcoded expected values, error paths (empty responses, unknown
+ stations), and a schema entry in `tests/registry/test_schema.py` for
+ your data file. Add the source's expected rows to the
+ `sources_serving`/`variables()` pins in `tests/sources/test_sources.py`.
+6. CHANGELOG entry describing the source and its variables.
+
+## External sources (no repo changes)
+
+Users plug in their own data without touching this repo:
+
+- Station-keyed (e.g. a BigQuery table): implement the `Feed` protocol
+ and pass the instance — `StationSource(dataset=my_feed)` or directly
+ in a location's `sources=(...)` tuple. Declare `cacheable = False`
+ for fast backends. The contract test pattern is `FixtureFeed` in
+ `tests/sources/test_sources.py`.
+- Gridded: implement `Source.estimate` returning
+ `(DataFrame, warnings, provenance)` and declare `name`, `kind`, and
+ `variables` on the object (routing reads them). Register the instance
+ the same way as a feed to make it nameable in preference tuples,
+ `source=` pins, and location serialization. No built-in grid adapter
+ ships yet, and `candidates()` remains station-only.
+
+To make an external source nameable — usable as a string in preference
+tuples and `load_data(source=...)`, and rebuildable by
+`WeatherLocation.from_dict`/`from_json` — register the instance once at
+import of your package:
+
+```python
+eeweather.sources.register(MyFeed())
+location = eeweather.WeatherLocation(lat, lon, sources=("ghcnh", "my-feed"))
+```
+
+Registration accepts feeds and normals sources; built-in names cannot
+be replaced. Optionally, register a sqlite file of your own
+(`eeweather.registry.db.metadata_db_connection_proxy.register_attachment`,
+same roles as built-in sources) to give your source availability
+filtering, inventory-based quality, and ranking columns.
+
+A source serving variables the vocabulary lacks declares them at
+registration; they become requestable, routable, and visible in
+`eeweather.sources.variables()` like canonical entries:
+
+```python
+eeweather.sources.register(
+ MyIrradianceFeed(),
+ vocabulary=(
+ eeweather.sources.Variable(
+ "ghi", "W/m2", "Global horizontal irradiance.", "mean"
+ ),
+ ),
+)
+```
+
+The one-definition rule still holds: canonical names are reserved, and
+when several sources register the same variable their definitions must
+agree, so the first registration freezes the unit and aggregation. A
+variable's ``aggregation`` ("mean", "sum", "min", or "max") controls
+its resampling to other frequencies;
+accumulation variables (``aggregation="sum"``, e.g. precipitation
+depth) add up within each period and are never gap-interpolated.
+
+What an external source cannot do, by design:
+
+- Serve stations the registry does not know. Station-keyed loads
+ translate registry ids; a private sensor network enters as a
+ location-keyed `Source`, not a feed.
+
+Dependencies for external backends (cloud SDKs and the like) belong in
+the user's package, never in eeweather's.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..c303c35
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,198 @@
+# API Reference
+
+The public surface is deliberately small: two classes at the root, plus the
+`eeweather.sources`, `eeweather.cache`, and `eeweather.exceptions` namespaces.
+Anything not documented here is internal and may change without notice.
+
+---
+
+## `eeweather.WeatherLocation`
+
+Weather at a latitude/longitude. The location is the primary abstraction;
+weather is estimated by the configured sources, and each requested variable
+routes to the first source in the preference tuple that serves it.
+
+```python
+WeatherLocation(latitude, longitude, sources=("ghcnh",), pins=None)
+```
+
+- `sources` — ordered source preference: built-in names (`"ghcnh"`), custom
+ source objects, or registered names.
+- `pins` — station id per source name, bypassing matching. Normally not
+ written by hand: pins are captured automatically from provenance at load
+ time and round-trip through `to_dict`/`from_dict`.
+
+**`from_place(kind, code, sources=("ghcnh",))`** — construct from a coded
+place, e.g. `from_place("zcta", "91104")` for a ZIP code tabulation area.
+
+**`load_data(start, end, frequency="h", variables=None, source=None,
+ignore_disqualification=False, **load_kwargs)`** — weather between two
+explicit-UTC datetimes (inclusive). Returns `(DataFrame, warnings)`.
+
+- `frequency` — any pandas offset alias (`"30min"`, `"h"`, `"D"`, `"W"`,
+ `"MS"`, `"YS"`). Coarser-than-hourly aggregates by each variable's
+ vocabulary aggregation; finer-than-hourly (must divide the hour evenly)
+ interpolates.
+- `variables` — canonical names; defaults to `("temperature",)`; `"all"` is
+ every variable the configured sources serve.
+- `source` — pin every variable to one source, bypassing routing.
+ Typical-year sources (`"tmy3"`, `"cz2010"`) are reachable only this way.
+- `ignore_disqualification` — serve the best available station with a warning
+ instead of raising when none qualifies (see
+ [matching.md](matching.md)).
+- `load_kwargs` — cache and network controls: `read_from_cache`,
+ `write_to_cache`, `fetch_from_web` (all default `True`).
+
+Routed loads never raise for missing data (NaN plus warnings); a pinned load
+with nothing at all for the dates raises `DataNotAvailableError`. Records
+provenance on the frame's `attrs["provenance"]` and on `self.provenance`, and
+pins the location to the resolved stations.
+
+**`candidates(**filters)`** — ranked candidate stations as a DataFrame.
+Filters: `match_zones`, `match_subdivision` (with `site_subdivision`),
+`has_sources`, `minimum_quality`, `rating_period`, `max_distance_meters`,
+`max_difference_elevation_meters` (with `site_elevation`).
+
+**`zones`** — `{system: zone_id}` for the point, e.g.
+`{"iecc_climate_zone": "3", "ba_climate_zone": "Hot-Dry", ...}`.
+
+**`provenance`** — `{source_name: Provenance}` from the most recent load;
+`None` before any load. Each `Provenance` records `kind`
+(`"observations"`/`"normals"`), `source`, `variables`, `station_id`,
+`distance_meters`, and a `payload` dict (empty unless a source adds
+source-specific detail; the station fields are `None` for non-station
+sources).
+
+**`pins`** — `{source_name: station_id}` the location replays.
+
+**`to_dict()` / `from_dict(data)` / `to_json()` / `from_json(text)`** — the
+location's replay state (coordinates, source names, pins). A location rebuilt
+from this state loads from the same station per source. Custom source objects
+must be registered (`eeweather.sources.register`) to serialize.
+
+---
+
+## `eeweather.WeatherStation`
+
+Weather at a known station, keyed by its registry id. All other identifier
+systems are aliases that translate to it.
+
+```python
+WeatherStation(station_id, sources=("ghcnh",), load_metadata=True)
+```
+
+- `sources` — ordered source preference for routing; every entry must be an
+ observation source (built-in name, custom source object, or registered
+ name). Duplicate names or a normals source (e.g. `"tmy3"`) raise at
+ construction — pin a normals source through `load_data(source=...)` instead.
+
+**Constructors** — `from_id(namespace, external_id)` plus the conveniences
+`from_usaf(usaf_id)`, `from_wban(wban_id)`, `from_icao(icao_code)`.
+Identifiers are normalized (case, zero-padding); a historically reused
+identifier resolves to its most recent holder, and raises
+`AmbiguousIdentifierError` only when genuinely unresolvable.
+
+**`search(country=None, subdivision=None, has_sources=())`** *(classmethod)* —
+the station registry as a metadata DataFrame indexed by station id.
+
+**`translate(ids, from_namespace, to_namespace)`** *(static)* — bulk
+identifier translation; returns `{input_id: (target_ids, ...)}` (mappings can
+be one-to-many; unmapped ids are absent).
+
+**Attributes** — `id`, `ids` (`{namespace: [values]}`), `name`, `latitude`,
+`longitude`, `elevation`, `country`, `subdivision`, `zones`, `quality`
+(default-window rating), `inventory_years`; `json()` returns them as a dict.
+
+**`load_data(start, end, frequency="h", variables=None, source=None,
+read_from_cache=True, write_to_cache=True, fetch_from_web=True)`** — same
+`frequency`, `variables`, `source`, and cache-control semantics as the
+location's, minus resolution: there is no `ignore_disqualification` (the
+station is already chosen) and no arbitrary `**load_kwargs` passthrough (a
+pinned station has nothing further to configure). Sets `self.provenance`. A
+pinned (`source=`) load with nothing at all for the requested dates raises
+`DataNotAvailableError`; a routed load (`source` unset) never raises for
+missing data, returning NaN plus warnings instead.
+
+**`get_quality(anchor, source=None)`** — the station's data-quality
+rating (`"high"`/`"medium"`/`"low"`) anchored to a date; see
+[matching.md](matching.md) for the rating window.
+
+---
+
+## `eeweather.sources`
+
+**`Source`** — the estimation protocol: `estimate(latitude, longitude, start,
+end, frequency=, variables=, **load_kwargs)` returning
+`(DataFrame, warnings, provenance)`. Implemented directly for location-keyed
+(gridded) data.
+
+**`Feed`** — the station-keyed data protocol: declare `name`, `id_namespace`,
+`variables` (canonical names), optionally `default_variables` and
+`cacheable`; implement `fetch_year(external_id, year, variables)`. The engine
+supplies caching, id translation, alignment, warnings, and provenance.
+
+**`NormalsSource`** — the typical-year protocol: `fetch(station_id)`
+returning one typical year, tiled onto requested calendar years by
+month-day-hour (leap days are NaN).
+
+**`StationSource`** — the nearest-suitable-station strategy wrapping a feed:
+`StationSource(dataset="ghcnh", rank_kwargs=None, select_kwargs=None)`.
+Applies the 150 km distance cap and quality/coverage checks by default;
+configure via `rank_kwargs`/`select_kwargs`.
+
+**`Variable(name, unit, description, aggregation)`** — a vocabulary entry;
+`aggregation` is one of `"mean"`, `"sum"`, `"min"`, `"max"`.
+
+**`register(source, vocabulary=())`** — register a custom feed, normals
+source, or estimation/grid source under its name, making it usable as a
+string in preference tuples and `source=` pins and rebuildable by location
+serialization. `vocabulary`
+declares any variables the vocabulary lacks; canonical names are reserved and
+definitions must agree across sources.
+
+**`variables()`** — the vocabulary as a DataFrame: name, unit, description,
+aggregation, and the sources serving each entry.
+
+---
+
+## `eeweather.cache`
+
+Observed data caches locally per (source, station, year); current-year
+entries refresh as new data arrives.
+
+**`set_path(path)`** — relocate the cache database (the `EEWEATHER_CACHE_URL`
+environment variable does the same). **`clear()`** — empty it.
+
+---
+
+## `eeweather.exceptions`
+
+All errors subclass **`EEWeatherError`**:
+
+- **`UnrecognizedStationError`** — an id no identifier system knows.
+- **`UnrecognizedPlaceError`** — a place code missing from the geography.
+- **`AmbiguousIdentifierError`** — an external id with multiple holders and
+ no recent one to prefer.
+- **`DataNotAvailableError`** — a pinned source with nothing at all for the
+ requested dates (carries `source` and `station_id`).
+- **`NoQualifiedStationError`** — no station passed qualification for a
+ location (see `ignore_disqualification`).
+
+**`EEWeatherWarning`** — the structured warning returned by loads (not a
+Python `warnings` category): `qualified_name` (e.g.
+`"eeweather.data_gap"`), human-readable `description`, and a `data` dict.
+
+---
+
+## Registry maintenance
+
+Not an import surface, but part of the public contract:
+
+```
+python -m eeweather.registry.update # refresh registry data on demand
+python -m eeweather.registry.update --clear # revert to packaged data
+```
+
+Updates also run automatically in the background when the installed registry
+data is more than six months old; set `EEWEATHER_AUTO_UPDATE=0` to disable.
+See [methodology.md](methodology.md) for the update channel's design.
diff --git a/docs/example.md b/docs/example.md
new file mode 100644
index 0000000..d0471b8
--- /dev/null
+++ b/docs/example.md
@@ -0,0 +1,198 @@
+# Example
+
+An end-to-end walkthrough: weather for a building, provenance, reproducible
+reporting-period loads, station-level access, and typical years. Outputs shown
+are real (Burbank, CA; calendar 2024), captured against live NOAA services.
+
+## Weather at a location
+
+Analysts have coordinates, not station ids, so the primary entry point is a
+location:
+
+```python
+from datetime import datetime, timezone
+
+import eeweather
+
+location = eeweather.WeatherLocation(34.2, -118.365)
+# or from a ZIP code tabulation area:
+location = eeweather.WeatherLocation.from_place("zcta", "91505")
+
+df, warnings = location.load_data(
+ datetime(2024, 1, 1, tzinfo=timezone.utc),
+ datetime(2024, 12, 31, tzinfo=timezone.utc),
+)
+```
+
+```
+ temperature
+2024-01-01 00:00:00+00:00 13.890610
+2024-01-01 01:00:00+00:00 13.240167
+2024-01-01 02:00:00+00:00 13.098333
+...
+[8761 rows x 1 columns]
+```
+
+The frame covers the full requested range at the requested frequency (hourly
+by default) in UTC; hours the station did not report are NaN, and `warnings`
+lists anything worth knowing (late starts, gaps, truncation) as structured
+`EEWeatherWarning` objects — this load returned none.
+
+Everything about *how* the values were produced is on the provenance record,
+stamped on both the frame (`df.attrs["provenance"]`) and the location:
+
+```python
+location.provenance
+```
+
+```
+{'ghcnh': Provenance(kind='observations', source='ghcnh',
+ variables=('temperature',), station_id='USW00023152',
+ distance_meters=0.0, payload={})}
+```
+
+The location also knows its climate zones (from packaged geometries — no
+network involved):
+
+```python
+location.zones
+```
+
+```
+{'ba_climate_zone': 'Hot-Dry', 'ca_climate_zone': 'CA_09',
+ 'iecc_climate_zone': '3', 'iecc_moisture_regime': 'B'}
+```
+
+## Reproducibility across periods
+
+The first load pinned the location to the station it resolved. Serialize that
+state with the baseline, and the reporting-period load — months later, in a
+different process — is guaranteed the same station per source:
+
+```python
+saved = location.to_json()
+```
+
+```
+{"latitude": 34.2, "longitude": -118.365, "sources": ["ghcnh"],
+ "pins": {"ghcnh": "USW00023152"}}
+```
+
+```python
+location = eeweather.WeatherLocation.from_json(saved)
+reporting, warnings = location.load_data(reporting_start, reporting_end)
+```
+
+## Auditing the match
+
+The ranked candidate frame shows the full field the resolution chose from,
+with the metadata to defend (or override) the choice:
+
+```python
+location.candidates(minimum_quality="high").head(4)
+```
+
+```
+ distance_meters quality iecc_climate_zone is_tmy3
+station_id
+USW00023152 0.0 high 3 True
+USC00041194 2159.8 high 3 False
+USW00023130 11688.4 high 3 True
+USW00093197 21761.5 high 3 True
+```
+
+Stations more than 150 km away are disqualified by default (a station that far
+is not a defensible temperature proxy); see
+[matching.md](matching.md) for the cap, quality ratings, and the
+`ignore_disqualification` escape hatch.
+
+## Weather at a known station
+
+When the station is already decided, key on it directly — by registry id or
+through any historical identifier system:
+
+```python
+station = eeweather.WeatherStation("USW00023152")
+station = eeweather.WeatherStation.from_usaf("722880") # same station
+station.ids
+```
+
+```
+{'ghcn': ['USW00023152'], 'icao': ['KBUR'], 'usaf': ['722880'],
+ 'wban': ['23152'], 'wmo': ['72288']}
+```
+
+Multiple variables come back as columns of one aligned frame, each in its
+canonical unit:
+
+```python
+df, warnings = station.load_data(
+ start, end, variables=("temperature", "relative_humidity", "wind_speed")
+)
+df.describe().loc[["mean", "min", "max"]].round(1)
+```
+
+```
+ temperature relative_humidity wind_speed
+mean 17.7 59.9 2.3
+min 2.8 4.0 0.0
+max 44.8 100.0 17.6
+```
+
+`eeweather.sources.variables()` lists the full vocabulary — canonical names,
+units, aggregations, and which sources serve each.
+
+## Other frequencies
+
+`frequency` takes any pandas offset alias. Coarser frequencies aggregate each
+variable by its declared aggregation (temperature averages; an accumulation
+variable would sum); finer-than-hourly frequencies interpolate:
+
+```python
+monthly, _ = location.load_data(start, end, frequency="MS")
+```
+
+```
+ temperature
+2024-01-01 00:00:00+00:00 12.5
+2024-02-01 00:00:00+00:00 12.4
+2024-03-01 00:00:00+00:00 13.6
+2024-04-01 00:00:00+00:00 15.5
+...
+```
+
+```python
+halfhourly, _ = station.load_data(july_1, july_2, frequency="30min")
+```
+
+```
+ temperature
+2024-07-01 00:00:00+00:00 31.464583
+2024-07-01 00:30:00+00:00 30.516958
+2024-07-01 01:00:00+00:00 29.569333
+2024-07-01 01:30:00+00:00 28.895125
+```
+
+## Typical years
+
+Typical-year products (TMY3, CZ2010) are synthetic, so they never mix into
+observed data implicitly — they are reachable only by an explicit `source=`
+pin, and the typical year tiles onto the calendar years you request:
+
+```python
+typical, _ = station.load_data(start, end, source="tmy3")
+station.provenance
+```
+
+```
+{'tmy3': Provenance(kind='normals', source='tmy3',
+ variables=('temperature',), station_id='USW00023152',
+ distance_meters=None, payload={})}
+```
+
+## Caching
+
+Observed data caches locally per station-year, so repeated loads (a portfolio
+of sites sharing stations, re-runs of an analysis) do not refetch.
+`eeweather.cache.set_path(...)` relocates the cache;
+`EEWEATHER_CACHE_URL` does the same via the environment.
diff --git a/docs/expanding-the-vocabulary.md b/docs/expanding-the-vocabulary.md
new file mode 100644
index 0000000..43487b3
--- /dev/null
+++ b/docs/expanding-the-vocabulary.md
@@ -0,0 +1,61 @@
+# Expanding the variable vocabulary
+
+> External sources do not need this document: a variable served only by
+> a private source is declared at runtime via
+> `eeweather.sources.register(source, vocabulary=...)`. This document
+> covers adding a *canonical* entry — one built-in sources serve.
+
+Every variable a source can serve has exactly one canonical name, unit,
+definition, and aggregation, owned by `sources/vocabulary.py`. Users only ever see
+canonical forms; sources translate their native names and units at
+ingest. The vocabulary is curated: un-vetted native fields are never
+passed through, so adding a variable is a deliberate, verified change.
+
+## Rules
+
+- **One canonical unit per variable, forever.** Pick it when the
+ variable is added (SI-leaning, ascii-spelled: `degC`, `m/s`, `hPa`,
+ `W/m2`) and never change it — downstream analyses depend on a column
+ name implying its unit. There are no unit options in the API; sources
+ convert at ingest, users convert downstream if they must.
+- **Names are snake_case, owned by eeweather.** The initial set adopted
+ GHCNh's spellings because they were sensible, but the name belongs to
+ the vocabulary, not to any source.
+- **Declare the aggregation.** A variable's `aggregation` ("mean",
+ "sum", "min", or "max") governs its resampling to coarser frequencies,
+ and is always stated explicitly — a silent default on an accumulation
+ variable would produce wrong values with no error. Point-in-time
+ quantities declare "mean"; accumulation variables (precipitation)
+ declare "sum" — the engine adds them up within each period, spreads
+ them evenly across sub-hourly slots, and never gap-interpolates them.
+- **Reserved suffixes.** Columns ending in `_imputed_fraction` or
+ `_is_imputed` are engine-produced companions of a canonical variable
+ and are exempt from vocabulary validation; never name a variable to
+ collide with them.
+
+## Steps
+
+1. Add the `Variable(name, unit, description, aggregation)` entry to
+ `VARIABLES` in `sources/vocabulary.py`.
+2. Declare it in the `variables` tuple of every source that serves it,
+ with translation from the source's native name and conversion into
+ the canonical unit inside that source's `fetch_year`/`fetch`.
+3. Verify units against real payloads — capture a live response as a
+ fixture and pin expected values with hardcoded numbers. Unit
+ validation against real data is the acceptance bar for the
+ declaration; a plausible-looking column in the wrong unit is worse
+ than no column.
+4. Update the pins in `tests/sources/test_sources.py`
+ (`sources_serving`, the `variables()` accessor frame) and add
+ fetch-level tests for the new variable in the serving sources' test
+ modules.
+5. CHANGELOG entry naming the variable, its unit, and which sources
+ serve it.
+
+## What routing does with it automatically
+
+Nothing else is required: a requested variable routes to the first
+source in the preference tuple that declares it, `variables="all"`
+expands to the union of the configured sources' vocabularies, an
+unroutable request raises a `ValueError` naming the sources that do
+serve it, and provenance records which source supplied it.
diff --git a/docs/matching.md b/docs/matching.md
new file mode 100644
index 0000000..c708f5c
--- /dev/null
+++ b/docs/matching.md
@@ -0,0 +1,88 @@
+# Station Matching and Quality
+
+How EEweather decides which station represents a location, and how it judges
+whether that station's data is good enough. Matching runs automatically inside
+`WeatherLocation.load_data`; everything here is also inspectable directly
+through `location.candidates()`.
+
+## Ranking
+
+Candidates are ranked by geodesic distance from the site (WGS84). The ranked
+frame carries everything a reviewer needs to audit the choice: distance,
+coordinates and elevation, climate-zone assignments (IECC zone and moisture
+regime, Building America, California Building Climate Zone), subdivision,
+quality rating, and per-source availability columns (`is_tmy3`, `is_cz2010`,
+`tmy3_class`).
+
+Filters restrict the pool before ranking:
+
+- `match_zones=("iecc_climate_zone", ...)` — candidates must share the site's
+ zone in the named systems (a site with no zone matches only stations with
+ none).
+- `match_subdivision=True` — restrict to the site's state/subdivision.
+- `has_sources=("tmy3",)` — candidates must be able to serve the named
+ sources. Station-backed sources apply this for their own dataset
+ automatically.
+- `minimum_quality="high"` — see quality, below.
+- `max_distance_meters`, `max_difference_elevation_meters` — hard caps.
+
+## The distance cap
+
+**A station 150 km from a building is not a defensible temperature proxy**, so
+station-backed sources apply `max_distance_meters=150_000` by default when
+resolving a location. The cap is generous by construction: every US ZCTA has a
+GHCNh station within 124 km (half are within 19 km), so no ordinary location is
+stranded — the cap exists to catch the extraordinary ones (offshore
+coordinates, geocoding errors, sites outside the station network) rather than
+to constrain normal matching.
+
+When no station qualifies, the load raises `NoQualifiedStationError` rather
+than silently serving a bad proxy. To proceed anyway — mirroring OpenDSM's
+disqualification pattern, the flag sits on the operation that would raise —
+pass `ignore_disqualification=True` to `load_data`: the best available station
+serves, and an `eeweather.station_disqualified` warning records which
+qualifications it failed and by how much. `rank_stations` itself applies no
+default cap; as the audit surface it shows the full field.
+
+## Quality ratings
+
+Quality is computed from the station's observation inventory — monthly
+observation counts per station-year, packaged and refreshed from NOAA's
+inventory file. A station rates **high** when every month of the rating window
+has more than 600 observations (roughly ~20 per day), **medium** above 360,
+and **low** otherwise.
+
+Because networks change, quality is a function of *when*: a station that is
+excellent today may have been sparse in 2012. The default `quality` column
+rates the last five full calendar years; anchored ratings use the same
+counts over a window matched to the request — the five calendar years ending
+two years after the anchor date (sliding back so the window never ends
+after the last full year). Two entry points expose it:
+
+- `location.candidates(minimum_quality="high", rating_period=anchor)` —
+ rank and filter by quality as of an era.
+- `station.get_quality(anchor)` — one station's rating anchored to a date.
+
+Ratings are computed at query time from the inventory, so registry updates
+extend them to recent periods without ever changing what a historical period
+rates.
+
+## Coverage and selection
+
+Ranking answers "which stations are plausible"; selection confirms the winner
+can actually serve the request. When a coverage check is configured, the
+selected station's data for the requested range is validated against a
+minimum-fraction threshold before it is accepted, and selection falls through
+to the next candidate when it fails. Custom feeds validate against their own
+data, not the default source's.
+
+## Memoization and pinning
+
+Within a process, a source memoizes its resolution per location and period
+year-span — repeated loads do not re-rank, and a different era re-resolves
+(station suitability is era-dependent). Across periods and processes, the
+guarantee is stronger: a location pins itself to the stations recorded in its
+first load's provenance, and serialized locations
+(`to_json`/`from_json`) replay those pins exactly. An explicit pin is its own
+acceptance: pinned resolution skips qualification, exactly as loading through
+`WeatherStation` directly does.
diff --git a/docs/methodology.md b/docs/methodology.md
new file mode 100644
index 0000000..0ab1f00
--- /dev/null
+++ b/docs/methodology.md
@@ -0,0 +1,142 @@
+# Methodology
+
+EEweather answers one question for energy-efficiency measurement: **what was the
+weather at this building?** Meters are at buildings; weather is observed at
+stations. Between the two sit every problem this library exists to solve —
+finding a defensible station for a location, judging whether its data is good
+enough, fetching and aligning the observations, and being able to prove, years
+later, exactly where every value came from.
+
+## Motivation
+
+Savings measurement compares energy use across periods that can be years apart,
+normalized for weather. That places demands on weather data that general-purpose
+weather APIs do not meet:
+
+- **Defensible attribution.** A weather-normalized savings number is only as
+ credible as its weather. The station serving a site must be near enough to be
+ a valid proxy, its data quality must be assessable, and the choice must be
+ explainable.
+- **Reproducibility across periods.** A baseline fit in one year and a
+ reporting-period prediction in another must use the *same* station. A silent
+ switch to a different station between periods contaminates the comparison.
+- **Analysis-ready alignment.** Models want complete, regular, UTC-indexed
+ frames with honest missing-data semantics — not raw observation streams with
+ duplicate timestamps, gaps, and native units.
+- **Longevity.** Programs run for decades. Station networks change, identifier
+ systems come and go, and data products are retired (EEweather itself
+ migrated when the ISD feed stopped receiving data in 2025). The library must
+ absorb that churn without breaking the analyses built on it.
+
+## The two abstractions
+
+The public API is two classes and a handful of namespaces.
+
+**`WeatherLocation`** is weather at a latitude/longitude — the common case,
+since analysts have building coordinates, not station ids. A location resolves
+itself to the nearest suitable station per source, loads data, and records how.
+
+**`WeatherStation`** is weather at a known station — the pinned case, keyed by
+the station's registry id, with constructors that translate historical
+identifier systems (USAF, WBAN, ICAO, WMO).
+
+Both share one loading verb: `load_data(start, end, frequency, variables,
+source=)` returns `(DataFrame, warnings)` for observed data and typical years
+alike. There are no per-product methods to learn.
+
+## Station identity
+
+Every station has exactly one registry id (its GHCN id — the broadest station
+identifier system in use, covering roughly ten times as many stations as USAF
+or WMO numbering). Every other identifier is an *alias* recorded in a packaged
+crosswalk, so `WeatherStation.from_usaf("722880")`,
+`from_wban("23152")`, `from_icao("KBUR")`, and
+`WeatherStation("USW00023152")` all reach the same station. Aliases carry a
+recency marker: when an identifier was reused over history (retired WBAN
+numbers were), the most recent holder wins, and a genuinely unresolvable
+identifier raises rather than guessing. Id formats are never parsed; ids are
+opaque keys into the registry.
+
+## Sources and routing
+
+A *source* is a provider of weather data. Built-ins: **GHCNh** (NOAA's hourly
+observation network — the default), **TMY3** and **CZ2010** (typical-year
+products, for normals-based analysis). Custom sources — a BigQuery mirror, an
+internal archive — implement small public protocols and participate as equals
+(see [adding-a-source.md](adding-a-source.md)).
+
+Locations and stations carry an ordered source preference
+(`sources=("ghcnh",)` by default). Each requested variable routes to the
+first source in the preference that serves it, and the results join into one
+frame — a user asking for temperature from observations and irradiance from a
+gridded product names variables, not plumbing. Two rules keep routing honest:
+
+- **One variable, one source, per call.** Values from different sources are
+ never stitched together inside a single column.
+- **Typical years never route.** Measured and synthetic data are never mixed
+ silently; TMY3/CZ2010 are reachable only by an explicit `source=` pin.
+
+## The variable vocabulary
+
+Every variable has exactly one canonical name, unit, definition, and
+aggregation, owned by the vocabulary. Sources translate their native fields
+and units at ingest; users never see native forms, and un-vetted fields are
+never passed through. The 1.0 set is temperature, dew point temperature
+[degC], relative humidity [%], wind speed [m/s], station-level pressure
+[hPa], and visibility [km]; `eeweather.sources.variables()` lists the current
+vocabulary and which sources serve each entry. External sources may register
+additional variables at runtime under the same one-definition rules.
+
+## Frequencies and aggregation
+
+Data is fetched hourly and served at any pandas frequency —
+`load_data(frequency=...)` takes a pandas offset alias (`"30min"`, `"h"`,
+`"D"`, `"W"`, `"MS"`, `"YS"`), parsed and validated by pandas itself.
+Resampling honors each variable's declared aggregation: point-in-time
+variables (temperature) average into coarser periods, while accumulation
+variables sum, are never gap-interpolated, and report NaN (never a silent
+zero) for periods with no data. Frequencies finer than hourly interpolate
+point-in-time variables linearly between hourly values and spread
+accumulations evenly, never crossing a missing hour. Frames for one request
+range and frequency share an identical UTC index regardless of source, so
+multi-source results join safely by construction.
+
+## Missing data
+
+Partial coverage surfaces as NaN values plus structured warnings (late starts,
+early ends, internal gaps) — routed requests never raise for missing data. A
+source explicitly pinned with `source=` that has nothing at all for the
+requested dates raises `DataNotAvailableError`. Sub-hourly gaps up to an hour
+are filled by linear interpolation at the minute level (the CalTRACK 2.3.3
+rule) for point-in-time variables.
+
+## Provenance and reproducibility
+
+Every load records provenance — per source: what kind of data, which station,
+how far from the site, which variables — on the returned frame's
+`attrs["provenance"]` and on the location or station object. Provenance is
+also the reproducibility mechanism: a location **pins itself to the stations
+resolved at its first load**, so later loads on the same location reuse them,
+and `to_dict`/`to_json` (with `from_dict`/`from_json`) carry that state across
+processes. A baseline load, a saved JSON blob, and a reporting-period load a
+year later are guaranteed the same station per source. See
+[matching.md](matching.md) for how the station is chosen in the first place.
+
+## The packaged registry, and keeping it current
+
+EEweather ships its registry — station catalog, identifier crosswalk,
+climate-zone geometries and assignments, ZCTA places, observation inventory,
+quality ratings — as packaged data, so matching and metadata work offline and
+identically for everyone on a given install. Weather data itself is always
+fetched live (and cached locally; see `eeweather.cache`).
+
+The live parts of the registry (inventory, quality, newly commissioned
+stations, identifier aliases) decay on a yearly timescale. EEweather keeps
+them current automatically: when the installed data is more than six months
+old, a load starts a background update into the platform user data directory,
+preferring ready-made files published by the repository's scheduled refresh
+(CDN-served, so fleets of any size cost the upstream agencies nothing) and
+falling back to rebuilding directly from the live NOAA files. Updates are
+atomic, abort on implausible upstream content, and take effect in new
+processes. `EEWEATHER_AUTO_UPDATE=0` disables them;
+`python -m eeweather.registry.update` runs one on demand.
diff --git a/docs/references.md b/docs/references.md
new file mode 100644
index 0000000..d282293
--- /dev/null
+++ b/docs/references.md
@@ -0,0 +1,47 @@
+# References
+
+## Data sources
+
+- **GHCNh — Global Historical Climatology Network, hourly** (NOAA NCEI).
+ The observed-weather source: hourly surface observations, served through
+ the [NCEI Access Data Service](https://www.ncei.noaa.gov/access/services/data/v1)
+ and described on the
+ [GHCNh product page](https://www.ncei.noaa.gov/products/global-historical-climatology-network-hourly).
+ The packaged station catalog and observation inventory derive from the
+ GHCNh station list and inventory files under
+ `https://www.ncei.noaa.gov/oa/global-historical-climatology-network/`.
+ GHCNh supersedes the ISD/GSOD feeds earlier EEweather versions used (those
+ stopped receiving data 2025-08-27); overlap validation against ISD history
+ showed a median hourly deviation of 0.0001 °C.
+- **TMY3 — Typical Meteorological Year 3** (NREL). Typical-year data for US
+ stations, from the archived NREL TMY3 collection; EEweather serves a
+ mirrored copy of the archive.
+- **CZ2010** (California Energy Commission). Typical-year data for
+ California's building-climate-zone reference stations, used in Title 24
+ work; EEweather serves a mirrored copy.
+- **Identifier crosswalk.** Station aliases (USAF, WBAN, ICAO, WMO) derive
+ from the GHCNh station list and the historical ISD station history file,
+ audited against both (every alias verified by coordinate agreement;
+ catch-all and reused identifiers resolved by recency).
+- **Geography.** ZCTA centroids from the US Census Bureau; IECC climate zones
+ and moisture regimes and Building America climate zones from DOE/PNNL
+ county assignments; California Building Climate Zone Areas from the CEC.
+
+## Methods
+
+- **Sub-hourly gap interpolation** follows CalTRACK 2.3.3: linear
+ interpolation at the minute level, limited to 60 minutes, for
+ point-in-time variables. See the
+ [CalTRACK methods](https://docs.caltrack.org/) and their successor,
+ maintained under [OpenDSM](https://opendsm.energy/).
+- **Station matching** ranks by WGS84 geodesic distance with optional
+ climate-zone, subdivision, quality, and availability constraints; see
+ [matching.md](matching.md).
+
+## Related projects
+
+- [OpenDSM](https://opendsm.energy/) (formerly OpenEEmeter) — the
+ measurement models EEweather was built to feed; its documentation site
+ hosts the published version of these pages.
+- [eemeter](https://github.com/opendsm/opendsm) — model implementations
+ consuming EEweather frames.
diff --git a/eeweather/__init__.py b/eeweather/__init__.py
index 070fe62..52988e5 100644
--- a/eeweather/__init__.py
+++ b/eeweather/__init__.py
@@ -1,8 +1,6 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
+"""Weather for energy-efficiency modeling.
-Copyright 2018-2023 OpenEEmeter contributors
+Copyright 2018-2026 OpenDSM contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,77 +13,16 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-
"""
-
import logging
+from logging import NullHandler
-from .__version__ import __title__, __description__, __url__, __version__
-from .__version__ import __author__, __author_email__, __license__
-from .__version__ import __copyright__
-from .geo import get_lat_long_climate_zones, get_zcta_metadata, zcta_to_lat_long
-from .database import build_metadata_db
-from .exceptions import (
- EEWeatherError,
- UnrecognizedUSAFIDError,
- UnrecognizedZCTAError,
- DataNotAvailableError,
-)
-from .summaries import get_zcta_ids, get_isd_station_usaf_ids
-from .ranking import rank_stations, combine_ranked_stations, select_station
-from .stations import (
- WeatherStation,
- get_ghcn_id,
- get_isd_station_metadata,
- get_station_quality,
- get_station_qualities,
- get_isd_file_metadata,
- fetch_hourly_data,
- fetch_tmy3_hourly_temp_data,
- fetch_cz2010_hourly_temp_data,
- get_hourly_data_cache_key,
- get_tmy3_hourly_temp_data_cache_key,
- get_cz2010_hourly_temp_data_cache_key,
- cached_hourly_data_is_expired,
- validate_hourly_data_cache,
- validate_tmy3_hourly_temp_data_cache,
- validate_cz2010_hourly_temp_data_cache,
- serialize_hourly_data,
- serialize_tmy3_hourly_temp_data,
- serialize_cz2010_hourly_temp_data,
- deserialize_hourly_data,
- deserialize_tmy3_hourly_temp_data,
- deserialize_cz2010_hourly_temp_data,
- read_hourly_data_from_cache,
- read_tmy3_hourly_temp_data_from_cache,
- read_cz2010_hourly_temp_data_from_cache,
- write_hourly_data_to_cache,
- write_tmy3_hourly_temp_data_to_cache,
- write_cz2010_hourly_temp_data_to_cache,
- destroy_cached_hourly_data,
- destroy_cached_tmy3_hourly_temp_data,
- destroy_cached_cz2010_hourly_temp_data,
- load_hourly_data_cached_proxy,
- load_tmy3_hourly_temp_data_cached_proxy,
- load_cz2010_hourly_temp_data_cached_proxy,
- load_data,
- load_tmy3_hourly_temp_data,
- load_cz2010_hourly_temp_data,
- load_cached_hourly_data,
- load_cached_tmy3_hourly_temp_data,
- load_cached_cz2010_hourly_temp_data,
-)
-from .utils import get_ghcn_ids
-from .visualization import plot_station_mapping, plot_station_mappings
-
+from .__version__ import __version__
+from .location import WeatherLocation
+from .station import WeatherStation
-def get_version():
- return __version__
-# Set default logging handler to avoid "No handler found" warnings.
-# will not work below py2.7
-# workaround: https://docs.python.org/release/2.6/library/logging.html#configuring-logging-for-a-library
-from logging import NullHandler
+__all__ = ("WeatherLocation", "WeatherStation", "__version__")
logging.getLogger(__name__).addHandler(NullHandler())
diff --git a/eeweather/__version__.py b/eeweather/__version__.py
index c8ad4d7..ab1ae1e 100644
--- a/eeweather/__version__.py
+++ b/eeweather/__version__.py
@@ -1,27 +1 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-__title__ = "eeweather"
-__description__ = "Weather for Open Energy Efficiency Meter"
-__url__ = "http://github.com/openeemeter/eeweather"
__version__ = "0.3.30"
-__author__ = "Phil Ngo"
-__author_email__ = "phil@openee.io"
-__license__ = "Apache 2.0"
-__copyright__ = "Copyright 2018-2023 OpenEEmeter contributors"
diff --git a/eeweather/build/__init__.py b/eeweather/build/__init__.py
new file mode 100644
index 0000000..bba35e2
--- /dev/null
+++ b/eeweather/build/__init__.py
@@ -0,0 +1 @@
+"""Registry build and refresh tooling."""
diff --git a/eeweather/build/__main__.py b/eeweather/build/__main__.py
new file mode 100644
index 0000000..0e6c1f7
--- /dev/null
+++ b/eeweather/build/__main__.py
@@ -0,0 +1,34 @@
+"""Packaged-data maintenance entry point.
+
+``python -m eeweather.build`` refreshes the live parts of the packaged
+data (GHCNh catalog, observation inventory, quality ratings, identity
+rows for new stations) in place. ``python -m eeweather.build --migrate
+OLD DESTDIR`` builds the packaged data files from a single-file
+ghcn-keyed database.
+"""
+import argparse
+
+from .migrate import migrate
+from .refresh import refresh
+
+
+
+def main():
+ parser = argparse.ArgumentParser(prog="python -m eeweather.build")
+ parser.add_argument(
+ "--migrate",
+ nargs=2,
+ metavar=("OLD", "DESTDIR"),
+ help="build the packaged data files from a single-file db",
+ )
+ args = parser.parse_args()
+
+ if args.migrate:
+ counts = migrate(args.migrate[0], args.migrate[1])
+ else:
+ counts = refresh()
+ print(counts)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/eeweather/build/migrate.py b/eeweather/build/migrate.py
new file mode 100644
index 0000000..f6b30b7
--- /dev/null
+++ b/eeweather/build/migrate.py
@@ -0,0 +1,278 @@
+"""Migrate a single-file ghcn-keyed database to the packaged data files.
+
+Produces the identifier crosswalk, the US geography pack, and one
+database per built-in source (the GHCNh catalog with inventory and
+quality; TMY3/CZ2010 station lists). Static content originates from
+primary sources that are partly retired, so it is carried forward from
+the existing packaged data rather than rebuilt; live content is updated
+separately by ``refresh``.
+"""
+import os
+import sqlite3
+from datetime import datetime, timezone
+
+from ..registry.db import MONTH_COLUMNS
+from .schema import (
+ CZ2010_SCHEMA,
+ GEOGRAPHY_SCHEMA,
+ GHCNH_SCHEMA,
+ IDENTIFIERS_SCHEMA,
+ TMY3_SCHEMA,
+ create_schema,
+)
+
+
+
+# GHCNh ids begin with a FIPS 10-4 country code; the registry stores ISO
+# 3166-1 alpha-2. Codes below are the full set present in the registry.
+FIPS_TO_ISO = {
+ "US": "US",
+ "AS": "AU",
+ "RQ": "PR",
+ "AY": "AQ",
+ "GM": "DE",
+ "VQ": "VI",
+ "CQ": "MP",
+ "AQ": "AS",
+ "GQ": "GU",
+ "WQ": "UM",
+ "MQ": "UM",
+ "JQ": "UM",
+ "LQ": "UM",
+ "FQ": "UM",
+ "DQ": "UM",
+ "TT": "TT",
+ "PP": "PG",
+ "AV": "AI",
+}
+
+WBAN_SENTINEL = "99999"
+
+# isd-history's catch-all id, reused for hundreds of distinct Australian
+# sites; aliases derived from it reference no particular station
+USAF_CATCHALL = "949999"
+
+# alias rows that contradict station coordinates in other primary sources
+# (e.g. the GHCNh list assigns Wasilla's ICAO to Yakataga, whose isd
+# records say PACY); excluded on build and on refresh
+EXCLUDED_ALIASES = {
+ ("icao", "PAWS", "USW00026445"),
+}
+
+# recency demotions where two records legitimately reference one site
+# (TJSJ: the San Juan airport's COOP record next to its airport record)
+DEMOTED_ALIASES = {
+ ("icao", "TJSJ", "RQC00668814"),
+}
+
+ZONE_SYSTEMS = (
+ "iecc_climate_zone",
+ "iecc_moisture_regime",
+ "ba_climate_zone",
+ "ca_climate_zone",
+)
+
+
+def _connect_fresh(path, schema):
+ if os.path.exists(path):
+ os.remove(path)
+ conn = sqlite3.connect(path)
+ create_schema(conn, schema)
+
+ return conn
+
+
+def _float_or_none(value):
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+# GHCN's id-encoding knowledge lives here: this is the only place a
+# station id is sliced to derive an attribute. Generic row-insert code
+# receives country as the value this returns, never by parsing the id.
+def _country(ghcn_id):
+ return FIPS_TO_ISO.get(ghcn_id[:2])
+
+
+def _migrate_stations(src, identifiers, ghcnh):
+ rows = src.execute(
+ """
+ select ghcn_id, name, latitude, longitude, elevation, state, quality,
+ usaf_ids, wban_ids, recent_wban_id, icao_code,
+ iecc_climate_zone, iecc_moisture_regime,
+ ba_climate_zone, ca_climate_zone
+ from station_metadata
+ """
+ ).fetchall()
+ for (
+ ghcn_id, name, latitude, longitude, elevation, state, quality,
+ usaf_ids, wban_ids, recent_wban_id, icao_code, *zones
+ ) in rows:
+ ghcnh.execute(
+ "insert into stations values (?, ?, ?, ?, ?, ?, ?)",
+ (
+ ghcn_id,
+ name,
+ _float_or_none(latitude),
+ _float_or_none(longitude),
+ _float_or_none(elevation),
+ _country(ghcn_id),
+ state,
+ ),
+ )
+ ghcnh.execute(
+ "insert into quality values (?, ?)", (ghcn_id, quality)
+ )
+
+ alias_rows = [("ghcn", ghcn_id, 1)]
+ real_usafs = [
+ u for u in (usaf_ids or "").split(",") if u and u != USAF_CATCHALL
+ ]
+ for usaf_id in real_usafs:
+ alias_rows.append(("usaf", usaf_id, 0))
+ # a station whose only usaf id is the catch-all inherited its wban
+ # list from the catch-all's unrelated records
+ if real_usafs:
+ for wban_id in set((wban_ids or "").split(",")):
+ if wban_id and wban_id != WBAN_SENTINEL:
+ alias_rows.append(
+ ("wban", wban_id, int(wban_id == recent_wban_id))
+ )
+ if icao_code:
+ alias_rows.append(("icao", icao_code, 1))
+ rows = [
+ (namespace, external_id, ghcn_id,
+ 0 if (namespace, external_id, ghcn_id) in DEMOTED_ALIASES else recent)
+ for namespace, external_id, recent in alias_rows
+ if (namespace, external_id, ghcn_id) not in EXCLUDED_ALIASES
+ ]
+ identifiers.executemany(
+ "insert into station_identifier values (?, ?, ?, ?)", rows
+ )
+
+ for system, zone_id in zip(ZONE_SYSTEMS, zones):
+ if zone_id is not None:
+ ghcnh.execute(
+ "insert into station_zone values (?, ?, ?)",
+ (ghcn_id, system, str(zone_id)),
+ )
+
+
+def _migrate_inventory(src, ghcnh):
+ months = ", ".join(MONTH_COLUMNS)
+ rows = src.execute(
+ "select ghcn_id, year, {} from ghcn_inventory".format(months)
+ ).fetchall()
+ placeholders = ", ".join("?" for _ in range(2 + len(MONTH_COLUMNS)))
+ ghcnh.executemany(
+ "insert into inventory values ({})".format(placeholders), rows
+ )
+
+
+def _migrate_places(src, geography):
+ rows = src.execute(
+ """
+ select zcta_id, state, latitude, longitude,
+ iecc_climate_zone, iecc_moisture_regime,
+ ba_climate_zone, ca_climate_zone
+ from zcta_metadata
+ """
+ ).fetchall()
+ for zcta_id, state, latitude, longitude, *zones in rows:
+ geography.execute(
+ "insert into place values (?, ?, ?, ?, ?, ?)",
+ ("zcta", zcta_id, "US", state,
+ _float_or_none(latitude), _float_or_none(longitude)),
+ )
+ for system, zone_id in zip(ZONE_SYSTEMS, zones):
+ if zone_id is not None:
+ geography.execute(
+ "insert into place_zone values (?, ?, ?, ?)",
+ ("zcta", zcta_id, system, str(zone_id)),
+ )
+
+
+def _migrate_zones(src, geography):
+ for system in ZONE_SYSTEMS:
+ if system == "ca_climate_zone":
+ name_col = "name"
+ else:
+ name_col = "null"
+ rows = src.execute(
+ "select {}, {}, geometry from {}_metadata".format(system, name_col, system)
+ ).fetchall()
+ geography.executemany(
+ "insert into zone values (?, ?, ?, ?)",
+ [(system, str(zone_id), name, geometry)
+ for zone_id, name, geometry in rows],
+ )
+
+
+def _migrate_normals(src, tmy3, cz2010):
+ tmy3.executemany(
+ "insert into stations values (?, ?, ?)",
+ src.execute(
+ "select ghcn_id, usaf_id, class from tmy3_station_metadata"
+ ).fetchall(),
+ )
+ cz2010.executemany(
+ "insert into stations values (?, ?)",
+ src.execute(
+ "select ghcn_id, usaf_id from cz2010_station_metadata"
+ ).fetchall(),
+ )
+
+
+def _stamp_refreshed(ghcnh):
+ ghcnh.execute(
+ "create table if not exists meta (key text primary key, value text)"
+ " without rowid"
+ )
+ ghcnh.execute(
+ "insert or replace into meta values ('refreshed_at', ?)",
+ (datetime.now(timezone.utc).strftime("%Y-%m-%d"),),
+ )
+
+
+def migrate(source_path, dest_dir):
+ """Build the packaged data files from a single-file ghcn-keyed
+ database, writing identifiers.db, geography_us.db, ghcnh.db, tmy3.db,
+ and cz2010.db into ``dest_dir``."""
+ src = sqlite3.connect(source_path)
+ identifiers = _connect_fresh(
+ os.path.join(dest_dir, "identifiers.db"), IDENTIFIERS_SCHEMA
+ )
+ geography = _connect_fresh(
+ os.path.join(dest_dir, "geography_us.db"), GEOGRAPHY_SCHEMA
+ )
+ ghcnh = _connect_fresh(os.path.join(dest_dir, "ghcnh.db"), GHCNH_SCHEMA)
+ tmy3 = _connect_fresh(os.path.join(dest_dir, "tmy3.db"), TMY3_SCHEMA)
+ cz2010 = _connect_fresh(os.path.join(dest_dir, "cz2010.db"), CZ2010_SCHEMA)
+
+ _migrate_stations(src, identifiers, ghcnh)
+ _migrate_inventory(src, ghcnh)
+ _migrate_places(src, geography)
+ _migrate_zones(src, geography)
+ _migrate_normals(src, tmy3, cz2010)
+ _stamp_refreshed(ghcnh)
+
+ counts = {}
+ for name, conn, tables in (
+ ("identifiers", identifiers, ("station_identifier",)),
+ ("geography_us", geography, ("place", "place_zone", "zone")),
+ ("ghcnh", ghcnh, ("stations", "station_zone", "inventory", "quality")),
+ ("tmy3", tmy3, ("stations",)),
+ ("cz2010", cz2010, ("stations",)),
+ ):
+ for table in tables:
+ counts["{}.{}".format(name, table)] = conn.execute(
+ "select count(*) from {}".format(table)
+ ).fetchone()[0]
+ conn.commit()
+ conn.execute("vacuum")
+ conn.close()
+ src.close()
+
+ return counts
diff --git a/eeweather/build/refresh.py b/eeweather/build/refresh.py
new file mode 100644
index 0000000..3e58bbe
--- /dev/null
+++ b/eeweather/build/refresh.py
@@ -0,0 +1,304 @@
+"""Refresh the live parts of the packaged data files in place.
+
+Updates the GHCNh catalog (newly listed stations), its observation
+inventory and quality ratings, identity rows for appended stations, and
+WMO/ICAO alias rows the GHCNh station list carries. Geography packs,
+archive station lists, and existing identifier rows are never touched.
+Station ids are permanent: existing rows are never re-keyed or deleted.
+
+Stations append only when they fall within the geography the package
+serves (within APPEND_BUFFER_KM of a zone geometry of an attached
+geography pack; offshore platforms self-exclude) and have observations
+within the last APPEND_ACTIVITY_YEARS full years; appended stations
+arrive with their zone assignments. Implausibly small upstream files
+abort the refresh rather than wiping packaged content.
+"""
+import csv
+import io
+import json
+import sqlite3
+from collections import defaultdict
+from datetime import datetime, timezone
+
+import requests
+from shapely.geometry import Point, shape
+
+from ..registry.db import (
+ GHCNH_DB_PATH,
+ IDENTIFIERS_DB_PATH,
+ MONTH_COLUMNS,
+ metadata_db_connection_proxy,
+)
+from ..registry.quality import QUALITY_WINDOW_YEARS, _quality_from_minimum
+from .migrate import EXCLUDED_ALIASES, _country, _float_or_none, _stamp_refreshed
+
+
+
+STATION_LIST_URL = (
+ "https://www.ncei.noaa.gov/oa/global-historical-climatology-network/"
+ "hourly/doc/ghcnh-station-list.csv"
+)
+INVENTORY_URL = (
+ "https://www.ncei.noaa.gov/oa/global-historical-climatology-network/"
+ "hourly/doc/ghcnh-inventory.txt"
+)
+
+REQUEST_TIMEOUT_SECONDS = 120
+
+# admission buffer around zone geometries: coastal stations sit just
+# outside imprecise polygon edges; offshore platforms sit far outside
+APPEND_BUFFER_KM = 10.0
+
+APPEND_ACTIVITY_YEARS = 2
+
+
+def _fetch_station_list():
+ """GHCNh station list as {ghcn_id: (name, lat, lon, elev, wmo, icao, iso_code)}."""
+ response = requests.get(STATION_LIST_URL, timeout=REQUEST_TIMEOUT_SECONDS)
+ response.raise_for_status()
+ stations = {}
+ reader = csv.DictReader(io.StringIO(response.text))
+ for row in reader:
+ ghcn_id = (row.get("GHCN_ID") or "").strip()
+ if not ghcn_id:
+ continue
+ stations[ghcn_id] = (
+ (row.get("NAME") or "").strip(),
+ row.get("LATITUDE"),
+ row.get("LONGITUDE"),
+ row.get("ELEVATION"),
+ (row.get("WMO_ID") or "").strip(),
+ (row.get("ICAO") or "").strip(),
+ (row.get("ISO_CODE") or "").strip(),
+ )
+
+ return stations
+
+
+def _fetch_inventory():
+ """GHCNh inventory rows as (ghcn_id, year, jan..dec observation counts)."""
+ response = requests.get(INVENTORY_URL, timeout=REQUEST_TIMEOUT_SECONDS)
+ response.raise_for_status()
+ rows = []
+ for line in response.text.splitlines():
+ parts = line.split()
+ if len(parts) != 2 + len(MONTH_COLUMNS):
+ continue
+ ghcn_id, year = parts[0], parts[1]
+ if not year.isdigit(): # skip the header row
+ continue
+ rows.append((ghcn_id, int(year)) + tuple(int(n) for n in parts[2:]))
+
+ return rows
+
+
+def _zone_geometries():
+ """(system, zone_id, shape) triples from every attached geography pack."""
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ geometries = []
+ for alias in proxy.geography_aliases:
+ for system, zone_id, geometry in conn.execute(
+ "select system, zone_id, geometry from {}.zone".format(alias)
+ ):
+ geometries.append((system, zone_id, shape(json.loads(geometry))))
+
+ return geometries
+
+
+def _recently_active_stations(inventory):
+ """Station ids with any observations in the last full years."""
+ last_full_year = datetime.now(timezone.utc).year - 1
+ threshold = last_full_year - APPEND_ACTIVITY_YEARS + 1
+ active = set()
+ for row in inventory:
+ if row[1] >= threshold and any(row[2:]):
+ active.add(row[0])
+
+ return active
+
+
+def _append_new_stations(ghcnh, identifiers, station_list, inventory):
+ """Insert stations newly present in the GHCNh list.
+
+ A station appends only when its coordinates fall within
+ APPEND_BUFFER_KM of a zone geometry of an attached geography pack and
+ it has observations within the last APPEND_ACTIVITY_YEARS full years.
+ Appended stations get their zone assignments (strict containment) and
+ a ghcn identity row. Existing stations are never modified."""
+ known = {row[0] for row in ghcnh.execute("select station_id from stations")}
+ geometries = _zone_geometries()
+ buffer_degrees = APPEND_BUFFER_KM / 111.0
+ active = _recently_active_stations(inventory)
+ added = 0
+ for ghcn_id, (name, latitude, longitude, elevation, _wmo, _icao, iso_code) in station_list.items():
+ if ghcn_id in known or ghcn_id not in active:
+ continue
+ lat = _float_or_none(latitude)
+ lon = _float_or_none(longitude)
+ if lat is None or lon is None:
+ continue
+ point = Point(lon, lat)
+ if not any(
+ geometry.distance(point) <= buffer_degrees
+ for _, _, geometry in geometries
+ ):
+ continue
+ country = iso_code or _country(ghcn_id)
+ ghcnh.execute(
+ "insert into stations values (?, ?, ?, ?, ?, ?, ?)",
+ (
+ ghcn_id,
+ name,
+ lat,
+ lon,
+ _float_or_none(elevation),
+ country,
+ None,
+ ),
+ )
+ zones = {}
+ for system, zone_id, geometry in geometries:
+ if system not in zones and geometry.contains(point):
+ zones[system] = zone_id
+ ghcnh.executemany(
+ "insert into station_zone values (?, ?, ?)",
+ [(ghcn_id, system, zone_id) for system, zone_id in zones.items()],
+ )
+ identifiers.execute(
+ "insert into station_identifier values (?, ?, ?, ?)",
+ ("ghcn", ghcn_id, ghcn_id, 1),
+ )
+ added += 1
+
+ return added
+
+
+def _refresh_aliases(ghcnh, identifiers, station_list):
+ """Insert WMO and ICAO alias rows the GHCNh list carries for cataloged
+ stations. Existing rows are never modified, and an id that already
+ has a recent holder on another station is never claimed (the upstream
+ list disagreeing with the packaged crosswalk is a conflict to review,
+ not to auto-resolve)."""
+ known = {row[0] for row in ghcnh.execute("select station_id from stations")}
+ existing = set()
+ recent_holders = defaultdict(set)
+ for namespace, external_id, station_id, recent in identifiers.execute(
+ "select namespace, external_id, station_id, recent"
+ " from station_identifier where namespace in ('wmo', 'icao')"
+ ):
+ existing.add((namespace, external_id, station_id))
+ if recent:
+ recent_holders[(namespace, external_id)].add(station_id)
+ added = 0
+ for ghcn_id, (_name, _lat, _lon, _elev, wmo, icao, _iso) in station_list.items():
+ if ghcn_id not in known:
+ continue
+ for namespace, external_id in (("wmo", wmo), ("icao", icao)):
+ if not external_id:
+ continue
+ if (namespace, external_id, ghcn_id) in existing:
+ continue
+ if (namespace, external_id, ghcn_id) in EXCLUDED_ALIASES:
+ continue
+ if recent_holders[(namespace, external_id)] - {ghcn_id}:
+ continue
+ identifiers.execute(
+ "insert into station_identifier values (?, ?, ?, ?)",
+ (namespace, external_id, ghcn_id, 1),
+ )
+ recent_holders[(namespace, external_id)].add(ghcn_id)
+ added += 1
+
+ return added
+
+
+def _replace_inventory(ghcnh, inventory):
+ """Replace inventory rows for cataloged stations."""
+ known = {row[0] for row in ghcnh.execute("select station_id from stations")}
+ ghcnh.execute("delete from inventory")
+ placeholders = ", ".join("?" for _ in range(2 + len(MONTH_COLUMNS)))
+ rows = [row for row in inventory if row[0] in known]
+ ghcnh.executemany(
+ "insert into inventory values ({})".format(placeholders), rows
+ )
+
+ return len(rows)
+
+
+def _recompute_quality(ghcnh):
+ """Recompute the quality ratings over the last full years."""
+ last_full_year = datetime.now(timezone.utc).year - 1
+ window_start = last_full_year - QUALITY_WINDOW_YEARS + 1
+ months = ", ".join(MONTH_COLUMNS)
+ counts_by_station = {}
+ for row in ghcnh.execute(
+ "select station_id, year, {} from inventory"
+ " where year between ? and ?".format(months),
+ (window_start, last_full_year),
+ ):
+ counts_by_station.setdefault(row[0], {})[row[1]] = row[2:]
+
+ ghcnh.execute("delete from quality")
+ for station_id, in ghcnh.execute("select station_id from stations").fetchall():
+ years = counts_by_station.get(station_id, {})
+ minimum = None
+ for year in range(window_start, last_full_year + 1):
+ year_minimum = min(years.get(year, (0,) * 12))
+ if minimum is None or year_minimum < minimum:
+ minimum = year_minimum
+ ghcnh.execute(
+ "insert into quality values (?, ?)",
+ (station_id, _quality_from_minimum(minimum)),
+ )
+
+
+def _plausible_or_raise(ghcnh, station_list, inventory):
+ """Abort when an upstream file is implausibly small: a renamed header
+ or format change must fail the refresh, not wipe packaged content."""
+ n_stations = ghcnh.execute("select count(*) from stations").fetchone()[0]
+ n_inventory = ghcnh.execute("select count(*) from inventory").fetchone()[0]
+ listed = sum(1 for row in ghcnh.execute("select station_id from stations")
+ if row[0] in station_list)
+ if listed < 0.9 * n_stations:
+ raise RuntimeError(
+ "Implausible station list: covers {} of {} cataloged"
+ " stations.".format(listed, n_stations)
+ )
+ covered = sum(1 for row in inventory if row[0] in
+ {r[0] for r in ghcnh.execute("select station_id from stations")})
+ if covered < 0.9 * n_inventory:
+ raise RuntimeError(
+ "Implausible inventory: {} rows for cataloged stations,"
+ " packaged has {}.".format(covered, n_inventory)
+ )
+
+
+def refresh(ghcnh_path=GHCNH_DB_PATH, identifiers_path=IDENTIFIERS_DB_PATH):
+ """Refresh live packaged content in place; returns change counts."""
+ station_list = _fetch_station_list()
+ inventory = _fetch_inventory()
+
+ ghcnh = sqlite3.connect(ghcnh_path)
+ identifiers = sqlite3.connect(identifiers_path)
+ try:
+ _plausible_or_raise(ghcnh, station_list, inventory)
+ added = _append_new_stations(ghcnh, identifiers, station_list, inventory)
+ aliases_added = _refresh_aliases(ghcnh, identifiers, station_list)
+ inventory_rows = _replace_inventory(ghcnh, inventory)
+ _recompute_quality(ghcnh)
+ _stamp_refreshed(ghcnh)
+ for conn in (ghcnh, identifiers):
+ conn.commit()
+ conn.execute("vacuum")
+ finally:
+ ghcnh.close()
+ identifiers.close()
+
+ result = {
+ "stations_added": added,
+ "aliases_added": aliases_added,
+ "inventory_rows": inventory_rows,
+ }
+
+ return result
diff --git a/eeweather/build/schema.py b/eeweather/build/schema.py
new file mode 100644
index 0000000..42fb7f5
--- /dev/null
+++ b/eeweather/build/schema.py
@@ -0,0 +1,131 @@
+"""DDL for the packaged data files.
+
+The registry holds two kinds of files, each named for exactly what it
+contains: the identifier crosswalk (identifiers.db — global; station ids
+are GHCN ids, other id systems are aliases), and regional geography
+packs (geography_us.db — zone geometries and coded places with their
+zone assignments). Each source packages its own database beside its
+adapter: the GHCNh catalog (station facts and their zone assignments,
+as GHCNh's material claims them) with observation inventory and quality
+ratings, and the TMY3/CZ2010 archive station lists. Connections ATTACH
+geography packs and registered source databases so fact, availability,
+and quality queries can join across files. Composite-key tables are WITHOUT ROWID so
+the primary key is the storage order and no shadow index is needed.
+"""
+from ..registry.db import MONTH_COLUMNS
+
+
+
+IDENTIFIERS_SCHEMA = (
+ """
+ create table station_identifier (
+ namespace text not null,
+ external_id text not null,
+ station_id text not null,
+ recent integer not null default 0,
+ primary key (namespace, external_id, station_id)
+ ) without rowid
+ """,
+ "create index ix_identifier_station on station_identifier (station_id)",
+)
+
+GEOGRAPHY_SCHEMA = (
+ """
+ create table place (
+ kind text not null,
+ code text not null,
+ country text,
+ subdivision text,
+ latitude real,
+ longitude real,
+ primary key (kind, code)
+ ) without rowid
+ """,
+ """
+ create table place_zone (
+ kind text not null,
+ code text not null,
+ system text not null,
+ zone_id text not null,
+ primary key (kind, code, system)
+ ) without rowid
+ """,
+ """
+ create table zone (
+ system text not null,
+ zone_id text not null,
+ name text,
+ geometry text,
+ primary key (system, zone_id)
+ ) without rowid
+ """,
+)
+
+GHCNH_SCHEMA = (
+ """
+ create table stations (
+ station_id text primary key,
+ name text,
+ latitude real,
+ longitude real,
+ elevation real,
+ country text,
+ subdivision text
+ ) without rowid
+ """,
+ "create index ix_stations_country on stations (country, subdivision)",
+ """
+ create table station_zone (
+ station_id text not null,
+ system text not null,
+ zone_id text not null,
+ primary key (station_id, system)
+ ) without rowid
+ """,
+ """
+ create table inventory (
+ station_id text not null,
+ year integer not null,
+ {},
+ primary key (station_id, year)
+ ) without rowid
+ """.format(", ".join("{} integer".format(m) for m in MONTH_COLUMNS)),
+ """
+ create table quality (
+ station_id text primary key,
+ quality text not null
+ ) without rowid
+ """,
+ """
+ create table meta (
+ key text primary key,
+ value text
+ ) without rowid
+ """,
+)
+
+# normals sources: which stations have archives, and the USAF id naming
+# each station's archive file
+TMY3_SCHEMA = (
+ """
+ create table stations (
+ station_id text primary key,
+ usaf_id text not null,
+ class text
+ ) without rowid
+ """,
+)
+
+CZ2010_SCHEMA = (
+ """
+ create table stations (
+ station_id text primary key,
+ usaf_id text not null
+ ) without rowid
+ """,
+)
+
+
+def create_schema(conn, statements):
+ for statement in statements:
+ conn.execute(statement)
diff --git a/eeweather/cache.py b/eeweather/cache.py
index cf04880..81c71dc 100644
--- a/eeweather/cache.py
+++ b/eeweather/cache.py
@@ -1,21 +1,9 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+"""The shared weather-data cache.
+One sqlite-backed JSON key-value store serves every station and source.
+Its location is taken from ``set_path``, the EEWEATHER_CACHE_URL
+environment variable, or the platform user cache directory, in that
+order.
"""
import contextlib
import datetime
@@ -23,12 +11,18 @@
import os
import sqlite3
-import pytz
+import platformdirs
SQLITE_URL_PREFIX = "sqlite:///"
+DATA_EXPIRATION_DAYS = 1
+
+# a data year keeps arriving for a while after it ends (publication lag),
+# so entries written shortly after year end stay refreshable
+YEAR_END_GRACE_DAYS = 14
+
def _sqlite_path_from_url(url):
if not url.startswith(SQLITE_URL_PREFIX):
@@ -41,12 +35,7 @@ def _sqlite_path_from_url(url):
class KeyValueStore(object):
- """JSON key-value store on a local sqlite database.
-
- The database location is taken from the url argument, the
- EEWEATHER_CACHE_URL environment variable, or ~/.eeweather/cache.db,
- in that order. Urls have the form sqlite:///path/to/cache.db.
- """
+ """JSON key-value store on a local sqlite database."""
def __init__(self, url=None):
self._prepare_db(url)
@@ -57,9 +46,8 @@ def __repr__(self):
def _get_url(self): # pragma: no cover (tests always provide url)
url = os.environ.get("EEWEATHER_CACHE_URL")
if url is None:
- directory = "{}/.eeweather".format(os.path.expanduser("~"))
- if not os.path.exists(directory):
- os.makedirs(directory)
+ directory = platformdirs.user_cache_dir("eeweather")
+ os.makedirs(directory, exist_ok=True)
url = "sqlite:///{}/cache.db".format(directory)
return url
@@ -73,26 +61,38 @@ def _prepare_db(self, url=None):
with self._connect() as conn, conn:
conn.execute(
"create table if not exists items ("
- " key text unique,"
+ " key text primary key,"
" data text,"
" updated text)"
)
- conn.execute("create index if not exists ix_items_key on items (key)")
def _connect(self):
# closes the connection on exit; writes commit via the inner
# transaction context in each caller
return contextlib.closing(sqlite3.connect(self._path))
- def key_exists(self, key):
+ def key_exists(self, key: str) -> bool:
with self._connect() as conn:
row = conn.execute("select 1 from items where key = ?", (key,)).fetchone()
return row is not None
+ def keys(self, prefix: str) -> list:
+ """All keys beginning with a prefix, sorted."""
+ escaped = (
+ prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ )
+ with self._connect() as conn:
+ rows = conn.execute(
+ "select key from items where key like ? escape '\\' order by key",
+ (escaped + "%",),
+ ).fetchall()
+
+ return [row[0] for row in rows]
+
def save_json(self, key, data):
data = json.dumps(data, separators=(",", ":"))
- updated = datetime.datetime.now(pytz.UTC).isoformat()
+ updated = datetime.datetime.now(datetime.timezone.utc).isoformat()
with self._connect() as conn, conn:
conn.execute(
"insert into items (key, data, updated) values (?, ?, ?)"
@@ -128,3 +128,55 @@ def clear(self, key=None):
conn.execute("delete from items")
else:
conn.execute("delete from items where key = ?", (key,))
+
+
+class KeyValueStoreProxy(object):
+ def __init__(self):
+ self._store = None
+
+ def get_store(self): # pragma: no cover
+ if self._store is None:
+ self._store = KeyValueStore()
+
+ return self._store
+
+ def set_store(self, store):
+ self._store = store
+
+
+key_value_store_proxy = KeyValueStoreProxy()
+
+
+def set_path(path: str) -> None:
+ """Point the shared weather cache at a specific sqlite file, creating
+ its directory if needed.
+
+ All stations and sources share this one store; overrides the
+ EEWEATHER_CACHE_URL environment variable and the default location.
+ """
+ parent = os.path.dirname(os.path.abspath(path))
+ os.makedirs(parent, exist_ok=True)
+ key_value_store_proxy.set_store(KeyValueStore("sqlite:///{}".format(path)))
+
+
+def clear() -> None:
+ """Drop all cached weather data from the shared store."""
+ key_value_store_proxy.get_store().clear()
+
+
+def _expired(last_updated, year):
+ """Whether a cache entry for a data year is stale: entries written
+ while the year's data was still arriving (during the year, or within
+ YEAR_END_GRACE_DAYS after it ends) expire after
+ DATA_EXPIRATION_DAYS."""
+ if last_updated is None:
+ return True
+ expiration_limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+ days=DATA_EXPIRATION_DAYS
+ )
+ volatile_until = datetime.datetime(
+ year + 1, 1, 1, tzinfo=datetime.timezone.utc
+ ) + datetime.timedelta(days=YEAR_END_GRACE_DAYS)
+ still_volatile = last_updated < volatile_until
+
+ return expiration_limit > last_updated and still_volatile
diff --git a/eeweather/cli.py b/eeweather/cli.py
deleted file mode 100644
index fa929c2..0000000
--- a/eeweather/cli.py
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import json
-import subprocess
-
-import click
-
-from . import (
- get_isd_station_metadata as _get_isd_station_metadata,
- get_isd_file_metadata as _get_isd_file_metadata,
-)
-from .exceptions import UnrecognizedUSAFIDError
-
-from .database import build_metadata_db, inspect_metadata_db
-
-
-@click.group()
-def cli():
- """Example usage
-
- See station metadata:
-
- \b
- $ eeweather inspect-isd-station 722880
- {
- "usaf_id": "722880",
- "wban_ids": "23152,99999",
- "recent_wban_id": "23152",
- "name": "BURBANK-GLENDALE-PASA ARPT",
- "latitude": "+34.201",
- "longitude": "-118.358",
- "elevation": "+0236.2"
- }
-
- See station file data:
-
- \b
- $ eeweather inspect-isd-file-years 722880
- {...}
-
- See station file names for ISD:
-
- \b
- $ eeweather inspect-isd-filenames 722880 2017
- ftp://ftp.ncei.noaa.gov/pub/data/noaa/2017/722880-23152-2017.gz
-
- Or for GSOD:
-
- \b
- $ eeweather inspect-gsod-filenames 722880 2017
- ftp://ftp.ncei.noaa.gov/pub/data/gsod/2017/722880-23152-2017.op.gz
-
- Rebuild metadata db from primary source files:
-
- \b
- $ eeweather rebuild-db
-
- Inspect metadata db using sqlite3 CLI:
-
- \b
- $ eeweather inspect-db
-
-
- """
- pass # pragma: no cover
-
-
-@cli.command()
-@click.argument("usaf_id")
-def inspect_isd_station(usaf_id):
- metadata = _get_isd_station_metadata(usaf_id)
- click.echo(json.dumps(metadata, indent=2))
-
-
-@cli.command()
-@click.argument("usaf_id")
-def inspect_isd_file_years(usaf_id):
- metadata = _get_isd_file_metadata(usaf_id)
- click.echo(json.dumps(metadata, indent=2))
-
-
-@cli.command()
-@click.option("--zcta-geometry/--no-zcta-geometry", default=False)
-@click.option(
- "--iecc-climate-zone-geometry/--no-iecc-climate-zone-geometry", default=True
-)
-@click.option(
- "--iecc-moisture-regime-geometry/--no-iecc-moisture-regime-geometry", default=True
-)
-@click.option("--ba-climate-zone-geometry/--no-ba-climate-zone-geometry", default=True)
-@click.option("--ca-climate-zone-geometry/--no-ca-climate-zone-geometry", default=True)
-def rebuild_db(
- zcta_geometry,
- iecc_climate_zone_geometry,
- iecc_moisture_regime_geometry,
- ba_climate_zone_geometry,
- ca_climate_zone_geometry,
-):
- build_metadata_db( # pragma: no cover
- zcta_geometry,
- iecc_climate_zone_geometry,
- iecc_moisture_regime_geometry,
- ba_climate_zone_geometry,
- ca_climate_zone_geometry,
- )
-
-
-@cli.command()
-def inspect_db():
- inspect_metadata_db() # pragma: no cover
diff --git a/eeweather/connections.py b/eeweather/connections.py
deleted file mode 100644
index 841a22b..0000000
--- a/eeweather/connections.py
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-
-import logging
-import os
-import sqlite3
-import threading
-
-from .cache import KeyValueStore
-
-logger = logging.getLogger(__name__)
-
-__all__ = ("metadata_db_connection_proxy",)
-
-
-class MetadataDBConnectionProxy(object):
- """Serves a cached read connection to the packaged metadata database.
-
- Connections are cached per thread and reused, so callers do not close
- them.
- """
-
- def __init__(self):
- root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- path = os.path.join(root_dir, "eeweather", "resources")
- self.db_path = os.path.join(path, "metadata.db")
- self._local = threading.local()
-
- def get_connection(self):
- connection = getattr(self._local, "connection", None)
- if connection is None:
- connection = sqlite3.connect(self.db_path)
- self._local.connection = connection
-
- return connection
-
- def reset_database(self): # pragma: no cover
- connection = getattr(self._local, "connection", None)
- if connection is not None:
- connection.close()
- self._local.connection = None
- os.remove(self.db_path)
-
- return self.get_connection()
-
-
-class KeyValueStoreProxy(object):
- def __init__(self):
- self._store = None
-
- def get_store(self): # pragma: no cover
- if self._store is None:
- self._store = KeyValueStore()
- return self._store
-
-
-# Use proxies for lazy loading, abstraction
-metadata_db_connection_proxy = MetadataDBConnectionProxy()
-key_value_store_proxy = KeyValueStoreProxy()
diff --git a/eeweather/database.py b/eeweather/database.py
deleted file mode 100644
index eb0723c..0000000
--- a/eeweather/database.py
+++ /dev/null
@@ -1,1465 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-from collections import defaultdict
-from datetime import datetime, timedelta
-import json
-import logging
-import os
-import shutil
-import subprocess
-import tempfile
-
-import pandas as pd
-import numpy as np
-
-from .connections import metadata_db_connection_proxy
-from .stations import QUALITY_WINDOW_YEARS, _quality_from_minimum
-
-
-
-logger = logging.getLogger(__name__)
-
-__all__ = ("build_metadata_db", "inspect_metadata_db")
-
-CZ2010_LIST = [
- "725958",
- "725945",
- "723840",
- "724837",
- "724800",
- "725845",
- "747188",
- "722880",
- "723926",
- "722926",
- "722927",
- "746120",
- "722899",
- "724936",
- "725946",
- "723815",
- "723810",
- "722810",
- "725940",
- "723890",
- "722976",
- "724935",
- "747185",
- "722909",
- "723826",
- "722956",
- "725847",
- "723816",
- "747020",
- "724927",
- "722895",
- "722970",
- "722975",
- "722874",
- "722950",
- "724815",
- "724926",
- "722953",
- "725955",
- "724915",
- "725957",
- "724955",
- "723805",
- "724930",
- "723927",
- "722868",
- "747187",
- "723820",
- "724937",
- "723965",
- "723910",
- "723895",
- "725910",
- "725920",
- "722860",
- "722869",
- "724830",
- "724839",
- "724917",
- "724938",
- "722925",
- "722907",
- "722900",
- "722903",
- "722906",
- "724940",
- "724945",
- "724946",
- "722897",
- "722910",
- "723830",
- "722977",
- "723925",
- "723940",
- "722885",
- "724957",
- "724920",
- "722955",
- "745160",
- "725846",
- "690150",
- "725905",
- "722886",
- "723930",
- "723896",
- "724838",
-]
-
-
-class PrettyFloat(float):
- def __repr__(self):
- return "%.7g" % self
-
-
-def pretty_floats(obj):
- if isinstance(obj, float):
- return PrettyFloat(round(obj, 4))
- elif isinstance(obj, dict):
- return dict((k, pretty_floats(v)) for k, v in obj.items())
- elif isinstance(obj, (list, tuple)):
- return list(map(pretty_floats, obj))
- return obj
-
-
-def to_geojson(polygon):
- import simplejson
- from shapely.geometry import mapping
-
- return simplejson.dumps(pretty_floats(mapping(polygon)), separators=(",", ":"))
-
-
-def _download_primary_sources():
- root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- scripts_path = os.path.join(root_dir, "scripts", "download_primary_sources.sh")
-
- download_path = tempfile.mkdtemp()
- subprocess.call([scripts_path, download_path])
- return download_path
-
-
-def _load_isd_station_metadata(download_path):
- """Collect metadata for US isd stations."""
- from shapely.geometry import Point
-
- # load ISD history which contains metadata
- isd_history = pd.read_csv(
- os.path.join(download_path, "isd-history.csv"),
- dtype=str,
- parse_dates=["BEGIN", "END"],
- )
-
- hasGEO = (
- isd_history.LAT.notnull() & isd_history.LON.notnull() & (isd_history.LAT != 0)
- )
- hasUSAF = isd_history.USAF != "999999"
-
- isUS = (
- ((isd_history.CTRY == "US") & (isd_history.STATE.notnull()))
- # AQ = American Samoa, GQ = Guam, RQ = Peurto Rico, VQ = Virgin Islands
- | (isd_history.CTRY.str[1] == "Q")
- )
-
- isAus = isd_history.CTRY == "AS"
-
- metadata = {}
- for usaf_station, group in isd_history[hasGEO & hasUSAF & (isUS | isAus)].groupby(
- "USAF"
- ):
- # find most recent
- recent = group.loc[group.END.idxmax()]
- wban_stations = list(group.WBAN)
- metadata[usaf_station] = {
- "usaf_id": usaf_station,
- "wban_ids": wban_stations,
- "recent_wban_id": recent.WBAN,
- "name": recent["STATION NAME"],
- "icao_code": recent.ICAO,
- "latitude": recent.LAT if recent.LAT not in ("+00.000",) else None,
- "longitude": recent.LON if recent.LON not in ("+000.000",) else None,
- "point": Point(float(recent.LON), float(recent.LAT)),
- "elevation": (
- recent["ELEV(M)"]
- if not str(float(recent["ELEV(M)"])).startswith("-999")
- else None
- ),
- "state": recent.STATE,
- }
-
- for usaf_id, (lat, lon) in ISD_COORDINATE_CORRECTIONS.items():
- if usaf_id in metadata:
- metadata[usaf_id]["latitude"] = lat
- metadata[usaf_id]["longitude"] = lon
- metadata[usaf_id]["point"] = Point(float(lon), float(lat))
-
- return metadata
-
-
-# Corrections to known-bad coordinates in the upstream isd-history registry,
-# verified against the physical site location and the GHCNh station list.
-ISD_COORDINATE_CORRECTIONS = {
- # Ann Arbor Municipal (KARB): isd-history longitude is off by 4 degrees
- "725374": ("+42.223", "-083.740"),
-}
-
-
-GHCN_MATCH_SANITY_KM = 50.0
-GHCN_NEAREST_NEIGHBOR_KM = 5.0
-
-
-def _haversine_km(lat1, lon1, lat2, lon2):
- earth_radius_km = 6371.0
- p1, p2 = np.radians(lat1), np.radians(lat2)
- dp, dl = np.radians(lat2 - lat1), np.radians(lon2 - lon1)
- a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2
-
- return 2 * earth_radius_km * np.arcsin(np.sqrt(a))
-
-
-GHCNH_INVENTORY_MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
- "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
-
-
-def _load_ghcnh_inventory(download_path):
- """Monthly observation counts per GHCNh station and year."""
- inventory = pd.read_csv(
- os.path.join(download_path, "ghcnh-inventory.txt"), sep=r"\s+"
- )
-
- return inventory
-
-
-def _ghcn_data_years(inventory):
- """First and last year with observations per GHCNh station."""
- with_data = inventory[inventory[GHCNH_INVENTORY_MONTHS].sum(axis=1) > 0]
- years = with_data.groupby("GHCNh_ID").YEAR.agg(["min", "max"])
-
- return {gid: (int(row["min"]), int(row["max"])) for gid, row in years.iterrows()}
-
-
-def _load_registry_ghcn_inventory(isd_station_metadata, inventory):
- """Monthly observation counts per registry station and year."""
- ghcn_to_usaf = {}
- for usaf_id, metadata in isd_station_metadata.items():
- ghcn_to_usaf.setdefault(metadata["ghcn_id"], []).append(usaf_id)
-
- rows = []
- for record in inventory.itertuples(index=False):
- for usaf_id in ghcn_to_usaf.get(record.GHCNh_ID, ()):
- rows.append(
- (usaf_id, int(record.YEAR))
- + tuple(int(getattr(record, month)) for month in GHCNH_INVENTORY_MONTHS)
- )
-
- return rows
-
-
-def _write_ghcn_inventory_table(conn, ghcn_inventory):
- cur = conn.cursor()
- cur.executemany(
- """
- insert into ghcn_inventory(
- usaf_id, year, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec
- ) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
- """,
- ghcn_inventory,
- )
- cur.execute(
- """
- create index ghcn_inventory_usaf_id_year on ghcn_inventory(usaf_id, year)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _compute_station_quality_from_ghcnh(
- isd_station_metadata, inventory, end_year=None, years_back=None
-):
- """Rate each station by its GHCNh observation counts.
-
- A station is high quality when every month of the last years_back
- full years has more than 600 observations, medium above 360, low
- otherwise or when any year is absent.
- """
- if end_year is None:
- end_year = datetime.now().year - 1 # last full year
- if years_back is None:
- years_back = QUALITY_WINDOW_YEARS
-
- year_range = set(range(end_year - (years_back - 1), end_year + 1))
- window = inventory[inventory.YEAR.isin(year_range)]
- grouped = {gid: group for gid, group in window.groupby("GHCNh_ID")}
-
- def quality(ghcn_id):
- group = grouped.get(ghcn_id)
- if group is None or set(group.YEAR) != year_range:
- return "low"
- minimum = group.groupby("YEAR")[GHCNH_INVENTORY_MONTHS].sum().to_numpy().min()
-
- return _quality_from_minimum(minimum)
-
- for usaf_id, metadata in isd_station_metadata.items():
- metadata["quality"] = quality(metadata["ghcn_id"])
-
-
-def _map_isd_stations_to_ghcn(isd_station_metadata, download_path):
- """Assign each station its GHCNh id; stations without one are removed.
-
- Match priority: shared ICAO code (nearest candidate, within
- GHCN_MATCH_SANITY_KM), GHCNh id following the USW000{wban} pattern
- (within GHCN_MATCH_SANITY_KM), then nearest GHCNh station within
- GHCN_NEAREST_NEIGHBOR_KM. Each mapped station records the first and
- last year its GHCNh record has observations.
- """
- ghcn = pd.read_csv(
- os.path.join(download_path, "ghcnh-station-list.csv"), dtype=str
- )
- ghcn["lat"] = pd.to_numeric(ghcn.LATITUDE, errors="coerce")
- ghcn["lon"] = pd.to_numeric(ghcn.LONGITUDE, errors="coerce")
- ghcn = ghcn.dropna(subset=["lat", "lon"]).reset_index(drop=True)
- ghcn_by_id = ghcn.set_index("GHCN_ID", drop=False)
- data_years = _ghcn_data_years(_load_ghcnh_inventory(download_path))
- ghcn_by_icao = {icao: group for icao, group in ghcn.dropna(subset=["ICAO"]).groupby("ICAO")}
-
- unmapped = []
- for usaf_id, metadata in isd_station_metadata.items():
- lat = pd.to_numeric(metadata["latitude"], errors="coerce")
- lon = pd.to_numeric(metadata["longitude"], errors="coerce")
- ghcn_id, method = None, None
-
- if not pd.isna(lat):
- icao_candidates = ghcn_by_icao.get(metadata["icao_code"])
- if icao_candidates is not None:
- d = _haversine_km(lat, lon, icao_candidates.lat.values, icao_candidates.lon.values)
- i = int(np.argmin(d))
- if d[i] <= GHCN_MATCH_SANITY_KM:
- ghcn_id, method = icao_candidates.GHCN_ID.values[i], "icao"
-
- if ghcn_id is None:
- wban_guess = "USW000" + str(metadata["recent_wban_id"]).zfill(5)
- if wban_guess in ghcn_by_id.index:
- row = ghcn_by_id.loc[wban_guess]
- if _haversine_km(lat, lon, row.lat, row.lon) <= GHCN_MATCH_SANITY_KM:
- ghcn_id, method = wban_guess, "wban"
-
- if ghcn_id is None:
- d = _haversine_km(lat, lon, ghcn.lat.values, ghcn.lon.values)
- i = int(np.argmin(d))
- if d[i] <= GHCN_NEAREST_NEIGHBOR_KM:
- ghcn_id, method = ghcn.GHCN_ID.values[i], "latlon"
-
- if ghcn_id is None:
- unmapped.append(usaf_id)
- else:
- metadata["ghcn_id"] = ghcn_id
- metadata["ghcn_map_method"] = method
- first_year, last_year = data_years.get(ghcn_id, (None, None))
- metadata["ghcn_first_year"] = first_year
- metadata["ghcn_last_year"] = last_year
-
- for usaf_id in unmapped:
- del isd_station_metadata[usaf_id]
-
- print("Removed {} stations with no GHCNh counterpart".format(len(unmapped)))
-
-
-def _load_isd_file_metadata(download_path, isd_station_metadata):
- """Collect data counts for isd files."""
-
- isd_inventory = pd.read_csv(
- os.path.join(download_path, "isd-inventory.csv"), dtype=str
- )
-
- # filter to stations with metadata
- station_keep = [usaf in isd_station_metadata for usaf in isd_inventory.USAF]
- isd_inventory = isd_inventory[station_keep]
- # filter by year
- year_keep = isd_inventory.YEAR > "2005"
- isd_inventory = isd_inventory[year_keep]
-
- metadata = {}
- for (usaf_station, year), group in isd_inventory.groupby(["USAF", "YEAR"]):
- if usaf_station not in metadata:
- metadata[usaf_station] = {"usaf_id": usaf_station, "years": {}}
- metadata[usaf_station]["years"][year] = [
- {
- "wban_id": row.WBAN,
- "counts": [
- row.JAN,
- row.FEB,
- row.MAR,
- row.APR,
- row.MAY,
- row.JUN,
- row.JUL,
- row.AUG,
- row.SEP,
- row.OCT,
- row.NOV,
- row.DEC,
- ],
- }
- for i, row in group.iterrows()
- ]
- return metadata
-
-
-def _load_zcta_metadata(download_path):
- from shapely.geometry import shape
-
- # load zcta geojson
- geojson_path = os.path.join(download_path, "cb_2016_us_zcta510_500k.json")
- with open(geojson_path, "r") as f:
- geojson = json.load(f)
-
- # load ZIP code prefixes by state
- zipcode_prefixes_path = os.path.join(download_path, "zipcode_prefixes.json")
- with open(zipcode_prefixes_path, "r") as f:
- zipcode_prefixes = json.load(f)
- prefix_to_zipcode = {
- zipcode_prefix: state
- for state, zipcode_prefix_list in zipcode_prefixes.items()
- for zipcode_prefix in zipcode_prefix_list
- }
-
- def _get_state(zcta):
- prefix = zcta[:3]
- return prefix_to_zipcode.get(prefix)
-
- metadata = {}
- for feature in geojson["features"]:
- zcta = feature["properties"]["GEOID10"]
- geometry = feature["geometry"]
- polygon = shape(geometry)
- centroid = polygon.centroid
- state = _get_state(zcta)
- metadata[zcta] = {
- "zcta": zcta,
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- "centroid": centroid,
- "latitude": centroid.coords[0][1],
- "longitude": centroid.coords[0][0],
- "state": state,
- }
- return metadata
-
-
-def _load_county_metadata(download_path):
- from shapely.geometry import shape
-
- # load county geojson
- geojson_path = os.path.join(download_path, "cb_2016_us_county_500k.json")
- with open(geojson_path, "r") as f:
- geojson = json.load(f)
-
- metadata = {}
- for feature in geojson["features"]:
- county = feature["properties"]["GEOID"]
- geometry = feature["geometry"]
- polygon = shape(geometry)
- centroid = polygon.centroid
- metadata[county] = {
- "county": county,
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- "centroid": centroid,
- "latitude": centroid.coords[0][1],
- "longitude": centroid.coords[0][0],
- }
-
- # load county climate zones
- county_climate_zones = pd.read_csv(
- os.path.join(download_path, "climate_zones.csv"),
- dtype=str,
- usecols=[
- "State FIPS",
- "County FIPS",
- "IECC Climate Zone",
- "IECC Moisture Regime",
- "BA Climate Zone",
- "County Name",
- ],
- )
-
- for i, row in county_climate_zones.iterrows():
- county = row["State FIPS"] + row["County FIPS"]
- if county not in metadata:
- logger.warn(
- "Could not find geometry for county {}, skipping.".format(county)
- )
- continue
-
- metadata[county].update(
- {
- "name": row["County Name"],
- "iecc_climate_zone": row["IECC Climate Zone"],
- "iecc_moisture_regime": (
- row["IECC Moisture Regime"]
- if not pd.isnull(row["IECC Moisture Regime"])
- else None
- ),
- "ba_climate_zone": row["BA Climate Zone"],
- }
- )
-
- return metadata
-
-
-def _load_CA_climate_zone_metadata(download_path):
- from shapely.geometry import shape, mapping
-
- ca_climate_zone_names = {
- "01": "Arcata",
- "02": "Santa Rosa",
- "03": "Oakland",
- "04": "San Jose-Reid",
- "05": "Santa Maria",
- "06": "Torrance",
- "07": "San Diego-Lindbergh",
- "08": "Fullerton",
- "09": "Burbank-Glendale",
- "10": "Riverside",
- "11": "Red Bluff",
- "12": "Sacramento",
- "13": "Fresno",
- "14": "Palmdale",
- "15": "Palm Spring-Intl",
- "16": "Blue Canyon",
- }
- geojson_path = os.path.join(
- download_path, "CA_Building_Standards_Climate_Zones.json"
- )
- with open(geojson_path, "r") as f:
- geojson = json.load(f)
-
- metadata = {}
- for feature in geojson["features"]:
- zone = "{:02d}".format(int(feature["properties"]["Zone"]))
- geometry = feature["geometry"]
- polygon = shape(geometry)
- metadata[zone] = {
- "ca_climate_zone": "CA_{}".format(zone),
- "name": ca_climate_zone_names[zone],
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- }
- return metadata
-
-
-def _load_tmy3_station_metadata(download_path):
- from bs4 import BeautifulSoup
-
- path = os.path.join(download_path, "tmy3-stations.html")
- with open(path, "r") as f:
- soup = BeautifulSoup(f.read(), "html.parser")
- tmy3_station_elements = soup.select("td .hide")
-
- metadata = {}
- for station_el in tmy3_station_elements:
- station_name_el = station_el.findNext("td").findNext("td")
- station_class_el = station_name_el.findNext("td")
- usaf_id = station_el.text.strip()
- name = (
- "".join(station_name_el.text.split(",")[:-1])
- .replace("\n", "")
- .replace("\t", "")
- .strip()
- )
- metadata[usaf_id] = {
- "usaf_id": usaf_id,
- "name": name,
- "state": station_name_el.text.split(",")[-1].strip(),
- "class": station_class_el.text.split()[1].strip(),
- }
- return metadata
-
-
-def _load_cz2010_station_metadata():
- return {usaf_id: {"usaf_id": usaf_id} for usaf_id in CZ2010_LIST}
-
-
-def _create_merged_climate_zones_metadata(county_metadata):
- from shapely.ops import unary_union
-
- iecc_climate_zone_polygons = defaultdict(list)
- iecc_moisture_regime_polygons = defaultdict(list)
- ba_climate_zone_polygons = defaultdict(list)
-
- for county in county_metadata.values():
- polygon = county["polygon"]
- iecc_climate_zone = county.get("iecc_climate_zone")
- iecc_moisture_regime = county.get("iecc_moisture_regime")
- ba_climate_zone = county.get("ba_climate_zone")
- if iecc_climate_zone is not None:
- iecc_climate_zone_polygons[iecc_climate_zone].append(polygon)
- if iecc_moisture_regime is not None:
- iecc_moisture_regime_polygons[iecc_moisture_regime].append(polygon)
- if ba_climate_zone is not None:
- ba_climate_zone_polygons[ba_climate_zone].append(polygon)
-
- iecc_climate_zone_metadata = {}
- for iecc_climate_zone, polygons in iecc_climate_zone_polygons.items():
- polygon = unary_union(polygons)
- polygon = polygon.simplify(0.01)
- iecc_climate_zone_metadata[iecc_climate_zone] = {
- "iecc_climate_zone": iecc_climate_zone,
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- }
-
- iecc_moisture_regime_metadata = {}
- for iecc_moisture_regime, polygons in iecc_moisture_regime_polygons.items():
- polygon = unary_union(polygons)
- polygon = polygon.simplify(0.01)
- iecc_moisture_regime_metadata[iecc_moisture_regime] = {
- "iecc_moisture_regime": iecc_moisture_regime,
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- }
-
- ba_climate_zone_metadata = {}
- for ba_climate_zone, polygons in ba_climate_zone_polygons.items():
- polygon = unary_union(polygons)
- polygon = polygon.simplify(0.01)
- ba_climate_zone_metadata[ba_climate_zone] = {
- "ba_climate_zone": ba_climate_zone,
- "polygon": polygon,
- "geometry": to_geojson(polygon),
- }
-
- return (
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- )
-
-
-def _compute_containment(
- point_metadata, point_id_field, polygon_metadata, polygon_metadata_field
-):
- from shapely.vectorized import contains
-
- points, lats, lons = zip(
- *[
- (
- point,
- float(point["latitude"]),
- float(point["longitude"]),
- )
- for point in point_metadata.values()
- if point["latitude"] and point["longitude"]
- ]
- )
-
- for i, polygon in enumerate(polygon_metadata.values()):
- containment = contains(polygon["polygon"], lons, lats)
- for point, c in zip(points, containment):
- if c:
- point[polygon_metadata_field] = polygon[polygon_metadata_field]
- # fill in with None
- for point in point_metadata.values():
- point[polygon_metadata_field] = point.get(polygon_metadata_field, None)
-
-
-def _map_zcta_to_climate_zones(
- zcta_metadata,
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- ca_climate_zone_metadata,
-):
- _compute_containment(
- zcta_metadata, "zcta", iecc_climate_zone_metadata, "iecc_climate_zone"
- )
-
- _compute_containment(
- zcta_metadata, "zcta", iecc_moisture_regime_metadata, "iecc_moisture_regime"
- )
-
- _compute_containment(
- zcta_metadata, "zcta", ba_climate_zone_metadata, "ba_climate_zone"
- )
-
- _compute_containment(
- zcta_metadata, "zcta", ca_climate_zone_metadata, "ca_climate_zone"
- )
-
-
-def _map_isd_station_to_climate_zones(
- isd_station_metadata,
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- ca_climate_zone_metadata,
-):
- _compute_containment(
- isd_station_metadata, "usaf_id", iecc_climate_zone_metadata, "iecc_climate_zone"
- )
-
- _compute_containment(
- isd_station_metadata,
- "usaf_id",
- iecc_moisture_regime_metadata,
- "iecc_moisture_regime",
- )
-
- _compute_containment(
- isd_station_metadata, "usaf_id", ba_climate_zone_metadata, "ba_climate_zone"
- )
-
- _compute_containment(
- isd_station_metadata, "usaf_id", ca_climate_zone_metadata, "ca_climate_zone"
- )
-
-
-def _find_zcta_closest_isd_stations(zcta_metadata, isd_station_metadata, limit=None):
- if limit is None:
- limit = 10
- import pyproj
-
- geod = pyproj.Geod(ellps="WGS84")
-
- isd_usaf_ids, isd_lats, isd_lngs = zip(
- *[
- (
- isd_station["usaf_id"],
- float(isd_station["latitude"]),
- float(isd_station["longitude"]),
- )
- for isd_station in isd_station_metadata.values()
- ]
- )
-
- isd_lats = np.array(isd_lats)
- isd_lngs = np.array(isd_lngs)
-
- for zcta in zcta_metadata.values():
- zcta_lats = np.tile(zcta["latitude"], isd_lats.shape)
- zcta_lngs = np.tile(zcta["longitude"], isd_lngs.shape)
-
- dists = geod.inv(zcta_lngs, zcta_lats, isd_lngs, isd_lats)[2]
- sorted_dists = np.argsort(dists)[:limit]
-
- closest_isd_stations = []
- for i, idx in enumerate(sorted_dists):
- usaf_id = isd_usaf_ids[idx]
- isd_station = isd_station_metadata[usaf_id]
- closest_isd_stations.append(
- {
- "usaf_id": usaf_id,
- "distance_meters": int(round(dists[idx])),
- "rank": i + 1,
- "iecc_climate_zone_match": (
- zcta.get("iecc_climate_zone")
- == isd_station.get("iecc_climate_zone")
- ),
- "iecc_moisture_regime_match": (
- zcta.get("iecc_moisture_regime")
- == isd_station.get("iecc_moisture_regime")
- ),
- "ba_climate_zone_match": (
- zcta.get("ba_climate_zone")
- == isd_station.get("ba_climate_zone")
- ),
- "ca_climate_zone_match": (
- zcta.get("ca_climate_zone")
- == isd_station.get("ca_climate_zone")
- ),
- }
- )
- zcta["closest_isd_stations"] = closest_isd_stations
-
-
-def _create_table_structures(conn):
- cur = conn.cursor()
- cur.execute(
- """
- create table isd_station_metadata (
- usaf_id text not null
- , wban_ids text not null
- , recent_wban_id text not null
- , name text not null
- , icao_code text
- , latitude text
- , longitude text
- , elevation text
- , state text
- , ghcn_id text not null
- , ghcn_map_method text not null
- , ghcn_first_year integer
- , ghcn_last_year integer
- , quality text default 'low'
- , iecc_climate_zone text
- , iecc_moisture_regime text
- , ba_climate_zone text
- , ca_climate_zone text
- )
- """
- )
-
- cur.execute(
- """
- create table isd_file_metadata (
- usaf_id text not null
- , year text not null
- , wban_id text not null
- )
- """
- )
-
- cur.execute(
- """
- create table zcta_metadata (
- zcta_id text not null
- , geometry text
- , latitude text not null
- , longitude text not null
- , state text
- , iecc_climate_zone text
- , iecc_moisture_regime text
- , ba_climate_zone text
- , ca_climate_zone text
- )
- """
- )
-
- cur.execute(
- """
- create table iecc_climate_zone_metadata (
- iecc_climate_zone text not null
- , geometry text
- )
- """
- )
-
- cur.execute(
- """
- create table iecc_moisture_regime_metadata (
- iecc_moisture_regime text not null
- , geometry text
- )
- """
- )
-
- cur.execute(
- """
- create table ba_climate_zone_metadata (
- ba_climate_zone text not null
- , geometry text
- )
- """
- )
-
- cur.execute(
- """
- create table ca_climate_zone_metadata (
- ca_climate_zone text not null
- , name text not null
- , geometry text
- )
- """
- )
-
- cur.execute(
- """
- create table tmy3_station_metadata (
- usaf_id text not null
- , name text not null
- , state text not null
- , class text not null
- )
- """
- )
-
- cur.execute(
- """
- create table cz2010_station_metadata (
- usaf_id text not null
- )
- """
- )
-
- cur.execute(
- """
- create table ghcn_inventory (
- usaf_id text not null
- , year integer not null
- , jan integer not null
- , feb integer not null
- , mar integer not null
- , apr integer not null
- , may integer not null
- , jun integer not null
- , jul integer not null
- , aug integer not null
- , sep integer not null
- , oct integer not null
- , nov integer not null
- , dec integer not null
- )
- """
- )
-
-
-def _write_isd_station_metadata_table(conn, isd_station_metadata):
- cur = conn.cursor()
-
- rows = [
- (
- metadata["usaf_id"],
- ",".join(metadata["wban_ids"]),
- metadata["recent_wban_id"],
- metadata["name"],
- metadata["icao_code"],
- metadata["latitude"],
- metadata["longitude"],
- metadata["elevation"],
- metadata["state"],
- metadata["ghcn_id"],
- metadata["ghcn_map_method"],
- metadata["ghcn_first_year"],
- metadata["ghcn_last_year"],
- metadata["quality"],
- metadata["iecc_climate_zone"],
- metadata["iecc_moisture_regime"],
- metadata["ba_climate_zone"],
- metadata["ca_climate_zone"],
- )
- for station, metadata in sorted(isd_station_metadata.items())
- ]
- cur.executemany(
- """
- insert into isd_station_metadata(
- usaf_id
- , wban_ids
- , recent_wban_id
- , name
- , icao_code
- , latitude
- , longitude
- , elevation
- , state
- , ghcn_id
- , ghcn_map_method
- , ghcn_first_year
- , ghcn_last_year
- , quality
- , iecc_climate_zone
- , iecc_moisture_regime
- , ba_climate_zone
- , ca_climate_zone
- ) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index isd_station_metadata_usaf_id on isd_station_metadata(usaf_id)
- """
- )
- cur.execute(
- """
- create index isd_station_metadata_state on isd_station_metadata(state)
- """
- )
- cur.execute(
- """
- create index isd_station_metadata_iecc_climate_zone on
- isd_station_metadata(iecc_climate_zone)
- """
- )
- cur.execute(
- """
- create index isd_station_metadata_iecc_moisture_regime on
- isd_station_metadata(iecc_moisture_regime)
- """
- )
- cur.execute(
- """
- create index isd_station_metadata_ba_climate_zone on
- isd_station_metadata(ba_climate_zone)
- """
- )
- cur.execute(
- """
- create index isd_station_metadata_ca_climate_zone on
- isd_station_metadata(ca_climate_zone)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_isd_file_metadata_table(conn, isd_file_metadata):
- cur = conn.cursor()
-
- rows = [
- (metadata["usaf_id"], year, station_data["wban_id"])
- for isd_station, metadata in sorted(isd_file_metadata.items())
- for year, year_data in sorted(metadata["years"].items())
- for station_data in year_data
- ]
-
- cur.executemany(
- """
- insert into isd_file_metadata(
- usaf_id
- , year
- , wban_id
- ) values (?,?,?)
- """,
- rows,
- )
-
- cur.execute(
- """
- create index isd_file_metadata_usaf_id on
- isd_file_metadata(usaf_id)
- """
- )
- cur.execute(
- """
- create index isd_file_metadata_year on
- isd_file_metadata(year)
- """
- )
- cur.execute(
- """
- create index isd_file_metadata_usaf_id_year on
- isd_file_metadata(usaf_id, year)
- """
- )
- cur.execute(
- """
- create index isd_file_metadata_wban_id on
- isd_file_metadata(wban_id)
- """
- )
-
- cur.close()
- conn.commit()
-
-
-def _write_zcta_metadata_table(conn, zcta_metadata, geometry=False):
- cur = conn.cursor()
-
- rows = [
- (
- metadata["zcta"],
- metadata["geometry"] if geometry else None,
- metadata["latitude"],
- metadata["longitude"],
- metadata["state"],
- metadata["iecc_climate_zone"],
- metadata["iecc_moisture_regime"],
- metadata["ba_climate_zone"],
- metadata["ca_climate_zone"],
- )
- for zcta, metadata in sorted(zcta_metadata.items())
- ]
- cur.executemany(
- """
- insert into zcta_metadata(
- zcta_id
- , geometry
- , latitude
- , longitude
- , state
- , iecc_climate_zone
- , iecc_moisture_regime
- , ba_climate_zone
- , ca_climate_zone
- ) values (?,?,?,?,?,?,?,?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index zcta_metadata_zcta_id on zcta_metadata(zcta_id)
- """
- )
- cur.execute(
- """
- create index zcta_metadata_state on zcta_metadata(state)
- """
- )
- cur.execute(
- """
- create index zcta_metadata_iecc_climate_zone on zcta_metadata(iecc_climate_zone)
- """
- )
- cur.execute(
- """
- create index zcta_metadata_iecc_moisture_regime on zcta_metadata(iecc_moisture_regime)
- """
- )
- cur.execute(
- """
- create index zcta_metadata_ba_climate_zone on zcta_metadata(ba_climate_zone)
- """
- )
- cur.execute(
- """
- create index zcta_metadata_ca_climate_zone on zcta_metadata(ca_climate_zone)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_iecc_climate_zone_metadata_table(
- conn, iecc_climate_zone_metadata, geometry=True
-):
- cur = conn.cursor()
-
- rows = [
- (metadata["iecc_climate_zone"], metadata["geometry"] if geometry else None)
- for iecc_climate_zone, metadata in sorted(iecc_climate_zone_metadata.items())
- ]
- cur.executemany(
- """
- insert into iecc_climate_zone_metadata(
- iecc_climate_zone
- , geometry
- ) values (?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index iecc_climate_zone_metadata_iecc_climate_zone on
- iecc_climate_zone_metadata(iecc_climate_zone)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_iecc_moisture_regime_metadata_table(
- conn, iecc_moisture_regime_metadata, geometry=True
-):
- cur = conn.cursor()
-
- rows = [
- (metadata["iecc_moisture_regime"], metadata["geometry"] if geometry else None)
- for iecc_moisture_regime, metadata in sorted(
- iecc_moisture_regime_metadata.items()
- )
- ]
- cur.executemany(
- """
- insert into iecc_moisture_regime_metadata(
- iecc_moisture_regime
- , geometry
- ) values (?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index iecc_moisture_regime_metadata_iecc_moisture_regime on
- iecc_moisture_regime_metadata(iecc_moisture_regime)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_ba_climate_zone_metadata_table(
- conn, ba_climate_zone_metadata, geometry=True
-):
- cur = conn.cursor()
-
- rows = [
- (metadata["ba_climate_zone"], metadata["geometry"] if geometry else None)
- for ba_climate_zone, metadata in sorted(ba_climate_zone_metadata.items())
- ]
- cur.executemany(
- """
- insert into ba_climate_zone_metadata(
- ba_climate_zone
- , geometry
- ) values (?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index ba_climate_zone_metadata_ba_climate_zone on
- ba_climate_zone_metadata(ba_climate_zone)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_ca_climate_zone_metadata_table(
- conn, ca_climate_zone_metadata, geometry=True
-):
- cur = conn.cursor()
-
- rows = [
- (
- metadata["ca_climate_zone"],
- metadata["name"],
- metadata["geometry"] if geometry else None,
- )
- for ca_climate_zone, metadata in sorted(ca_climate_zone_metadata.items())
- ]
- cur.executemany(
- """
- insert into ca_climate_zone_metadata(
- ca_climate_zone
- , name
- , geometry
- ) values (?,?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index ca_climate_zone_metadata_ca_climate_zone on
- ca_climate_zone_metadata(ca_climate_zone)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_tmy3_station_metadata_table(conn, tmy3_station_metadata):
- cur = conn.cursor()
- rows = [
- (metadata["usaf_id"], metadata["name"], metadata["state"], metadata["class"])
- for tmy3_station, metadata in sorted(tmy3_station_metadata.items())
- ]
- cur.executemany(
- """
- insert into tmy3_station_metadata(
- usaf_id
- , name
- , state
- , class
- ) values (?,?,?,?)
- """,
- rows,
- )
- cur.execute(
- """
- create index tmy3_station_metadata_usaf_id on
- tmy3_station_metadata(usaf_id)
- """
- )
- cur.close()
- conn.commit()
-
-
-def _write_cz2010_station_metadata_table(conn, cz2010_station_metadata):
- cur = conn.cursor()
- rows = [
- (metadata["usaf_id"],)
- for cz2010_station, metadata in sorted(cz2010_station_metadata.items())
- ]
- cur.executemany(
- """
- insert into cz2010_station_metadata(
- usaf_id
- ) values (?)
- """,
- rows,
- )
- cur.execute(
- """
- create index cz2010_station_metadata_usaf_id on
- cz2010_station_metadata(usaf_id)
- """
- )
- cur.close()
- conn.commit()
-
-
-def build_metadata_db(
- zcta_geometry=False,
- iecc_climate_zone_geometry=True,
- iecc_moisture_regime_geometry=True,
- ba_climate_zone_geometry=True,
- ca_climate_zone_geometry=True,
-):
- """Build database of metadata from primary sources.
-
- Downloads primary sources, clears existing DB, and rebuilds from scratch.
-
- Parameters
- ----------
- zcta_geometry : bool, optional
- Whether or not to include ZCTA geometry in database.
- iecc_climate_zone_geometry : bool, optional
- Whether or not to include IECC Climate Zone geometry in database.
- iecc_moisture_regime_geometry : bool, optional
- Whether or not to include IECC Moisture Regime geometry in database.
- ba_climate_zone_geometry : bool, optional
- Whether or not to include Building America Climate Zone geometry in database.
- ca_climate_zone_geometry : bool, optional
- Whether or not to include California Building Climate Zone Area geometry in database.
- """
-
- try:
- import shapely
- except ImportError:
- raise ImportError("Loading polygons requires shapely.")
-
- try:
- from bs4 import BeautifulSoup
- except ImportError:
- raise ImportError("Scraping TMY3 station data requires beautifulsoup4.")
-
- try:
- import pyproj
- except ImportError:
- raise ImportError("Computing distances requires pyproj.")
-
- try:
- import simplejson
- except ImportError:
- raise ImportError("Writing geojson requires simplejson.")
-
- download_path = _download_primary_sources()
- conn = metadata_db_connection_proxy.reset_database()
-
- # Load data into memory
- print("Loading ZCTAs")
- zcta_metadata = _load_zcta_metadata(download_path)
-
- print("Loading counties")
- county_metadata = _load_county_metadata(download_path)
-
- print("Merging county climate zones")
- (
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- ) = _create_merged_climate_zones_metadata(county_metadata)
-
- print("Loading CA climate zones")
- ca_climate_zone_metadata = _load_CA_climate_zone_metadata(download_path)
-
- print("Loading ISD station metadata")
- isd_station_metadata = _load_isd_station_metadata(download_path)
-
- print("Mapping ISD stations to GHCNh ids")
- _map_isd_stations_to_ghcn(isd_station_metadata, download_path)
-
- print("Loading ISD station file metadata")
- isd_file_metadata = _load_isd_file_metadata(download_path, isd_station_metadata)
-
- print("Loading TMY3 station metadata")
- tmy3_station_metadata = _load_tmy3_station_metadata(download_path)
-
- print("Loading CZ2010 station metadata")
- cz2010_station_metadata = _load_cz2010_station_metadata()
-
- # Augment data in memory
- print("Loading GHCNh inventory for registry stations")
- ghcn_inventory = _load_registry_ghcn_inventory(
- isd_station_metadata, _load_ghcnh_inventory(download_path)
- )
-
- print("Computing station quality from the GHCNh inventory")
- # rough station quality: all months in the last 5 full years have
- # more than 600 observations
- _compute_station_quality_from_ghcnh(
- isd_station_metadata, _load_ghcnh_inventory(download_path)
- )
-
- print("Mapping ZCTAs to climate zones")
- # add county and ca climate zone mappings
- _map_zcta_to_climate_zones(
- zcta_metadata,
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- ca_climate_zone_metadata,
- )
-
- print("Mapping ISD stations to climate zones")
- # add county and ca climate zone mappings
- _map_isd_station_to_climate_zones(
- isd_station_metadata,
- iecc_climate_zone_metadata,
- iecc_moisture_regime_metadata,
- ba_climate_zone_metadata,
- ca_climate_zone_metadata,
- )
-
- # Write tables
- print("Creating table structures")
- _create_table_structures(conn)
-
- print("Writing ZCTA data")
- _write_zcta_metadata_table(conn, zcta_metadata, geometry=zcta_geometry)
-
- print("Writing IECC climate zone data")
- _write_iecc_climate_zone_metadata_table(
- conn, iecc_climate_zone_metadata, geometry=iecc_climate_zone_geometry
- )
-
- print("Writing IECC moisture regime data")
- _write_iecc_moisture_regime_metadata_table(
- conn, iecc_moisture_regime_metadata, geometry=iecc_moisture_regime_geometry
- )
-
- print("Writing BA climate zone data")
- _write_ba_climate_zone_metadata_table(
- conn, ba_climate_zone_metadata, geometry=ba_climate_zone_geometry
- )
-
- print("Writing CA climate zone data")
- _write_ca_climate_zone_metadata_table(
- conn, ca_climate_zone_metadata, geometry=ca_climate_zone_geometry
- )
-
- print("Writing ISD station metadata")
- _write_isd_station_metadata_table(conn, isd_station_metadata)
-
- print("Writing ISD file metadata")
- _write_isd_file_metadata_table(conn, isd_file_metadata)
-
- print("Writing GHCNh inventory")
- _write_ghcn_inventory_table(conn, ghcn_inventory)
-
- print("Writing TMY3 station metadata")
- _write_tmy3_station_metadata_table(conn, tmy3_station_metadata)
-
- print("Writing CZ2010 station metadata")
- _write_cz2010_station_metadata_table(conn, cz2010_station_metadata)
-
- print("Cleaning up...")
- shutil.rmtree(download_path)
-
- print("\u2728 Completed! \u2728")
-
-
-def inspect_metadata_db():
- subprocess.call(["sqlite3", metadata_db_connection_proxy.db_path])
diff --git a/eeweather/exceptions.py b/eeweather/exceptions.py
index 0e0c6c6..2daf59f 100644
--- a/eeweather/exceptions.py
+++ b/eeweather/exceptions.py
@@ -1,136 +1,151 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+"""Exceptions and the warning type returned by data loads.
+These are data-dependent conditions pipelines legitimately branch on.
+Caller bugs (e.g. non-UTC datetimes) raise plain ValueError instead.
"""
class EEWeatherError(Exception):
"""Base class for exceptions in the eeweather package."""
- pass
+ def __init__(self, message):
+ super().__init__(message)
+ self.message = message
-class UnrecognizedUSAFIDError(EEWeatherError):
- """Raised when an unrecognized USAF station id is encountered.
+class UnrecognizedStationError(EEWeatherError):
+ """Raised when a station id is not in the registry.
Attributes
----------
value : str
- the value which is not a valid USAF ID
- message : str
- a message describing the error
+ the value which is not a recognized station id
"""
def __init__(self, value):
- self.value = value
- self.message = (
- 'The value "{}" was not recognized as a valid USAF weather station'
+ super().__init__(
+ 'The value "{}" was not recognized as a valid weather station'
" identifier.".format(value)
)
+ self.value = value
-class UnrecognizedZCTAError(EEWeatherError):
- """Raised when an unrecognized ZCTA is encountered.
+class UnrecognizedPlaceError(EEWeatherError):
+ """Raised when a place code is not in the registry.
Attributes
----------
- value : str
- the value which is not a valid ZCTA
- message : str
- a message describing the error
+ kind : str
+ the place kind, e.g. ``'zcta'``
+ code : str
+ the code which is not a recognized place of that kind
"""
- def __init__(self, value):
- self.value = value
- self.message = (
- 'The value "{}" was not recognized as a valid ZCTA identifier.'.format(
- value
- )
+ def __init__(self, kind, code):
+ super().__init__(
+ 'The value "{}" was not recognized as a valid place of kind'
+ ' "{}".'.format(code, kind)
)
+ self.kind = kind
+ self.code = code
-class DataNotAvailableError(EEWeatherError):
- """Raised when data is not available for a particular station and year.
+class AmbiguousIdentifierError(EEWeatherError):
+ """Raised when an external identifier maps to multiple stations and no
+ mapping is marked recent.
Attributes
----------
- usaf_id : str
- the USAF ID for which data does not exist.
- year : int
- the year for which data does not exist.
- message : str
- a message describing the error
+ namespace : str
+ the identifier system, e.g. ``'wban'``
+ external_id : str
+ the ambiguous identifier
+ station_ids : tuple of str
+ the registry stations the identifier maps to
"""
- def __init__(self, usaf_id, year):
- self.usaf_id = usaf_id
- self.year = year
- self.message = 'Data does not exist for station "{}" in year {}.'.format(
- usaf_id, year
+ def __init__(self, namespace, external_id, station_ids):
+ super().__init__(
+ 'The {} id "{}" maps to multiple stations ({}) and cannot be'
+ " resolved.".format(namespace, external_id, ", ".join(station_ids))
)
+ self.namespace = namespace
+ self.external_id = external_id
+ self.station_ids = tuple(station_ids)
+
+class DataNotAvailableError(EEWeatherError):
+ """Raised when a source has no data at all for a request.
-class TMY3DataNotAvailableError(EEWeatherError):
- """Raised when TMY3 data is not available for a particular station.
+ Partial coverage never raises; it surfaces as NaN values plus warnings.
Attributes
----------
- usaf_id : str
- the USAF ID for which TMY3 data does not exist.
- message : str
- a message describing the error
+ source : str
+ the source that had no data
+ station_id : str or None
+ the station requested; None for non-station sources
+ year : int or None
+ the year requested; None when the whole request had no data
"""
- def __init__(self, usaf_id):
- self.usaf_id = usaf_id
- self.message = 'TMY3 data does not exist for station "{}".'.format(usaf_id)
+ def __init__(self, source, *, station_id=None, year=None):
+ super().__init__(
+ "No {} data available for station={} year={}.".format(
+ source, station_id, year
+ )
+ )
+ self.source = source
+ self.station_id = station_id
+ self.year = year
-class CZ2010DataNotAvailableError(EEWeatherError):
- """Raised when CZ2010 data is not available for a particular station.
+class NoQualifiedStationError(EEWeatherError):
+ """Raised when no station in the registry qualifies to estimate weather
+ at a location under the configured filters.
Attributes
----------
- usaf_id : str
- the USAF ID for which CZ2010 data does not exist.
- message : str
- a message describing the error
+ latitude, longitude : float
+ the location that could not be served
"""
- def __init__(self, usaf_id):
- self.usaf_id = usaf_id
- self.message = 'CZ2010 data does not exist for station "{}".'.format(usaf_id)
+ def __init__(self, latitude, longitude):
+ super().__init__(
+ "No qualified station found for location ({}, {}).".format(
+ latitude, longitude
+ )
+ )
+ self.latitude = latitude
+ self.longitude = longitude
-class NonUTCTimezoneInfoError(EEWeatherError):
- """Raised when input start and end date aren't explicitly defined
- to have a UTC timezone.
+class EEWeatherWarning(object):
+ """A warning describing a data condition, returned alongside loaded data.
Attributes
----------
- usaf_id : str
- the USAF ID for which CZ2010 data does not exist.
- message : str
- a message describing the error
+ qualified_name : str
+ Qualified name, e.g. ``'eeweather.data_gap'``.
+ description : str
+ Prose describing the nature of the warning.
+ data : dict
+ Data that reproducibly shows why the warning was issued.
"""
- def __init__(self, this_date):
- self.message = (
- '"{}" does not have a UTC timezone. If using the datetime package, it should be'
- " in the format datetime(1,1,1,tzinfo=pytz.UTC).".format(this_date)
- )
+ def __init__(self, qualified_name, description, data):
+ self.qualified_name = qualified_name
+ self.description = description
+ self.data = data
+
+ def __repr__(self):
+ return "EEWeatherWarning(qualified_name={})".format(self.qualified_name)
+
+ def json(self):
+ serialized = {
+ "qualified_name": self.qualified_name,
+ "description": self.description,
+ "data": self.data,
+ }
+
+ return serialized
diff --git a/eeweather/geo.py b/eeweather/geo.py
deleted file mode 100644
index e212ee2..0000000
--- a/eeweather/geo.py
+++ /dev/null
@@ -1,234 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import json
-
-from .connections import metadata_db_connection_proxy
-from .exceptions import UnrecognizedUSAFIDError, UnrecognizedZCTAError
-
-from .utils import lazy_property
-from .validation import valid_zcta_or_raise
-
-
-__all__ = ("get_lat_long_climate_zones", "get_zcta_metadata", "zcta_to_lat_long")
-
-
-class CachedData(object):
- @lazy_property
- def climate_zone_geometry(self):
- try:
- from shapely.geometry import shape
- except ImportError: # pragma: no cover
- raise ImportError(
- "Matching by lat/lng within climate zone requires shapely"
- )
-
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- cur.execute(
- """
- select
- iecc_climate_zone, geometry
- from
- iecc_climate_zone_metadata
- """
- )
- iecc_climate_zones = [
- (cz_id, shape(json.loads(geometry))) for (cz_id, geometry) in cur.fetchall()
- ]
-
- cur.execute(
- """
- select
- iecc_moisture_regime, geometry
- from
- iecc_moisture_regime_metadata
- """
- )
- iecc_moisture_regimes = [
- (cz_id, shape(json.loads(geometry))) for (cz_id, geometry) in cur.fetchall()
- ]
-
- cur.execute(
- """
- select
- ba_climate_zone, geometry
- from
- ba_climate_zone_metadata
- """
- )
- ba_climate_zones = [
- (cz_id, shape(json.loads(geometry))) for (cz_id, geometry) in cur.fetchall()
- ]
-
- cur.execute(
- """
- select
- ca_climate_zone, geometry
- from
- ca_climate_zone_metadata
- """
- )
- ca_climate_zones = [
- (cz_id, shape(json.loads(geometry))) for (cz_id, geometry) in cur.fetchall()
- ]
-
- return (
- iecc_climate_zones,
- iecc_moisture_regimes,
- ba_climate_zones,
- ca_climate_zones,
- )
-
-
-cached_data = CachedData()
-
-
-def get_lat_long_climate_zones(latitude, longitude):
- """Get climate zones that contain lat/long coordinates.
-
- Parameters
- ----------
- latitude : float
- Latitude of point.
- longitude : float
- Longitude of point.
-
- Returns
- -------
- climate_zones: dict of str
- Region ids for each climate zone type.
- """
- try:
- from shapely.geometry import Point
- except ImportError: # pragma: no cover
- raise ImportError("Finding climate zone of lat/long points requires shapely.")
-
- (
- iecc_climate_zones,
- iecc_moisture_regimes,
- ba_climate_zones,
- ca_climate_zones,
- ) = cached_data.climate_zone_geometry
-
- point = Point(longitude, latitude) # x,y
- climate_zones = {}
- for iecc_climate_zone, shape in iecc_climate_zones:
- if shape.contains(point):
- climate_zones["iecc_climate_zone"] = iecc_climate_zone
- break
- else:
- climate_zones["iecc_climate_zone"] = None
-
- for iecc_moisture_regime, shape in iecc_moisture_regimes:
- if shape.contains(point):
- climate_zones["iecc_moisture_regime"] = iecc_moisture_regime
- break
- else:
- climate_zones["iecc_moisture_regime"] = None
-
- for ba_climate_zone, shape in ba_climate_zones:
- if shape.contains(point):
- climate_zones["ba_climate_zone"] = ba_climate_zone
- break
- else:
- climate_zones["ba_climate_zone"] = None
-
- for ca_climate_zone, shape in ca_climate_zones:
- if shape.contains(point):
- climate_zones["ca_climate_zone"] = ca_climate_zone
- break
- else:
- climate_zones["ca_climate_zone"] = None
-
- return climate_zones
-
-
-def get_zcta_metadata(zcta):
- """Get metadata about a ZIP Code Tabulation Area (ZCTA).
-
- Parameters
- ----------
- zcta : str
- ID of ZIP Code Tabulation Area
-
- Returns
- -------
- metadata : dict
- Dict of data about the ZCTA, including lat/long coordinates.
- """
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- *
- from
- zcta_metadata
- where
- zcta_id = ?
- """,
- (zcta,),
- )
- row = cur.fetchone()
- if row is None:
- raise UnrecognizedZCTAError(zcta)
- return {col[0]: row[i] for i, col in enumerate(cur.description)}
-
-
-def zcta_to_lat_long(zcta):
- """Get location of ZCTA centroid
-
- Retrieves latitude and longitude of centroid of ZCTA
- to use for matching with weather station.
-
- Parameters
- ----------
- zcta : str
- ID of the target ZCTA.
-
- Returns
- -------
- latitude : float
- Latitude of centroid of ZCTA.
- longitude : float
- Target Longitude of centroid of ZCTA.
- """
- valid_zcta_or_raise(zcta)
-
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- cur.execute(
- """
- select
- latitude
- , longitude
- from
- zcta_metadata
- where
- zcta_id = ?
- """,
- (zcta,),
- )
- # match existence checked in validate_zcta_or_raise(zcta)
- latitude, longitude = cur.fetchone()
-
- return float(latitude), float(longitude)
diff --git a/eeweather/location.py b/eeweather/location.py
new file mode 100644
index 0000000..3da9e5a
--- /dev/null
+++ b/eeweather/location.py
@@ -0,0 +1,303 @@
+"""Weather at a geographic location."""
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+import pandas as pd
+
+from .registry.summaries import get_place
+from .registry.zones import zones_at
+from .sources import StationSource
+from .sources.engine import known_source_names, resolve_source, _route, _validate_requested
+from .sources.matching import rank_stations
+
+
+
+__all__ = ("WeatherLocation",)
+
+
+def _normalize_sources(sources):
+ if isinstance(sources, str):
+ sources = (sources,)
+ normalized = []
+ for entry in sources:
+ if isinstance(entry, str):
+ resolved = resolve_source(entry)
+ if not hasattr(resolved, "estimate"):
+ resolved = StationSource(dataset=resolved)
+ entry = resolved
+ elif hasattr(entry, "fetch_year"):
+ entry = StationSource(dataset=entry)
+ normalized.append(entry)
+ names = [source.name for source in normalized]
+ if len(set(names)) != len(names):
+ raise ValueError(
+ "Duplicate source names in preference tuple: {}.".format(
+ ", ".join(names)
+ )
+ )
+
+ return tuple(normalized)
+
+
+class WeatherLocation(object):
+ """Weather at a latitude/longitude.
+
+ The location is the primary abstraction; weather is estimated by the
+ configured sources. Each requested variable routes to the first
+ source in the preference tuple that serves it; the default resolves
+ the point to the nearest suitable GHCNh station. After a load,
+ ``provenance`` records how each value was produced (which source,
+ which station or cell, how far away).
+
+ Parameters
+ ----------
+ latitude, longitude : float
+ sources : str, source object, or tuple of these
+ Ordered source preference. Strings name built-in sources; Feed
+ objects and configured sources (e.g. ``StationSource``) are used
+ as given.
+ pins : dict of str to str, optional
+ Station id to use per source name, bypassing matching. Pins are
+ captured automatically from provenance at load time, so once a
+ location has loaded data, later loads reuse the same station per
+ source; :meth:`to_dict`/:meth:`to_json` carry them across
+ processes for consistency between baseline and reporting
+ periods.
+ """
+
+ def __init__(
+ self, latitude: float, longitude: float, sources=("ghcnh",), pins=None
+ ):
+ self.latitude = latitude
+ self.longitude = longitude
+ self.pins = dict(pins or {})
+ self.sources = _normalize_sources(sources)
+ normals = [s.name for s in self.sources if getattr(s, "kind", None) == "normals"]
+ if normals:
+ raise ValueError(
+ "Normals sources are not preference sources: {}. Pin one"
+ " with load_data(source=...) instead.".format(", ".join(normals))
+ )
+
+ self.provenance = None
+
+ @classmethod
+ def from_place(cls, kind: str, code: str, sources=("ghcnh",)) -> "WeatherLocation":
+ """Construct a location from a coded place, e.g.
+ ``from_place("zcta", "91104")`` for a ZIP code tabulation area."""
+ place = get_place(kind, code)
+
+ return cls(place["latitude"], place["longitude"], sources=sources)
+
+ def to_dict(self) -> dict:
+ """The location's replay state: coordinates, source names, and
+ station pins. A location rebuilt with :meth:`from_dict` loads from
+ the same station per source. Source configuration (rank/select
+ kwargs) is not captured; pins bypass matching, so replay does not
+ depend on it.
+
+ To make replay faithful, any default :class:`StationSource`
+ without a captured pin is resolved and pinned here (registry-only,
+ no fetch) — so this mutates ``self.pins`` and, for a location with
+ no station within the distance cap, may raise
+ ``NoQualifiedStationError``. A source configured with
+ ``coverage_range`` or ``rating_period`` is pinned at load, not
+ here (resolving it depends on the request and may fetch), so
+ serializing one before any load raises ``ValueError``. Grid and
+ other non-station sources need no pin: coordinates plus source
+ name already round-trip deterministically.
+ """
+ unrebuildable = [
+ source.name for source in self.sources
+ if source.name not in known_source_names()
+ ]
+ if unrebuildable:
+ raise ValueError(
+ "Custom sources cannot be serialized: {}. Register them"
+ " with eeweather.sources.register to make them"
+ " nameable.".format(", ".join(unrebuildable))
+ )
+
+ self._pin_unloaded_station_sources()
+ state = {
+ "latitude": self.latitude,
+ "longitude": self.longitude,
+ "sources": [source.name for source in self.sources],
+ "pins": dict(self.pins),
+ }
+
+ return state
+
+ def _pin_unloaded_station_sources(self) -> None:
+ """Capture a station pin for each configured StationSource that
+ lacks one, so serialized replay resolves to the same station."""
+ for source in self.sources:
+ if not isinstance(source, StationSource):
+ continue
+ if source.name in self.pins:
+ continue
+ if source.period_dependent:
+ raise ValueError(
+ "Source {!r} is configured with coverage_range or"
+ " rating_period and has no captured pin; load once"
+ " before serializing it.".format(source.name)
+ )
+ station, _distance, _warnings = source.resolve(
+ self.latitude, self.longitude
+ )
+ self.pins[source.name] = station.id
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "WeatherLocation":
+ """Rebuild a location from :meth:`to_dict` output."""
+ return cls(
+ data["latitude"],
+ data["longitude"],
+ sources=tuple(data["sources"]),
+ pins=data.get("pins"),
+ )
+
+ def to_json(self) -> str:
+ """:meth:`to_dict` as a JSON string."""
+ return json.dumps(self.to_dict())
+
+ @classmethod
+ def from_json(cls, text: str) -> "WeatherLocation":
+ """Rebuild a location from :meth:`to_json` output."""
+ return cls.from_dict(json.loads(text))
+
+ def __repr__(self) -> str:
+ return "WeatherLocation({}, {})".format(self.latitude, self.longitude)
+
+ @property
+ def zones(self) -> dict[str, str | None]:
+ """The zone containing this location, by zone system."""
+ return zones_at(self.latitude, self.longitude)
+
+ def candidates(self, **filters) -> pd.DataFrame:
+ """Ranked candidate stations for this location.
+
+ Filters are those of
+ :func:`eeweather.sources.matching.rank_stations`: ``match_zones``,
+ ``match_subdivision`` (with ``site_subdivision``), ``has_sources``,
+ ``minimum_quality``, ``rating_period``, ``max_distance_meters``,
+ and ``max_difference_elevation_meters`` (with ``site_elevation``).
+ """
+ return rank_stations(self.latitude, self.longitude, **filters)
+
+ def _station_source(self, source=None):
+ if source is not None:
+ for candidate in self.sources:
+ if isinstance(candidate, StationSource) and (
+ candidate is source
+ or candidate.name == source
+ or candidate.adapter is source
+ ):
+ return candidate
+
+ return _normalize_sources((source,))[0]
+ for candidate in self.sources:
+ if isinstance(candidate, StationSource):
+ return candidate
+
+ raise ValueError("No station-backed source is configured.")
+
+ def _pinned_kwargs(self, estimation_source, load_kwargs):
+ """Load kwargs with this source's station pin applied."""
+ kwargs = dict(load_kwargs)
+ if isinstance(estimation_source, StationSource):
+ kwargs["station"] = self.pins.get(estimation_source.name)
+
+ return kwargs
+
+ def _capture_pins(self, provenance):
+ """First resolution wins: record each source's station so later
+ loads reuse it."""
+ for name, record in provenance.items():
+ if record.station_id is not None:
+ self.pins.setdefault(name, record.station_id)
+
+ def load_data(
+ self,
+ start: datetime,
+ end: datetime,
+ frequency="h",
+ variables=None,
+ source=None,
+ ignore_disqualification: bool = False,
+ **load_kwargs,
+ ):
+ """Weather at this location between two dates (inclusive).
+
+ Each requested variable routes to the first source in the
+ preference tuple that serves it; ``source=`` pins every variable
+ to one source instead (typical-year sources are only reachable
+ this way). Routed requests never raise for missing data (NaN
+ values plus warnings); a pinned request with nothing at all for
+ the dates raises DataNotAvailableError. Records provenance on
+ ``self.provenance`` and the frame's ``attrs["provenance"]``,
+ keyed by source name.
+
+ Returns
+ -------
+ tuple of (pandas.DataFrame, list of EEWeatherWarning)
+ """
+ if "sources" in load_kwargs:
+ raise TypeError(
+ "load_data() got an unexpected keyword argument 'sources'"
+ " (did you mean the constructor's sources=, or source= to"
+ " pin a single source for this load?)"
+ )
+ if source is not None:
+ estimation_source = self._station_source(source)
+ df, warnings, provenance = estimation_source.estimate(
+ self.latitude,
+ self.longitude,
+ start,
+ end,
+ frequency=frequency,
+ variables=variables,
+ raise_when_empty=True,
+ ignore_disqualification=ignore_disqualification,
+ **self._pinned_kwargs(estimation_source, load_kwargs),
+ )
+ df.attrs["provenance"] = provenance
+ self.provenance = provenance
+ self._capture_pins(provenance)
+
+ return df, warnings
+
+ # routed: the engine's routing groups variables by the first
+ # source serving them; typical-year sources never route
+ if variables is None:
+ variables = ("temperature",)
+ groups, requested = _route(variables, self.sources)
+ _validate_requested(requested)
+
+ frames = []
+ warnings = []
+ provenance = {}
+ for estimation_source, group_variables in groups.items():
+ df, group_warnings, group_provenance = estimation_source.estimate(
+ self.latitude,
+ self.longitude,
+ start,
+ end,
+ frequency=frequency,
+ variables=tuple(group_variables),
+ raise_when_empty=False,
+ ignore_disqualification=ignore_disqualification,
+ **self._pinned_kwargs(estimation_source, load_kwargs),
+ )
+ frames.append(df)
+ warnings.extend(group_warnings)
+ provenance.update(group_provenance)
+
+ df = pd.concat(frames, axis=1)[list(requested)]
+ df.attrs["provenance"] = provenance
+ self.provenance = provenance
+ self._capture_pins(provenance)
+
+ return df, warnings
diff --git a/eeweather/mockable.py b/eeweather/mockable.py
deleted file mode 100644
index 7a79d86..0000000
--- a/eeweather/mockable.py
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-"""
-Usage:
-
- # module.py
- import mockable
-
- @mockable.mockable
- def expensive_function():
- pass
-
- def function_a():
- mockable.expensive_function()
-
-
- # test_module.py
- import pytest
-
- @pytest.fixture
- def mock_expensive_function(monkeypatch):
- def cheap_func():
- pass
- monkeypatch.setattr(
- 'mockable.expensive_function',
- cheap_func
- )
-
- def test_function_a(mock_expensive_function):
- function_a()
-
-"""
-
-import sys
-
-
-def mockable():
- def decorator(func):
- setattr(sys.modules[__name__], func.__name__, func)
- return func
-
- return decorator
diff --git a/eeweather/py.typed b/eeweather/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/eeweather/ranking.py b/eeweather/ranking.py
deleted file mode 100644
index 533ddf1..0000000
--- a/eeweather/ranking.py
+++ /dev/null
@@ -1,461 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import pandas as pd
-import numpy as np
-import pyproj
-
-import eeweather.mockable
-from .exceptions import DataNotAvailableError
-from .connections import metadata_db_connection_proxy
-from .geo import get_lat_long_climate_zones
-from .stations import WeatherStation, get_station_qualities
-from .utils import lazy_property
-from .warnings import EEWeatherWarning
-
-__all__ = ("rank_stations", "combine_ranked_stations", "select_station")
-
-
-class CachedData(object):
- @lazy_property
- def all_station_metadata(self):
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- isd.usaf_id
- , isd.latitude
- , isd.longitude
- , isd.iecc_climate_zone
- , isd.iecc_moisture_regime
- , isd.ba_climate_zone
- , isd.ca_climate_zone
- , isd.quality as rough_quality
- , isd.elevation
- , isd.state
- , tmy3.class as tmy3_class
- , tmy3.usaf_id is not null as is_tmy3
- , cz2010.usaf_id is not null as is_cz2010
- from
- isd_station_metadata as isd
- left join cz2010_station_metadata as cz2010 on
- isd.usaf_id = cz2010.usaf_id
- left join tmy3_station_metadata as tmy3 on
- isd.usaf_id = tmy3.usaf_id
- order by
- isd.usaf_id
- """
- )
-
- df = pd.DataFrame(
- [
- {col[0]: val for col, val in zip(cur.description, row)}
- for row in cur.fetchall()
- ],
- columns=[
- "usaf_id",
- "latitude",
- "longitude",
- "iecc_climate_zone",
- "iecc_moisture_regime",
- "ba_climate_zone",
- "ca_climate_zone",
- "rough_quality",
- "elevation",
- "state",
- "tmy3_class",
- "is_tmy3",
- "is_cz2010",
- ],
- ).set_index("usaf_id")
-
- df["latitude"] = df.latitude.astype(float)
- df["longitude"] = df.longitude.astype(float)
- df["elevation"] = df.elevation.astype(float)
- df["is_tmy3"] = df.is_tmy3.astype(bool)
- df["is_cz2010"] = df.is_cz2010.astype(bool)
- return df
-
-
-cached_data = CachedData()
-
-
-def _combine_filters(filters, index):
- combined_filters = pd.Series(True, index=index)
- for f in filters:
- combined_filters &= f
- return combined_filters
-
-
-def rank_stations(
- site_latitude,
- site_longitude,
- site_state=None,
- site_elevation=None,
- match_iecc_climate_zone=False,
- match_iecc_moisture_regime=False,
- match_ba_climate_zone=False,
- match_ca_climate_zone=False,
- match_state=False,
- minimum_quality=None,
- rating_period=None,
- minimum_tmy3_class=None,
- max_distance_meters=None,
- max_difference_elevation_meters=None,
- is_tmy3=None,
- is_cz2010=None,
-):
- """Get a ranked, filtered set of candidate weather stations and metadata
- for a particular site.
-
- Parameters
- ----------
- site_latitude : float
- Latitude of target site for which to find candidate weather stations.
- site_longitude : float
- Longitude of target site for which to find candidate weather stations.
- site_state : str, 2 letter abbreviation
- US state of target site, used optionally to filter potential candidate
- weather stations. Ignored unless ``match_state=True``.
- site_elevation : float
- Elevation of target site in meters, used optionally to filter potential
- candidate weather stations. Ignored unless
- ``max_difference_elevation_meters`` is set.
- match_iecc_climate_zone : bool
- If ``True``, filter candidate weather stations to those
- matching the IECC climate zone of the target site.
- match_iecc_moisture_regime : bool
- If ``True``, filter candidate weather stations to those
- matching the IECC moisture regime of the target site.
- match_ca_climate_zone : bool
- If ``True``, filter candidate weather stations to those
- matching the CA climate zone of the target site.
- match_ba_climate_zone : bool
- If ``True``, filter candidate weather stations to those
- matching the Building America climate zone of the target site.
- match_state : bool
- If ``True``, filter candidate weather stations to those
- matching the US state of the target site, as specified by
- ``site_state=True``.
- rating_period : tuple of (datetime.datetime, datetime.datetime), optional
- When given, station quality is rated from GHCNh monthly
- observation counts over the five calendar years ending two years
- after the period's last date (sliding back to end no later than
- the last full year), and the ``rough_quality`` column and
- ``minimum_quality`` filter use that rating. When None, the rating
- covers the last five full years.
- minimum_quality : str, ``'high'``, ``'medium'``, ``'low'``
- If given, filter candidate weather stations to those meeting or
- exceeding the given quality, as summarized by the frequency and
- availability of observations in the NOAA Integrated Surface Database.
- minimum_tmy3_class : str, ``'I'``, ``'II'``, ``'III'``
- If given, filter candidate weather stations to those meeting or
- exceeding the given class, as reported in the NREL TMY3 metadata.
- max_distance_meters : float
- If given, filter candidate weather stations to those within the
- ``max_distance_meters`` of the target site location.
- max_difference_elevation_meters : float
- If given, filter candidate weather stations to those with elevations
- within ``max_difference_elevation_meters`` of the target site elevation.
- is_tmy3 : bool
- If given, filter candidate weather stations to those for which TMY3
- normal year temperature data is available.
- is_cz2010 : bool
- If given, filter candidate weather stations to those for which CZ2010
- normal year temperature data is available.
-
- Returns
- -------
- ranked_filtered_candidates : :any:`pandas.DataFrame`
- Index is ``usaf_id``. Each row contains a potential weather station
- match and metadata. Contains the following columns:
-
- - ``rank``: Rank of weather station match for the target site.
- - ``distance_meters``: Distance from target site to weather station site.
- - ``latitude``: Latitude of weather station site.
- - ``longitude``: Longitude of weather station site.
- - ``iecc_climate_zone``: IECC Climate Zone ID (1-8)
- - ``iecc_moisture_regime``: IECC Moisture Regime ID (A-C)
- - ``ba_climate_zone``: Building America climate zone name
- - ``ca_climate_zone``: Califoria climate zone number
- - ``rough_quality``: Approximate measure of frequency of GHCNh
- observations data at weather station.
- - ``elevation``: Elevation of weather station site, if available.
- - ``state``: US state of weather station site, if applicable.
- - ``tmy3_class``: Weather station class as reported by NREL TMY3, if
- available
- - ``is_tmy3``: Weather station has associated TMY3 data.
- - ``is_cz2010``: Weather station has associated CZ2010 data.
- - ``difference_elevation_meters``: Absolute difference in meters
- between target site elevation and weather station elevation, if
- available.
-
- """
- candidates = cached_data.all_station_metadata.copy()
-
- # compute distances
- candidates_defined_lat_long = candidates[
- candidates.latitude.notnull() & candidates.longitude.notnull()
- ]
- candidates_latitude = candidates_defined_lat_long.latitude
- candidates_longitude = candidates_defined_lat_long.longitude
- tiled_site_latitude = np.tile(site_latitude, candidates_latitude.shape)
- tiled_site_longitude = np.tile(site_longitude, candidates_longitude.shape)
- geod = pyproj.Geod(ellps="WGS84")
- dists = geod.inv(
- tiled_site_longitude,
- tiled_site_latitude,
- candidates_longitude.values,
- candidates_latitude.values,
- )[2]
- distance_meters = pd.Series(dists, index=candidates_defined_lat_long.index).reindex(
- candidates.index
- )
- candidates["distance_meters"] = distance_meters
-
- if site_elevation is not None:
- difference_elevation_meters = (candidates.elevation - site_elevation).abs()
- else:
- difference_elevation_meters = None
- candidates["difference_elevation_meters"] = difference_elevation_meters
-
- site_climate_zones = get_lat_long_climate_zones(site_latitude, site_longitude)
- site_iecc_climate_zone = site_climate_zones["iecc_climate_zone"]
- site_iecc_moisture_regime = site_climate_zones["iecc_moisture_regime"]
- site_ca_climate_zone = site_climate_zones["ca_climate_zone"]
- site_ba_climate_zone = site_climate_zones["ba_climate_zone"]
-
- # create filters
- filters = []
-
- if match_iecc_climate_zone:
- if site_iecc_climate_zone is None:
- filters.append(candidates.iecc_climate_zone.isnull())
- else:
- filters.append(candidates.iecc_climate_zone == site_iecc_climate_zone)
- if match_iecc_moisture_regime:
- if site_iecc_moisture_regime is None:
- filters.append(candidates.iecc_moisture_regime.isnull())
- else:
- filters.append(candidates.iecc_moisture_regime == site_iecc_moisture_regime)
- if match_ba_climate_zone:
- if site_ba_climate_zone is None:
- filters.append(candidates.ba_climate_zone.isnull())
- else:
- filters.append(candidates.ba_climate_zone == site_ba_climate_zone)
- if match_ca_climate_zone:
- if site_ca_climate_zone is None:
- filters.append(candidates.ca_climate_zone.isnull())
- else:
- filters.append(candidates.ca_climate_zone == site_ca_climate_zone)
-
- if match_state:
- if site_state is None:
- filters.append(candidates.state.isnull())
- else:
- filters.append(candidates.state == site_state)
-
- if is_tmy3 is not None:
- filters.append(candidates.is_tmy3.isin([is_tmy3]))
- if is_cz2010 is not None:
- filters.append(candidates.is_cz2010.isin([is_cz2010]))
-
- if rating_period is not None:
- start, end = rating_period
- period_qualities = get_station_qualities(start, end)
- candidates["rough_quality"] = period_qualities.reindex(
- candidates.index, fill_value="low"
- )
-
- if minimum_quality == "low":
- filters.append(candidates.rough_quality.isin(["high", "medium", "low"]))
- elif minimum_quality == "medium":
- filters.append(candidates.rough_quality.isin(["high", "medium"]))
- elif minimum_quality == "high":
- filters.append(candidates.rough_quality.isin(["high"]))
-
- if minimum_tmy3_class == "III":
- filters.append(candidates.tmy3_class.isin(["I", "II", "III"]))
- elif minimum_tmy3_class == "II":
- filters.append(candidates.tmy3_class.isin(["I", "II"]))
- elif minimum_tmy3_class == "I":
- filters.append(candidates.tmy3_class.isin(["I"]))
-
- if max_distance_meters is not None:
- filters.append(candidates.distance_meters <= max_distance_meters)
-
- if max_difference_elevation_meters is not None and site_elevation is not None:
- filters.append(
- candidates.difference_elevation_meters <= max_difference_elevation_meters
- )
-
- combined_filters = _combine_filters(filters, candidates.index)
- filtered_candidates = candidates[combined_filters]
- ranked_filtered_candidates = filtered_candidates.sort_values(by=["distance_meters"])
-
- # add rank column
- ranks = range(1, 1 + len(ranked_filtered_candidates))
- ranked_filtered_candidates.insert(0, "rank", ranks)
-
- return ranked_filtered_candidates[
- [
- "rank",
- "distance_meters",
- "latitude",
- "longitude",
- "iecc_climate_zone",
- "iecc_moisture_regime",
- "ba_climate_zone",
- "ca_climate_zone",
- "rough_quality",
- "elevation",
- "state",
- "tmy3_class",
- "is_tmy3",
- "is_cz2010",
- "difference_elevation_meters",
- ]
- ]
-
-
-def combine_ranked_stations(rankings):
- """Combine :any:`pandas.DataFrame` s of candidate weather stations to form
- a hybrid ranking dataframe.
-
- Parameters
- ----------
- rankings : list of :any:`pandas.DataFrame`
- Dataframes of ranked weather station candidates and metadata.
- All ranking dataframes should have the same columns and must be
- sorted by rank.
-
- Returns
- -------
- ranked_filtered_candidates : :any:`pandas.DataFrame`
- Dataframe has a rank column and the same columns given in the source
- dataframes.
- """
-
- if len(rankings) == 0:
- raise ValueError("Requires at least one ranking.")
-
- combined_ranking = rankings[0]
- for ranking in rankings[1:]:
- filtered_ranking = ranking[~ranking.index.isin(combined_ranking.index)]
- combined_ranking = pd.concat([combined_ranking, filtered_ranking])
-
- combined_ranking["rank"] = range(1, 1 + len(combined_ranking))
- return combined_ranking
-
-
-@eeweather.mockable.mockable()
-def load_hourly_temp_data(
- station, start_date, end_date, fetch_from_web
-): # pragma: no cover
- df, warnings = station.load_data(
- start_date, end_date, fetch_from_web=fetch_from_web,
- error_on_missing_years=False,
- )
-
- return df["temperature"], warnings
-
-
-def select_station(
- candidates,
- coverage_range=None,
- min_fraction_coverage=0.9,
- distance_warnings=(50000, 200000),
- rank=1,
- fetch_from_web=True,
-):
- """Select a station from a list of candidates that meets given data
- quality criteria.
-
- Parameters
- ----------
- candidates : :any:`pandas.DataFrame`
- A dataframe of the form given by :any:`eeweather.rank_stations` or
- :any:`eeweather.combine_ranked_stations`, specifically having at least
- an index with ``usaf_id`` values and the column ``distance_meters``.
-
- Returns
- -------
- station, warnings : tuple of (:any:`eeweather.WeatherStation`, list of str)
- A qualified weather station. ``None`` if no station meets criteria.
- """
-
- def _test_station(station):
- if coverage_range is None:
- return True, []
- else:
- start_date, end_date = coverage_range
- try:
- tempC, warnings = eeweather.mockable.load_hourly_temp_data(
- station, start_date, end_date, fetch_from_web
- )
- except DataNotAvailableError:
- return False, [] # reject
-
- # TODO(philngo): also need to incorporate within-day limits
- if len(tempC) > 0:
- fraction_coverage = tempC.notnull().sum() / float(len(tempC))
- return (fraction_coverage > min_fraction_coverage), warnings
- else:
- return False, [] # reject
-
- def _station_warnings(station, distance_meters):
- return [
- EEWeatherWarning(
- qualified_name="eeweather.exceeds_maximum_distance",
- description=(
- "Distance from target to weather station is greater"
- "than the specified km."
- ),
- data={
- "distance_meters": distance_meters,
- "max_distance_meters": d,
- "rank": rank,
- },
- )
- for d in distance_warnings
- if distance_meters > d
- ]
-
- n_stations_passed = 0
- for usaf_id, row in candidates.iterrows():
- station = WeatherStation(usaf_id)
- test_result, warnings = _test_station(station)
- if test_result:
- n_stations_passed += 1
- if n_stations_passed == rank:
- if not warnings:
- warnings = []
- warnings.extend(_station_warnings(station, row.distance_meters))
- return station, warnings
-
- no_station_warning = EEWeatherWarning(
- qualified_name="eeweather.no_weather_station_selected",
- description=(
- "No weather station found with the specified rank and"
- " minimum fracitional coverage."
- ),
- data={"rank": rank, "min_fraction_coverage": min_fraction_coverage},
- )
- return None, [no_station_warning]
diff --git a/eeweather/registry/__init__.py b/eeweather/registry/__init__.py
new file mode 100644
index 0000000..76324be
--- /dev/null
+++ b/eeweather/registry/__init__.py
@@ -0,0 +1 @@
+"""The packaged station and place registry."""
diff --git a/eeweather/registry/db.py b/eeweather/registry/db.py
new file mode 100644
index 0000000..276d8ab
--- /dev/null
+++ b/eeweather/registry/db.py
@@ -0,0 +1,174 @@
+"""Access to the packaged data files.
+
+Every connection opens the identifier crosswalk (identifiers.db) and
+ATTACHes the regional geography packs (geography_*.db) plus the data
+files source packages register at import, so identity, geography, fact,
+availability, and quality queries can join across files. Connections are
+cached per thread and reused; callers do not close them.
+"""
+import glob
+import os
+import sqlite3
+import threading
+from collections import namedtuple
+
+import platformdirs
+
+from ..exceptions import UnrecognizedStationError, UnrecognizedPlaceError
+
+
+
+_REGISTRY_DIR = os.path.dirname(os.path.abspath(__file__))
+_SOURCES_DIR = os.path.join(os.path.dirname(_REGISTRY_DIR), "sources")
+
+UPDATED_DATA_DIR = os.path.join(platformdirs.user_data_dir("eeweather"), "registry")
+
+# an update writes this marker only after both files swap in, so its
+# presence certifies a complete (non-torn) updated pair
+COMMIT_MARKER = ".committed"
+
+
+def data_path(packaged_path):
+ """The live path for a packaged data file: the downloaded update in
+ the user data directory when a completed update is present, else the
+ packaged copy. The updated copy is used only alongside the commit
+ marker that certifies the swap finished, so a torn pair is ignored.
+ Resolved at import, so an update applies to new processes."""
+ updated = os.path.join(UPDATED_DATA_DIR, os.path.basename(packaged_path))
+ marker = os.path.join(UPDATED_DATA_DIR, COMMIT_MARKER)
+ if os.path.exists(marker) and os.path.exists(updated):
+ return updated
+
+ return packaged_path
+
+
+PACKAGED_IDENTIFIERS_DB_PATH = os.path.join(_REGISTRY_DIR, "identifiers.db")
+PACKAGED_GHCNH_DB_PATH = os.path.join(_SOURCES_DIR, "ghcnh", "ghcnh.db")
+
+IDENTIFIERS_DB_PATH = data_path(PACKAGED_IDENTIFIERS_DB_PATH)
+GHCNH_DB_PATH = data_path(PACKAGED_GHCNH_DB_PATH)
+
+MONTH_COLUMNS = (
+ "jan", "feb", "mar", "apr", "may", "jun",
+ "jul", "aug", "sep", "oct", "nov", "dec",
+)
+
+Attachment = namedtuple(
+ "Attachment", ["alias", "path", "catalog", "inventory", "quality", "availability"]
+)
+
+
+def _geography_packs():
+ packs = []
+ for path in sorted(glob.glob(os.path.join(_REGISTRY_DIR, "geography_*.db"))):
+ alias = os.path.splitext(os.path.basename(path))[0]
+ packs.append((alias, path))
+
+ return packs
+
+
+class MetadataDBConnectionProxy(object):
+ def __init__(self):
+ self.db_path = IDENTIFIERS_DB_PATH
+ self._local = threading.local()
+ self._attachments = []
+ self.geography_aliases = [alias for alias, _ in _geography_packs()]
+
+ def register_attachment(
+ self, alias, path,
+ catalog=False, inventory=False, quality=False, availability=False,
+ ):
+ """Register a source database to attach to every connection.
+
+ ``catalog`` marks a station catalog (facts and zone assignments);
+ ``inventory``/``quality`` mark observation-count and rating
+ tables; ``availability`` marks an archive station list. Sources
+ register at import, before any connection is made.
+ """
+ if any(a.alias == alias for a in self._attachments):
+ return
+ self._attachments.append(
+ Attachment(alias, path, catalog, inventory, quality, availability)
+ )
+
+ @property
+ def catalogs(self):
+ return [a.alias for a in self._attachments if a.catalog]
+
+ @property
+ def inventory_sources(self):
+ return [a.alias for a in self._attachments if a.inventory]
+
+ @property
+ def quality_sources(self):
+ return [a.alias for a in self._attachments if a.quality]
+
+ @property
+ def availability_sources(self):
+ return [a.alias for a in self._attachments if a.availability]
+
+ def get_connection(self):
+ connection = getattr(self._local, "connection", None)
+ if connection is None:
+ connection = sqlite3.connect(self.db_path)
+ for alias, path in _geography_packs():
+ connection.execute(
+ "attach database ? as {}".format(alias), (path,)
+ )
+ self._local.connection = connection
+ self._local.attached = set()
+ # attach any source registered since this thread's connection was
+ # made, so late registration (e.g. importing a custom source
+ # after a first query) is not silently missing
+ for attachment in self._attachments:
+ if attachment.alias not in self._local.attached:
+ connection.execute(
+ "attach database ? as {}".format(attachment.alias),
+ (attachment.path,),
+ )
+ self._local.attached.add(attachment.alias)
+
+ return connection
+
+ def close(self):
+ """Close and drop this thread's cached connection, if any; the next
+ get_connection opens a fresh one. Attachments are re-applied then."""
+ connection = getattr(self._local, "connection", None)
+ if connection is not None:
+ connection.close()
+ self._local.connection = None
+ self._local.attached = set()
+
+
+metadata_db_connection_proxy = MetadataDBConnectionProxy()
+
+
+def valid_station_id_or_raise(station_id):
+ """Raise UnrecognizedStationError unless the id is in the registry."""
+ conn = metadata_db_connection_proxy.get_connection()
+ row = conn.execute(
+ "select exists (select 1 from station_identifier where station_id = ?)",
+ (station_id,),
+ ).fetchone()
+ if not row[0]:
+ raise UnrecognizedStationError(station_id)
+
+ return True
+
+
+def valid_place_or_raise(kind, code):
+ """Raise UnrecognizedPlaceError unless the place code is in a geography
+ pack."""
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ for alias in proxy.geography_aliases:
+ row = conn.execute(
+ "select exists (select 1 from {}.place where kind = ? and code = ?)".format(
+ alias
+ ),
+ (kind, code),
+ ).fetchone()
+ if row[0]:
+ return True
+
+ raise UnrecognizedPlaceError(kind, code)
diff --git a/eeweather/registry/geography_us.db b/eeweather/registry/geography_us.db
new file mode 100644
index 0000000..a68ff85
Binary files /dev/null and b/eeweather/registry/geography_us.db differ
diff --git a/eeweather/registry/identifiers.db b/eeweather/registry/identifiers.db
new file mode 100644
index 0000000..ccb5957
Binary files /dev/null and b/eeweather/registry/identifiers.db differ
diff --git a/eeweather/registry/identifiers.py b/eeweather/registry/identifiers.py
new file mode 100644
index 0000000..f76b96f
--- /dev/null
+++ b/eeweather/registry/identifiers.py
@@ -0,0 +1,86 @@
+"""External identifier translation over the station_identifier table."""
+from ..exceptions import AmbiguousIdentifierError, UnrecognizedStationError
+from .db import metadata_db_connection_proxy
+
+
+
+def translate(ids, from_namespace, to_namespace):
+ """Translate external ids between identifier systems.
+
+ Parameters
+ ----------
+ ids : iterable of str
+ External ids in ``from_namespace``. Ids with no mapping are absent
+ from the result.
+ from_namespace, to_namespace : str
+ Identifier systems, e.g. ``'usaf'``, ``'ghcn'``, ``'wban'``,
+ ``'icao'``.
+
+ Returns
+ -------
+ dict of str to tuple of str
+ Target ids keyed by input id. Values are always tuples; mappings
+ can be one-to-many in either direction.
+ """
+ ids = list(dict.fromkeys(ids))
+ conn = metadata_db_connection_proxy.get_connection()
+ placeholders = ", ".join("?" for _ in ids)
+ rows = conn.execute(
+ """
+ select src.external_id, dst.external_id
+ from station_identifier as src
+ join station_identifier as dst on src.station_id = dst.station_id
+ where src.namespace = ? and dst.namespace = ?
+ and src.external_id in ({})
+ order by src.external_id, dst.external_id
+ """.format(placeholders),
+ [from_namespace, to_namespace] + ids,
+ ).fetchall()
+
+ mapping = {}
+ for source_id, target_id in rows:
+ mapping.setdefault(source_id, [])
+ if target_id not in mapping[source_id]:
+ mapping[source_id].append(target_id)
+ mapping = {key: tuple(values) for key, values in mapping.items()}
+
+ return mapping
+
+
+_NORMALIZERS = {
+ "icao": lambda v: v.strip().upper(),
+ "wban": lambda v: v.strip().zfill(5),
+ "usaf": lambda v: v.strip().zfill(6),
+ "wmo": lambda v: v.strip().zfill(5),
+}
+
+
+def resolve_station(namespace, external_id):
+ """The registry station id an external id maps to.
+
+ Ids are normalized to the registry's uniform forms (icao uppercased;
+ wban/wmo zero-padded to 5, usaf to 6). When the id maps to several
+ stations, the mapping marked recent wins; with no recent mapping,
+ AmbiguousIdentifierError is raised.
+ """
+ if namespace in _NORMALIZERS:
+ external_id = _NORMALIZERS[namespace](external_id)
+ conn = metadata_db_connection_proxy.get_connection()
+ rows = conn.execute(
+ "select station_id, recent from station_identifier"
+ " where namespace = ? and external_id = ?"
+ " order by station_id",
+ (namespace, external_id),
+ ).fetchall()
+ if not rows:
+ raise UnrecognizedStationError(external_id)
+ if len(rows) == 1:
+ return rows[0][0]
+
+ recent = [station_id for station_id, is_recent in rows if is_recent]
+ if len(recent) == 1:
+ return recent[0]
+
+ raise AmbiguousIdentifierError(
+ namespace, external_id, [station_id for station_id, _ in rows]
+ )
diff --git a/eeweather/registry/metadata.py b/eeweather/registry/metadata.py
new file mode 100644
index 0000000..331d94b
--- /dev/null
+++ b/eeweather/registry/metadata.py
@@ -0,0 +1,72 @@
+"""Station metadata assembled from the crosswalk and source catalogs."""
+from ..exceptions import UnrecognizedStationError
+from .db import metadata_db_connection_proxy
+
+
+
+def get_station_metadata(station_id):
+ """Registry metadata for a station.
+
+ Identity comes from the crosswalk; facts (name, coordinates,
+ elevation, country, subdivision), zone assignments, and the quality
+ rating come from the first registered source catalog that lists the
+ station; first/last inventory years come from every source that
+ keeps an observation inventory.
+ """
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+
+ ids = {}
+ for namespace, external_id in conn.execute(
+ "select namespace, external_id from station_identifier"
+ " where station_id = ? order by namespace, external_id",
+ (station_id,),
+ ):
+ ids.setdefault(namespace, []).append(external_id)
+ if not ids:
+ raise UnrecognizedStationError(station_id)
+
+ metadata = {"station_id": station_id, "ids": ids}
+ for alias in proxy.catalogs:
+ cur = conn.cursor()
+ cur.execute(
+ "select * from {}.stations where station_id = ?".format(alias),
+ (station_id,),
+ )
+ row = cur.fetchone()
+ if row is None:
+ continue
+ for column, value in zip(cur.description, row):
+ metadata.setdefault(column[0], value)
+ metadata.setdefault(
+ "zones",
+ dict(
+ conn.execute(
+ "select system, zone_id from {}.station_zone"
+ " where station_id = ?".format(alias),
+ (station_id,),
+ ).fetchall()
+ ),
+ )
+ metadata.setdefault("zones", {})
+
+ for alias in proxy.quality_sources:
+ row = conn.execute(
+ "select quality from {}.quality where station_id = ?".format(alias),
+ (station_id,),
+ ).fetchone()
+ if row is not None:
+ metadata.setdefault("quality", row[0])
+ metadata.setdefault("quality", None)
+
+ metadata["inventory_years"] = {}
+ for alias in proxy.inventory_sources:
+ row = conn.execute(
+ "select min(year), max(year) from {}.inventory"
+ " where station_id = ?".format(alias),
+ (station_id,),
+ ).fetchone()
+ if row[0] is not None:
+ metadata["inventory_years"][alias] = (row[0], row[1])
+
+ return metadata
diff --git a/eeweather/registry/quality.py b/eeweather/registry/quality.py
new file mode 100644
index 0000000..8d4f070
--- /dev/null
+++ b/eeweather/registry/quality.py
@@ -0,0 +1,113 @@
+"""Station quality ratings from per-source observation inventory."""
+from datetime import datetime, timezone
+
+import pandas as pd
+
+from .db import MONTH_COLUMNS, metadata_db_connection_proxy
+
+
+
+QUALITY_WINDOW_YEARS = 5
+
+# every month of the rating window above these observation counts
+HIGH_MONTHLY_OBSERVATIONS = 600
+MEDIUM_MONTHLY_OBSERVATIONS = 360
+
+
+def _quality_rating_window(anchor):
+ """Calendar years rating a request: five years ending two years after
+ the anchor date, sliding back to end no later than the last full
+ year."""
+ last_full_year = datetime.now(timezone.utc).year - 1
+ window_end = min(anchor.year + 2, last_full_year)
+ window_start = window_end - (QUALITY_WINDOW_YEARS - 1)
+
+ return window_start, window_end
+
+
+def _quality_from_minimum(minimum):
+ if minimum > HIGH_MONTHLY_OBSERVATIONS:
+ return "high"
+ elif minimum > MEDIUM_MONTHLY_OBSERVATIONS:
+ return "medium"
+
+ return "low"
+
+
+def _inventory_source_or_raise(source):
+ proxy = metadata_db_connection_proxy
+ if source is None:
+ return proxy.inventory_sources[0]
+ if source not in proxy.inventory_sources:
+ raise ValueError(
+ "Unknown inventory source: '{}'. Sources with inventory:"
+ " {}.".format(source, ", ".join(proxy.inventory_sources))
+ )
+
+ return source
+
+
+def get_station_quality(station_id, anchor, source=None):
+ """Station quality anchored to a date, from a source's observation
+ counts.
+
+ Rates the five calendar years ending two years after the anchor date
+ (sliding back so the window ends no later than the last full year):
+ every month over 600 observations is high, over 360 is medium;
+ anything less, including absent months or years, is low.
+ """
+ source = _inventory_source_or_raise(source)
+ window_start, window_end = _quality_rating_window(anchor)
+ conn = metadata_db_connection_proxy.get_connection()
+ cur = conn.cursor()
+ cur.execute(
+ """
+ select year, {}
+ from {}.inventory
+ where station_id = ? and year between ? and ?
+ """.format(", ".join(MONTH_COLUMNS), source),
+ (station_id, window_start, window_end),
+ )
+ counts_by_year = {row[0]: row[1:] for row in cur.fetchall()}
+
+ minimum = None
+ for year in range(window_start, window_end + 1):
+ year_counts = counts_by_year.get(year, (0,) * 12)
+ year_minimum = min(year_counts)
+ if minimum is None or year_minimum < minimum:
+ minimum = year_minimum
+
+ return _quality_from_minimum(minimum)
+
+
+def get_station_qualities(anchor, source=None):
+ """Quality anchored to a date for every station, as a
+ station_id-indexed Series. Same rating as get_station_quality, computed
+ for the whole registry in one query."""
+ source = _inventory_source_or_raise(source)
+ window_start, window_end = _quality_rating_window(anchor)
+ conn = metadata_db_connection_proxy.get_connection()
+ inventory = pd.read_sql_query(
+ """
+ select station_id, year, {}
+ from {}.inventory
+ where year between ? and ?
+ """.format(", ".join(MONTH_COLUMNS), source),
+ conn,
+ params=(window_start, window_end),
+ )
+
+ months = list(MONTH_COLUMNS)
+ year_min = inventory[months].min(axis=1)
+ observed_min = year_min.groupby(inventory.station_id).min()
+
+ # a station must have a row for every year of the window
+ n_years = window_end - window_start + 1
+ year_counts = inventory.groupby("station_id").year.nunique()
+ observed_min = observed_min.where(year_counts >= n_years, 0)
+
+ qualities = pd.Series("low", index=observed_min.index)
+ qualities[observed_min > MEDIUM_MONTHLY_OBSERVATIONS] = "medium"
+ qualities[observed_min > HIGH_MONTHLY_OBSERVATIONS] = "high"
+
+ return qualities
diff --git a/eeweather/registry/summaries.py b/eeweather/registry/summaries.py
new file mode 100644
index 0000000..843fdeb
--- /dev/null
+++ b/eeweather/registry/summaries.py
@@ -0,0 +1,175 @@
+"""Registry enumeration and place lookups."""
+import pandas as pd
+
+from ..exceptions import UnrecognizedPlaceError
+from .db import metadata_db_connection_proxy
+
+
+
+def get_station_ids(state=None):
+ """Registry ids of all stations, optionally filtered by subdivision.
+
+ Stations are enumerated from the registered source catalogs.
+ """
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ station_ids = set()
+ for alias in proxy.catalogs:
+ if state is None:
+ cur = conn.execute(
+ "select station_id from {}.stations".format(alias)
+ )
+ else:
+ cur = conn.execute(
+ "select station_id from {}.stations where subdivision = ?".format(
+ alias
+ ),
+ (state,),
+ )
+ station_ids.update(row[0] for row in cur.fetchall())
+
+ return sorted(station_ids)
+
+
+def get_zcta_ids(state=None):
+ """Codes of all ZCTA places, optionally filtered by subdivision."""
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ zcta_ids = set()
+ for alias in proxy.geography_aliases:
+ if state is None:
+ cur = conn.execute(
+ "select code from {}.place where kind = 'zcta'".format(alias)
+ )
+ else:
+ cur = conn.execute(
+ "select code from {}.place where kind = 'zcta'"
+ " and subdivision = ?".format(alias),
+ (state,),
+ )
+ zcta_ids.update(row[0] for row in cur.fetchall())
+
+ return sorted(zcta_ids)
+
+
+def get_place(kind, code):
+ """Registry metadata for a place: the place row fields plus ``zones``."""
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ for alias in proxy.geography_aliases:
+ cur = conn.cursor()
+ cur.execute(
+ "select * from {}.place where kind = ? and code = ?".format(alias),
+ (kind, code),
+ )
+ row = cur.fetchone()
+ if row is None:
+ continue
+ place = {col[0]: row[i] for i, col in enumerate(cur.description)}
+ place["zones"] = dict(
+ conn.execute(
+ "select system, zone_id from {}.place_zone"
+ " where kind = ? and code = ?".format(alias),
+ (kind, code),
+ ).fetchall()
+ )
+
+ return place
+
+ raise UnrecognizedPlaceError(kind, code)
+
+
+def search_stations(country=None, subdivision=None, has_sources=()):
+ """Registry stations as a DataFrame indexed by station id.
+
+ One row per station from the registered source catalogs (first
+ catalog listing a station wins): name, latitude, longitude,
+ elevation, country, subdivision, quality, one column per zone
+ system, and per-source availability flags.
+ """
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+
+ frames = []
+ for alias in proxy.catalogs:
+ frames.append(_catalog_frame(conn, alias, proxy))
+ df = pd.concat(frames)
+ df = df[~df.index.duplicated(keep="first")].sort_index()
+
+ if country is not None:
+ df = df[df.country == country]
+ if subdivision is not None:
+ df = df[df.subdivision == subdivision]
+ for source in has_sources:
+ if source in proxy.catalogs:
+ continue
+ column = "is_{}".format(source)
+ if column not in df.columns:
+ raise ValueError("Unknown source: {}".format(source))
+ df = df[df[column]]
+
+ return df
+
+
+def _catalog_frame(conn, alias, proxy):
+ availability_selects = "".join(
+ """
+ , max({avail}.station_id) is not null as is_{avail}""".format(avail=avail)
+ for avail in proxy.availability_sources
+ )
+ availability_joins = "".join(
+ """
+ left join {avail}.stations as {avail} on
+ s.station_id = {avail}.station_id""".format(avail=avail)
+ for avail in proxy.availability_sources
+ )
+ quality_select = ", null as quality"
+ quality_join = ""
+ if alias in proxy.quality_sources:
+ quality_select = ", max(q.quality) as quality"
+ quality_join = (
+ "\n left join {alias}.quality as q on"
+ " s.station_id = q.station_id".format(alias=alias)
+ )
+
+ df = pd.read_sql_query(
+ """
+ select
+ s.station_id
+ , s.name
+ , s.latitude
+ , s.longitude
+ , s.elevation
+ , s.country
+ , s.subdivision
+ {quality_select}
+ , max(case when z.system = 'iecc_climate_zone' then z.zone_id end)
+ as iecc_climate_zone
+ , max(case when z.system = 'iecc_moisture_regime' then z.zone_id end)
+ as iecc_moisture_regime
+ , max(case when z.system = 'ba_climate_zone' then z.zone_id end)
+ as ba_climate_zone
+ , max(case when z.system = 'ca_climate_zone' then z.zone_id end)
+ as ca_climate_zone
+ {availability_selects}
+ from
+ {alias}.stations as s
+ left join {alias}.station_zone as z on s.station_id = z.station_id
+ {quality_join}
+ {availability_joins}
+ group by s.station_id
+ order by s.station_id
+ """.format(
+ alias=alias,
+ quality_select=quality_select,
+ quality_join=quality_join,
+ availability_selects=availability_selects,
+ availability_joins=availability_joins,
+ ),
+ conn,
+ ).set_index("station_id")
+ for avail in proxy.availability_sources:
+ column = "is_{}".format(avail)
+ df[column] = df[column].astype(bool)
+
+ return df
diff --git a/eeweather/registry/update.py b/eeweather/registry/update.py
new file mode 100644
index 0000000..9b497c3
--- /dev/null
+++ b/eeweather/registry/update.py
@@ -0,0 +1,361 @@
+"""Keep the live registry data current without package releases.
+
+The live packaged data (GHCNh station catalog, observation inventory,
+quality ratings, identifier aliases) is updated into the platform user
+data directory, where it takes precedence over the wheel's copies in new
+processes. Updates prefer the repository's rolling-release channel — the
+scheduled refresh publishes ready-made files served from GitHub's CDN,
+so any number of clients updating costs NOAA nothing and no client
+rebuilds locally. When the channel is unreachable, stale, or no newer
+than the local data, the update falls back to rebuilding from the live
+NOAA files directly, so updates keep working even if the repository goes
+dormant. Static content (geography packs, archive station lists) never
+changes and is not part of the update. Implausible files from either
+channel abort an update, leaving the previous data in place.
+
+Updates run automatically: loading data or ranking stations starts a
+background refresh when the live data is older than ``STALE_AFTER_DAYS``
+(quality rating windows advance when a calendar year completes, so six
+months caps the lag behind that yearly step). Set
+``EEWEATHER_AUTO_UPDATE=0`` to disable. ``python -m
+eeweather.registry.update`` refreshes on demand; ``--clear`` removes
+updated copies, returning to the packaged data.
+"""
+import argparse
+import os
+import shutil
+import sqlite3
+import threading
+import time
+import warnings
+from datetime import datetime, timedelta, timezone
+
+import requests
+
+from ..build.refresh import refresh
+from .db import (
+ COMMIT_MARKER,
+ GHCNH_DB_PATH,
+ PACKAGED_GHCNH_DB_PATH,
+ PACKAGED_IDENTIFIERS_DB_PATH,
+ UPDATED_DATA_DIR,
+ data_path,
+)
+
+
+
+UPDATABLE = {
+ "ghcnh.db": PACKAGED_GHCNH_DB_PATH,
+ "identifiers.db": PACKAGED_IDENTIFIERS_DB_PATH,
+}
+
+# source dbs whose meta(refreshed_at) stamp drives staleness; a db not
+# listed here (e.g. identifiers.db, which carries no meta table) never
+# forces the registry stale
+AUTO_UPDATING_SOURCES = {
+ "ghcnh.db": GHCNH_DB_PATH,
+}
+
+RELEASE_URL = (
+ "https://github.com/opendsm/eeweather/releases/download/registry-latest/{}"
+)
+
+DOWNLOAD_TIMEOUT_SECONDS = 300
+
+# a downloaded pack must be at least this fraction as populated as the
+# data it replaces
+PLAUSIBILITY_FLOOR = 0.9
+
+_FLOOR_TABLES = {
+ "ghcnh.db": ("stations", "inventory"),
+ "identifiers.db": ("station_identifier",),
+}
+
+STALE_AFTER_DAYS = 183
+
+RETRY_AFTER_DAYS = 1
+
+# the update thread waits before touching the network, so short-lived
+# processes (pipeline workers) exit before generating any traffic
+UPDATE_DELAY_SECONDS = 60
+
+_ATTEMPT_STAMP = "last_attempt"
+
+_process_checked = False
+
+
+def refreshed_at(path=GHCNH_DB_PATH):
+ """When the data at ``path`` was last refreshed, or None if unstamped."""
+ conn = sqlite3.connect(path)
+ try:
+ row = conn.execute(
+ "select value from meta where key = 'refreshed_at'"
+ ).fetchone()
+ except sqlite3.OperationalError:
+ row = None
+ finally:
+ conn.close()
+ if row is None:
+ return None
+
+ return datetime.strptime(row[0], "%Y-%m-%d").replace(tzinfo=timezone.utc)
+
+
+def _stale(now):
+ for path in AUTO_UPDATING_SOURCES.values():
+ stamp = refreshed_at(path)
+ if stamp is None or now - stamp > timedelta(days=STALE_AFTER_DAYS):
+ return True
+
+ return False
+
+
+def _claim_attempt(now):
+ """Atomically claim the machine-wide right to attempt an update.
+
+ The claim file doubles as the attempt record: exclusive creation
+ means exactly one process wins per retry window, however many
+ workers race for it.
+ """
+ path = os.path.join(UPDATED_DATA_DIR, _ATTEMPT_STAMP)
+ try:
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ except FileExistsError:
+ try:
+ attempted = datetime.fromtimestamp(
+ os.path.getmtime(path), tz=timezone.utc
+ )
+ except OSError:
+ return False
+ if now - attempted < timedelta(days=RETRY_AFTER_DAYS):
+ return False
+ try:
+ os.remove(path)
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ except OSError:
+ return False
+ os.close(fd)
+
+ return True
+
+
+def maybe_update():
+ """Start a background update when the live data is stale.
+
+ Checks at most once per process; across processes on a machine, an
+ atomic claim file limits attempts to one per ``RETRY_AFTER_DAYS``
+ however many workers race for it, and the update thread waits
+ ``UPDATE_DELAY_SECONDS`` before touching the network, so short-lived
+ processes exit without generating traffic. Updates prefer the
+ CDN-served published pack, so even uncoordinated fleets of ephemeral
+ machines cost NOAA nothing; ``EEWEATHER_AUTO_UPDATE=0`` suppresses
+ the traffic entirely. The running process keeps the data it
+ imported; the updated copies serve subsequent processes. Returns the
+ update thread, or None when no update starts.
+ """
+ global _process_checked
+ if _process_checked:
+ return None
+ _process_checked = True
+ if os.environ.get("EEWEATHER_AUTO_UPDATE", "1").lower() in ("0", "false"):
+ return None
+ now = datetime.now(timezone.utc)
+ if not _stale(now):
+ return None
+
+ os.makedirs(UPDATED_DATA_DIR, exist_ok=True)
+ if not _claim_attempt(now):
+ return None
+ thread = threading.Thread(target=_update_or_warn, daemon=True)
+ thread.start()
+
+ return thread
+
+
+def _update_or_warn():
+ try:
+ time.sleep(UPDATE_DELAY_SECONDS)
+ update()
+ except Exception as error:
+ warnings.warn(
+ "eeweather registry auto-update failed; using existing data:"
+ " {}".format(error)
+ )
+
+
+def _download(url, dest):
+ response = requests.get(url, timeout=DOWNLOAD_TIMEOUT_SECONDS, stream=True)
+ response.raise_for_status()
+ with open(dest, "wb") as f:
+ for chunk in response.iter_content(chunk_size=1 << 20):
+ f.write(chunk)
+
+
+def _plausible_pack_or_raise(staged):
+ """Abort when a downloaded file is implausibly small next to the data
+ it would replace."""
+ for filename, stage in staged.items():
+ live = data_path(UPDATABLE[filename])
+ stage_conn = sqlite3.connect(stage)
+ live_conn = sqlite3.connect(live)
+ try:
+ for table in _FLOOR_TABLES[filename]:
+ query = "select count(*) from {}".format(table)
+ staged_count = stage_conn.execute(query).fetchone()[0]
+ live_count = live_conn.execute(query).fetchone()[0]
+ if staged_count < PLAUSIBILITY_FLOOR * live_count:
+ raise RuntimeError(
+ "Implausible published {}: {} has {} rows, live data"
+ " has {}.".format(filename, table, staged_count, live_count)
+ )
+ finally:
+ stage_conn.close()
+ live_conn.close()
+
+
+def _write_marker():
+ """Certify that a complete updated pair is installed. Written via an
+ atomic rename so the marker only ever appears whole; data_path serves
+ the updated pair only when it is present, so a torn swap is ignored.
+ Only the marker's presence is consulted; its contents are unused."""
+ marker = os.path.join(UPDATED_DATA_DIR, COMMIT_MARKER)
+ stage = marker + ".staging"
+ with open(stage, "w"):
+ pass
+ os.replace(stage, marker)
+
+
+def _clear_marker():
+ """Retract certification before a swap begins, so an interrupted swap
+ (first update or any re-update) is never certified and reads fall back
+ to the packaged pair until _write_marker completes."""
+ marker = os.path.join(UPDATED_DATA_DIR, COMMIT_MARKER)
+ if os.path.exists(marker):
+ os.remove(marker)
+
+
+def _install_published(now):
+ """Install the published registry pack; returns its stamp, or None
+ when the channel is unreachable, implausible, stale, or no newer
+ than the live data (the NOAA rebuild is the fallback). A failed
+ replace leaves no marker, so reads fall back to the packaged pair."""
+ staged = {}
+ try:
+ for filename in UPDATABLE:
+ stage = os.path.join(UPDATED_DATA_DIR, filename + ".download")
+ _download(RELEASE_URL.format(filename), stage)
+ staged[filename] = stage
+ published = refreshed_at(staged["ghcnh.db"])
+ if published is None or now - published > timedelta(days=STALE_AFTER_DAYS):
+ return None
+ current = refreshed_at()
+ if current is not None and published <= current:
+ return None
+ _plausible_pack_or_raise(staged)
+ _clear_marker()
+ for filename, stage in staged.items():
+ os.replace(stage, os.path.join(UPDATED_DATA_DIR, filename))
+ staged[filename] = None
+ _write_marker()
+ except (requests.RequestException, sqlite3.Error, RuntimeError, OSError):
+ return None
+ finally:
+ for stage in staged.values():
+ if stage is not None and os.path.exists(stage):
+ os.remove(stage)
+
+ return published
+
+
+def _rebuild():
+ """Refresh staged copies of the live data from NOAA and swap them in."""
+ staged = {}
+ for filename, packaged in UPDATABLE.items():
+ dest = os.path.join(UPDATED_DATA_DIR, filename)
+ stage = dest + ".staging"
+ if os.path.exists(dest):
+ shutil.copy(dest, stage)
+ else:
+ shutil.copy(packaged, stage)
+ staged[filename] = stage
+
+ try:
+ counts = refresh(
+ ghcnh_path=staged["ghcnh.db"],
+ identifiers_path=staged["identifiers.db"],
+ )
+ try:
+ _clear_marker()
+ for filename, stage in staged.items():
+ os.replace(stage, os.path.join(UPDATED_DATA_DIR, filename))
+ _write_marker()
+ except OSError:
+ return None
+ finally:
+ for stage in staged.values():
+ if os.path.exists(stage):
+ os.remove(stage)
+
+ return counts
+
+
+def update():
+ """Update the registry data on this machine; returns a summary.
+
+ Prefers the published rolling-release pack (CDN-served, no local
+ rebuild); falls back to rebuilding from the live NOAA files when the
+ channel is unreachable, implausible, stale, or no newer than the
+ live data. Takes effect in new processes.
+
+ Readers never observe a torn pair: the commit marker is retracted
+ before the swap and rewritten only after both files are in place, so
+ an interrupted swap falls back to the packaged pair. Concurrent
+ updaters are not serialized across processes — the background path is
+ gated once per process and per machine, and a manual update racing it
+ is the unguarded case; readers stay consistent regardless.
+ """
+ os.makedirs(UPDATED_DATA_DIR, exist_ok=True)
+ published = _install_published(datetime.now(timezone.utc))
+ if published is not None:
+ summary = {
+ "channel": "published",
+ "refreshed_at": published.strftime("%Y-%m-%d"),
+ }
+
+ return summary
+
+ counts = _rebuild()
+
+ return counts
+
+
+def clear():
+ """Remove updated registry data, returning to the packaged copies."""
+ removed = []
+ # retract the marker first so a partial clear is never certified
+ for filename in [COMMIT_MARKER] + list(UPDATABLE) + [_ATTEMPT_STAMP]:
+ path = os.path.join(UPDATED_DATA_DIR, filename)
+ if os.path.exists(path):
+ os.remove(path)
+ removed.append(path)
+
+ return removed
+
+
+def main():
+ parser = argparse.ArgumentParser(prog="python -m eeweather.registry.update")
+ parser.add_argument(
+ "--clear",
+ action="store_true",
+ help="remove updated registry data, returning to packaged copies",
+ )
+ args = parser.parse_args()
+
+ if args.clear:
+ print({"removed": clear()})
+ else:
+ print(update())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/eeweather/registry/zones.py b/eeweather/registry/zones.py
new file mode 100644
index 0000000..a5cdf54
--- /dev/null
+++ b/eeweather/registry/zones.py
@@ -0,0 +1,47 @@
+"""Point-in-zone lookups against the packaged geography packs."""
+import json
+from functools import cached_property
+
+from shapely.geometry import Point, shape
+
+from .db import metadata_db_connection_proxy
+
+
+
+class _ZoneGeometries(object):
+ @cached_property
+ def by_system(self):
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ geometries = {}
+ for alias in proxy.geography_aliases:
+ for system, zone_id, geometry in conn.execute(
+ "select system, zone_id, geometry from {}.zone"
+ " order by system, zone_id".format(alias)
+ ):
+ geometries.setdefault(system, []).append(
+ (zone_id, shape(json.loads(geometry)))
+ )
+
+ return geometries
+
+
+_zone_geometries = _ZoneGeometries()
+
+
+def zones_at(latitude, longitude):
+ """The zone containing a point, by zone system.
+
+ Returns a dict with one entry per zone system in the geography packs;
+ the value is None when no zone of that system contains the point.
+ """
+ point = Point(longitude, latitude)
+ zones = {}
+ for system, geometries in _zone_geometries.by_system.items():
+ zones[system] = None
+ for zone_id, geometry in geometries:
+ if geometry.contains(point):
+ zones[system] = zone_id
+ break
+
+ return zones
diff --git a/eeweather/resources/__init__.py b/eeweather/resources/__init__.py
deleted file mode 100644
index a5ebcd3..0000000
--- a/eeweather/resources/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
diff --git a/eeweather/resources/metadata.db b/eeweather/resources/metadata.db
deleted file mode 100644
index 4fab6fc..0000000
Binary files a/eeweather/resources/metadata.db and /dev/null differ
diff --git a/eeweather/sources/__init__.py b/eeweather/sources/__init__.py
index 84ba50c..7d259cd 100644
--- a/eeweather/sources/__init__.py
+++ b/eeweather/sources/__init__.py
@@ -1,24 +1,24 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+"""Weather-data sources and estimation strategies.
+A *source* is a provider of weather data. Strings name the built-in
+sources (``"ghcnh"``, ``"tmy3"``, ``"cz2010"``); configured or custom
+sources are objects. Station-keyed external data plugs in through the
+:class:`Feed` protocol; location-keyed (gridded) data implements
+:class:`Source` directly.
"""
-from .ghcnh import DEFAULT_VARIABLES, fetch_ghcnh_hourly
-
-
-
-__all__ = ("DEFAULT_VARIABLES", "fetch_ghcnh_hourly")
+from .base import Feed, NormalsSource, Source
+from .engine import register, variables
+from .station_source import StationSource
+from .vocabulary import Variable
+
+
+
+__all__ = (
+ "Source",
+ "StationSource",
+ "Feed",
+ "NormalsSource",
+ "Variable",
+ "register",
+ "variables",
+)
diff --git a/eeweather/sources/base.py b/eeweather/sources/base.py
new file mode 100644
index 0000000..d408aa2
--- /dev/null
+++ b/eeweather/sources/base.py
@@ -0,0 +1,205 @@
+"""The contracts weather-data sources implement.
+
+``Source`` is the location-level estimation contract; ``Feed`` is the
+station-keyed observation contract; ``NormalsSource`` is the
+typical-year contract, including the machinery its implementations
+share. The engine and location layers interpret these kinds; adapters
+subclass them.
+"""
+from __future__ import annotations
+
+import time
+from datetime import datetime, timedelta, timezone
+
+import pandas as pd
+import requests
+
+from ..exceptions import DataNotAvailableError
+from ..registry.db import metadata_db_connection_proxy
+
+
+
+class Source(object):
+ """Estimates weather at a geographic location.
+
+ Subclasses implement ``estimate`` for one estimation strategy: a
+ station-based source resolves the point to a nearby weather station,
+ a grid-based source samples a gridded product at the point. All
+ return the same ``(DataFrame, warnings, provenance)`` contract so the
+ strategy is interchangeable behind a location; ``provenance`` maps
+ each source name used to a Provenance record.
+ """
+
+ def estimate(
+ self,
+ latitude: float,
+ longitude: float,
+ start: datetime,
+ end: datetime,
+ frequency="h",
+ variables=("temperature",),
+ **load_kwargs,
+ ):
+ raise NotImplementedError
+
+
+class Feed(object):
+ """The protocol for station-keyed observation data.
+
+ Implementations declare how their data is keyed and fetched; the
+ engine supplies caching, alignment, warnings, and provenance, and the
+ registry supplies station choice. Frames returned by ``fetch_year``
+ carry canonical variable names and units.
+
+ Attributes
+ ----------
+ name : str
+ Unique source name; appears in provenance and cache keys.
+ kind : str
+ ``"observations"``.
+ id_namespace : str
+ The identifier system the data is keyed by (``"ghcn"``,
+ ``"usaf"``, ...); translated from registry ids by the engine.
+ variables : tuple of str
+ Canonical variables the feed serves.
+ default_variables : tuple of str
+ Variables a bare load requests.
+ cacheable : bool
+ Whether the engine should cache fetched blocks; declare False
+ for fast local backends.
+ """
+
+ kind = "observations"
+ cacheable = True
+ default_variables = ("temperature",)
+
+ def fetch_year(self, external_id: str, year: int, variables) -> pd.DataFrame:
+ raise NotImplementedError
+
+
+REQUEST_TRIES = 3
+
+REQUEST_RETRY_BACKOFF_SECONDS = 5
+
+REQUEST_TIMEOUT_SECONDS = 120
+
+
+def request_text(url):
+ """Fetch a url's text, retrying connection errors and server errors
+ with growing backoff; client errors raise immediately."""
+ for attempt in range(REQUEST_TRIES):
+ try:
+ response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
+ response.raise_for_status()
+ except requests.HTTPError:
+ if response.status_code < 500 or attempt == REQUEST_TRIES - 1:
+ raise
+ time.sleep(REQUEST_RETRY_BACKOFF_SECONDS * (attempt + 1))
+ except requests.RequestException:
+ if attempt == REQUEST_TRIES - 1:
+ raise
+ time.sleep(REQUEST_RETRY_BACKOFF_SECONDS * (attempt + 1))
+ else:
+ return response.text
+
+
+def parse_normals_csv(text):
+ """Parse a TMY-format CSV into an hourly temperature series over the
+ year 1900 in UTC."""
+ index = pd.date_range(
+ "1900-01-01 00:00", "1900-12-31 23:00", freq="h", tz=timezone.utc
+ )
+ ts = pd.Series(None, index=index, dtype=float)
+
+ lines = text.splitlines()
+
+ utc_offset_str = lines[0].split(",")[3]
+ utc_offset = timedelta(seconds=3600 * float(utc_offset_str))
+
+ for line in lines[2:]:
+ row = line.split(",")
+ month = row[0][0:2]
+ day = row[0][3:5]
+ hour = int(row[1][0:2]) - 1
+
+ # YYYYMMDDHH
+ date_string = "1900{}{}{:02d}".format(month, day, hour)
+
+ dt = datetime.strptime(date_string, "%Y%m%d%H") - utc_offset
+
+ # The offset can push the first or last few hours of the year
+ # into an adjacent year; fold them back into 1900.
+ dt = dt.replace(year=1900, tzinfo=timezone.utc)
+ temp_C = float(row[31])
+
+ ts[dt] = temp_C
+
+ return ts
+
+
+class NormalsSource(object):
+ """A typical-year (normals) source.
+
+ One typical year per station, served from a USAF-named archive of
+ TMY-format CSVs listed in the source's packaged station table. The
+ engine tiles it onto requested calendar years (Feb 29 is NaN),
+ caches it, and serves it through ``load_data(source=...)`` — never
+ through routing, so typical values cannot silently mix with
+ observations. Subclasses declare ``name`` and the archive
+ ``_url(usaf_id)``.
+
+ Attributes
+ ----------
+ name : str
+ Unique source name; appears in provenance and cache keys.
+ kind : str
+ ``"normals"``.
+ variables : tuple of str
+ Canonical variables the source serves.
+ cacheable : bool
+ Whether the engine should cache fetched blocks.
+ """
+
+ kind = "normals"
+ cacheable = True
+ variables = ("temperature",)
+ default_variables = ("temperature",)
+
+ def _url(self, usaf_id):
+ raise NotImplementedError
+
+ def archive_station(self, station_id):
+ """The archive row for a station, raising DataNotAvailableError
+ when the station has no data in this normals source."""
+ conn = metadata_db_connection_proxy.get_connection()
+ cur = conn.cursor()
+ cur.execute(
+ "select * from {}.stations where station_id = ?".format(self.name),
+ (station_id,),
+ )
+ row = cur.fetchone()
+ if row is None:
+ raise DataNotAvailableError(self.name, station_id=station_id)
+ archive = {col[0]: row[i] for i, col in enumerate(cur.description)}
+
+ return archive
+
+ def fetch(self, station_id):
+ """The station's typical-year hourly temperature series (year 1900).
+
+ Raises DataNotAvailableError when the station has no data in this
+ normals source.
+ """
+ archive = self.archive_station(station_id)
+ url = self._url(archive["usaf_id"])
+ try:
+ text = request_text(url)
+ except requests.HTTPError as error:
+ if error.response is not None and error.response.status_code == 404:
+ raise DataNotAvailableError(
+ self.name, station_id=station_id
+ ) from error
+ raise
+ ts = parse_normals_csv(text)
+
+ return ts
diff --git a/eeweather/sources/cz2010/__init__.py b/eeweather/sources/cz2010/__init__.py
new file mode 100644
index 0000000..c04f98d
--- /dev/null
+++ b/eeweather/sources/cz2010/__init__.py
@@ -0,0 +1,15 @@
+"""California Energy Commission CZ2010 typical-year temperatures."""
+import os
+
+from ...registry.db import metadata_db_connection_proxy
+from .source import CZ2010Source
+
+
+
+__all__ = ("CZ2010Source", "DATA_PATH")
+
+# packaged archive station list
+DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cz2010.db")
+metadata_db_connection_proxy.register_attachment(
+ "cz2010", DATA_PATH, availability=True
+)
diff --git a/eeweather/sources/cz2010/cz2010.db b/eeweather/sources/cz2010/cz2010.db
new file mode 100644
index 0000000..a345e49
Binary files /dev/null and b/eeweather/sources/cz2010/cz2010.db differ
diff --git a/eeweather/sources/cz2010/source.py b/eeweather/sources/cz2010/source.py
new file mode 100644
index 0000000..354dbfa
--- /dev/null
+++ b/eeweather/sources/cz2010/source.py
@@ -0,0 +1,14 @@
+"""California Energy Commission CZ2010 typical-year temperatures."""
+from ..base import NormalsSource
+
+
+
+class CZ2010Source(NormalsSource):
+ name = "cz2010"
+
+ def _url(self, usaf_id):
+ url = "https://storage.googleapis.com/oee-cz2010/csv/{}_CZ2010.CSV".format(
+ usaf_id
+ )
+
+ return url
diff --git a/eeweather/sources/engine.py b/eeweather/sources/engine.py
new file mode 100644
index 0000000..5dea37f
--- /dev/null
+++ b/eeweather/sources/engine.py
@@ -0,0 +1,813 @@
+"""The source-agnostic load engine.
+
+Owns everything between an adapter's fetch and the frame a user receives:
+variable routing, per-year caching with variable-union refresh,
+typical-year tiling, range alignment, frequency aggregation, gap
+warnings, and provenance. Frames for one request range and frequency
+always share an identical UTC index regardless of source, so they join
+safely.
+
+Missing-data semantics: partial coverage (missing years, station gaps)
+surfaces as NaN values plus warnings. A pinned source with nothing at all
+for the request raises DataNotAvailableError; a routed request never
+raises for missing data.
+"""
+from __future__ import annotations
+
+from collections import namedtuple
+from datetime import datetime, timedelta, timezone
+
+import pandas as pd
+
+import eeweather.cache
+from ..exceptions import DataNotAvailableError, EEWeatherWarning
+from ..registry.identifiers import translate
+from ..registry.update import maybe_update
+from .cz2010 import CZ2010Source
+from .ghcnh import GHCNhSource
+from .tmy3 import TMY3Source
+from .vocabulary import (
+ aggregation_for,
+ all_variables,
+ register_variables,
+ valid_variables_or_raise,
+)
+
+
+
+BUILTIN_SOURCES = {
+ adapter.name: adapter
+ for adapter in (GHCNhSource(), TMY3Source(), CZ2010Source())
+}
+
+_registered_sources = {}
+
+
+def register(source, vocabulary=()):
+ """Register a custom source under its name.
+
+ Registration makes the source nameable everywhere a built-in name
+ works — preference tuples, ``load_data(source=...)``, and location
+ serialization (``to_dict``/``from_dict``) — without any change to
+ this repository. Accepts station-keyed feeds (``fetch_year``),
+ normals sources (``fetch``), and estimation/grid sources
+ (``estimate``). Registering the same name again replaces the
+ previous object; built-in names cannot be replaced. Returns the
+ source.
+
+ A source serving variables outside the vocabulary passes their
+ definitions as ``vocabulary`` (an iterable of
+ ``eeweather.sources.Variable``); they become requestable and
+ routable like canonical entries. Canonical names are reserved, and
+ definitions must agree across sources, so the first registration of
+ a name freezes its unit.
+ """
+ name = getattr(source, "name", None)
+ if not isinstance(name, str) or not name:
+ raise ValueError("A source must declare a non-empty string name.")
+ if name in BUILTIN_SOURCES:
+ raise ValueError("'{}' is a built-in source name.".format(name))
+ is_estimation = hasattr(source, "estimate")
+ is_fetch_year = hasattr(source, "fetch_year")
+ if not is_fetch_year and not hasattr(source, "fetch") and not is_estimation:
+ raise ValueError(
+ "A registered source must declare one of: fetch_year"
+ " (station-keyed feeds), fetch (normals sources), or"
+ " estimate (estimation/grid sources)."
+ )
+ if is_estimation:
+ if not hasattr(source, "variables"):
+ raise ValueError("An estimation source must declare 'variables'.")
+ if not hasattr(source, "kind"):
+ raise ValueError("An estimation source must declare 'kind'.")
+ elif is_fetch_year:
+ if not hasattr(source, "variables"):
+ raise ValueError("A fetch_year source must declare 'variables'.")
+ if not hasattr(source, "kind"):
+ raise ValueError("A fetch_year source must declare 'kind'.")
+ if not hasattr(source, "id_namespace"):
+ raise ValueError("A fetch_year source must declare 'id_namespace'.")
+ else:
+ if not hasattr(source, "variables"):
+ raise ValueError("A normals source must declare 'variables'.")
+ register_variables(vocabulary)
+ known = all_variables()
+ undeclared = [v for v in source.variables if v not in known]
+ if undeclared:
+ raise ValueError(
+ "Source '{}' serves variables the vocabulary lacks: {}. Pass"
+ " their definitions via register(..., vocabulary=...).".format(
+ name, ", ".join(undeclared)
+ )
+ )
+ _registered_sources[name] = source
+
+ return source
+
+
+def known_source_names():
+ """Names resolvable to a source: built-in plus registered."""
+ return set(BUILTIN_SOURCES) | set(_registered_sources)
+
+
+def resolve_source(source):
+ """The adapter for a source name or source object."""
+ if isinstance(source, str):
+ if source in BUILTIN_SOURCES:
+ return BUILTIN_SOURCES[source]
+ if source in _registered_sources:
+ return _registered_sources[source]
+
+ raise ValueError(
+ "Unknown source: '{}'. Known sources: {}.".format(
+ source, ", ".join(sorted(known_source_names()))
+ )
+ )
+
+ return source
+
+
+def sources_serving(variable):
+ """Names of built-in and registered sources that serve a canonical
+ variable."""
+ catalog = {**BUILTIN_SOURCES, **_registered_sources}
+ names = [
+ name
+ for name, adapter in sorted(catalog.items())
+ if variable in adapter.variables
+ ]
+
+ return names
+
+
+
+TRAILING_GAP_WARNING_THRESHOLD = timedelta(days=1)
+LEADING_GAP_WARNING_THRESHOLD = timedelta(days=1)
+INTERNAL_GAP_WARNING_THRESHOLD = timedelta(days=7)
+
+_ProvenanceFields = namedtuple(
+ "Provenance",
+ ["kind", "source", "variables", "station_id", "distance_meters", "payload"],
+)
+
+
+class Provenance(_ProvenanceFields):
+ """How a value was produced. Station fields are None for non-station
+ sources; ``payload`` is always a dict, empty unless the source adds
+ source-specific detail (e.g. a grid cell or interpolation method)."""
+ __slots__ = ()
+
+ def __new__(
+ cls, kind, source, variables,
+ station_id=None, distance_meters=None, payload=None,
+ ):
+ if payload is None:
+ payload = {}
+
+ record = super().__new__(
+ cls, kind, source, variables, station_id, distance_meters, payload
+ )
+
+ return record
+
+
+def _store():
+ return eeweather.cache.key_value_store_proxy.get_store()
+
+
+def _datetime_is_utc(dt):
+ if dt.tzinfo is None:
+ return False
+
+ return dt.utcoffset().total_seconds() == 0
+
+
+def _validate_range(start, end):
+ if not _datetime_is_utc(start):
+ raise ValueError(
+ "start must be an explicit-UTC datetime, got: {}".format(start)
+ )
+ if not _datetime_is_utc(end):
+ raise ValueError("end must be an explicit-UTC datetime, got: {}".format(end))
+ if start > end:
+ raise ValueError(
+ "start must not be after end, got: {} > {}".format(start, end)
+ )
+
+
+def _validate_requested(variables):
+ if len(variables) == 0:
+ raise ValueError("At least one variable must be requested.")
+ if len(set(variables)) != len(variables):
+ raise ValueError(
+ "Duplicate variables requested: {}".format(", ".join(variables))
+ )
+
+
+def _external_id(adapter, station_id):
+ """The id the adapter fetches by, translated from the registry id."""
+ namespace = adapter.id_namespace
+ if namespace == "ghcn":
+ return station_id
+ mapping = translate([station_id], "ghcn", namespace)
+ external_ids = mapping.get(station_id)
+ if not external_ids:
+ raise DataNotAvailableError(adapter.name, station_id=station_id)
+
+ return external_ids[0]
+
+
+def _data_gap_warnings(ts, source, variable):
+ """EEWeatherWarnings for requested ranges the returned data does not
+ cover: entirely empty series, late-starting or early-ending data, and
+ long internal gaps."""
+ warnings = []
+ if len(ts) == 0:
+ return warnings
+
+ if ts.isna().all():
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.no_data_in_requested_range",
+ description="No data was available within the requested range.",
+ data={
+ "source": source,
+ "variable": variable,
+ "requested_start": ts.index[0].isoformat(),
+ "requested_end": ts.index[-1].isoformat(),
+ },
+ )
+ )
+
+ return warnings
+
+ first_valid = ts.first_valid_index()
+ leading_gap = first_valid - ts.index[0]
+ if leading_gap > LEADING_GAP_WARNING_THRESHOLD:
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.data_starts_late",
+ description=(
+ "Data begins {} after the start of the requested"
+ " range.".format(leading_gap)
+ ),
+ data={
+ "source": source,
+ "variable": variable,
+ "first_valid": first_valid.isoformat(),
+ "requested_start": ts.index[0].isoformat(),
+ },
+ )
+ )
+
+ last_valid = ts.last_valid_index()
+ trailing_gap = ts.index[-1] - last_valid
+ if trailing_gap > TRAILING_GAP_WARNING_THRESHOLD:
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.data_truncated",
+ description=(
+ "Data ends {} before the end of the requested range.".format(
+ trailing_gap
+ )
+ ),
+ data={
+ "source": source,
+ "variable": variable,
+ "last_valid": last_valid.isoformat(),
+ "requested_end": ts.index[-1].isoformat(),
+ },
+ )
+ )
+
+ interior = ts.loc[first_valid:last_valid]
+ if len(interior) > 1:
+ period = interior.index[1] - interior.index[0]
+ is_missing = interior.isna()
+ max_gap_periods = int(is_missing.groupby((~is_missing).cumsum()).sum().max())
+ max_gap = max_gap_periods * period
+ if max_gap > INTERNAL_GAP_WARNING_THRESHOLD:
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.data_gap",
+ description=(
+ "Data contains an internal gap of {}.".format(max_gap)
+ ),
+ data={
+ "source": source,
+ "variable": variable,
+ "max_gap_days": max_gap / timedelta(days=1),
+ },
+ )
+ )
+
+ return warnings
+
+
+# observation caching: one JSON block per (source, station, year)
+
+
+def observation_cache_key(source_name, station_id, year):
+ return "{}-hourly-{}-{}".format(source_name, station_id, year)
+
+
+def serialize_hourly_data(df):
+ rows = [
+ [index.strftime("%Y%m%d%H")] + values
+ for index, values in zip(
+ df.index, df.astype(object).where(df.notna(), None).values.tolist()
+ )
+ ]
+ serialized = {"columns": list(df.columns), "rows": rows}
+
+ return serialized
+
+
+def deserialize_hourly_data(data):
+ index = pd.to_datetime(
+ [row[0] for row in data["rows"]], format="%Y%m%d%H", utc=True
+ )
+ df = pd.DataFrame(
+ [row[1:] for row in data["rows"]],
+ index=index,
+ columns=data["columns"],
+ dtype=float,
+ )
+
+ return df.sort_index().resample("h").mean()
+
+
+def _read_cached_year(adapter, station_id, year):
+ """The fresh cached block for a station-year, or None."""
+ store = _store()
+ key = observation_cache_key(adapter.name, station_id, year)
+ if not store.key_exists(key):
+ return None
+ if eeweather.cache._expired(store.key_updated(key), year):
+ store.clear(key)
+
+ return None
+
+ return deserialize_hourly_data(store.retrieve_json(key))
+
+
+def _fetch_year(adapter, station_id, external_id, year, variables):
+ """One year of observations resampled to an hourly frame.
+
+ Raises DataNotAvailableError when the station has no observations at
+ all for the year.
+ """
+ raw = adapter.fetch_year(external_id, year, variables)
+ if len(raw) == 0:
+ raise DataNotAvailableError(adapter.name, station_id=station_id, year=year)
+
+ point = [c for c in raw.columns if aggregation_for(c) != "sum"]
+ accumulation = [c for c in raw.columns if aggregation_for(c) == "sum"]
+ parts = []
+ if point:
+ # CalTRACK 2.3.3
+ parts.append(
+ raw[point]
+ .resample("min")
+ .mean()
+ .interpolate(method="linear", limit=60, limit_direction="both")
+ .resample("h")
+ .mean()
+ )
+ if accumulation:
+ # accumulations add up within the hour and are never fabricated
+ # by interpolation
+ parts.append(raw[accumulation].resample("h").sum(min_count=1))
+ df = pd.concat(parts, axis=1)[list(raw.columns)]
+
+ return df
+
+
+def _load_observation_year(
+ adapter, station_id, external_id, year, variables,
+ read_from_cache, write_to_cache, fetch_from_web,
+):
+ """One year of hourly data, from cache when it covers the request.
+
+ A cache entry serves the request when it is fresh and holds every
+ requested variable. Fetches request the union of the requested and
+ already-cached variables so a cache refresh never drops columns.
+ """
+ cached = None
+ if adapter.cacheable:
+ cached = _read_cached_year(adapter, station_id, year)
+
+ cache_covers_request = cached is not None and set(variables) <= set(cached.columns)
+ if read_from_cache and cache_covers_request:
+ return cached[list(variables)]
+
+ if not fetch_from_web:
+ raise DataNotAvailableError(adapter.name, station_id=station_id, year=year)
+
+ if cached is None:
+ cached_columns = ()
+ else:
+ cached_columns = tuple(cached.columns)
+ fetch_variables = tuple(dict.fromkeys(variables + cached_columns))
+ df = _fetch_year(adapter, station_id, external_id, year, fetch_variables)
+ if adapter.cacheable and write_to_cache:
+ _store().save_json(
+ observation_cache_key(adapter.name, station_id, year),
+ serialize_hourly_data(df),
+ )
+
+ return df.reindex(columns=list(variables))
+
+
+def _load_observations(
+ adapter, station_id, start, end, variables,
+ read_from_cache, write_to_cache, fetch_from_web,
+):
+ """Hourly observations over the requested years; missing years surface
+ as warnings, and NaN rows after alignment."""
+ warnings = []
+ data = []
+ try:
+ external_id = _external_id(adapter, station_id)
+ except DataNotAvailableError:
+ # station-level condition: the id has no alias in the adapter's
+ # namespace; one warning, not one per year
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.data_not_available",
+ description="Station has no {} identifier".format(
+ adapter.id_namespace
+ ),
+ data={"source": adapter.name, "station_id": station_id},
+ )
+ )
+ external_id = None
+ if external_id is None:
+ years = []
+ else:
+ years = range(start.year, end.year + 1)
+ for year in years:
+ try:
+ data.append(
+ _load_observation_year(
+ adapter, station_id, external_id, year, variables,
+ read_from_cache, write_to_cache, fetch_from_web,
+ )
+ )
+ except DataNotAvailableError:
+ warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.data_not_available",
+ description="Data not available",
+ data={
+ "source": adapter.name,
+ "station_id": station_id,
+ "year": year,
+ },
+ )
+ )
+
+ if data:
+ df = pd.concat(data)
+ else:
+ df = pd.DataFrame(
+ columns=list(variables),
+ index=pd.DatetimeIndex([], tz=timezone.utc),
+ dtype=float,
+ )
+
+ return df, warnings
+
+
+# normals: one typical-year JSON block per (source, station), tiled onto
+# the requested calendar years
+
+
+def normals_cache_key(source_name, station_id):
+ return "{}-hourly-{}".format(source_name, station_id)
+
+
+def _load_normals_block(
+ adapter, station_id, read_from_cache, write_to_cache, fetch_from_web
+):
+ store = _store()
+ key = normals_cache_key(adapter.name, station_id)
+ cached_ok = adapter.cacheable and store.key_exists(key)
+ column = adapter.variables[0]
+
+ if read_from_cache and cached_ok:
+ cached = deserialize_hourly_data(store.retrieve_json(key))
+
+ return cached[column]
+
+ if not fetch_from_web:
+ raise DataNotAvailableError(adapter.name, station_id=station_id)
+
+ ts = adapter.fetch(station_id)
+ if adapter.cacheable and write_to_cache:
+ store.save_json(key, serialize_hourly_data(ts.to_frame(name=column)))
+
+ return ts
+
+
+def _load_normals(
+ adapter, station_id, start, end, variables,
+ read_from_cache, write_to_cache, fetch_from_web,
+):
+ """The typical year tiled onto each requested calendar year by
+ month-day-hour; Feb 29 is NaN."""
+ single_year = _load_normals_block(
+ adapter, station_id, read_from_cache, write_to_cache, fetch_from_web
+ )
+
+ data = []
+ for year in range(start.year, end.year + 1):
+ tiled_index = single_year.index.map(lambda t: t.replace(year=year))
+ data.append(pd.Series(single_year.values, index=tiled_index))
+ ts = pd.concat(data).resample("h").mean()
+ df = ts.to_frame(name=adapter.variables[0])
+ no_warnings = []
+
+ return df, no_warnings
+
+
+# routing
+
+
+def _route(variables, adapters):
+ """Group requested variables by the first adapter declaring them.
+
+ Normals adapters never participate; they are reachable only by
+ explicit pin. Unroutable variables raise, naming the sources that do
+ serve them.
+ """
+ routable = [a for a in adapters if a.kind == "observations"]
+ if variables == "all":
+ union = []
+ for adapter in routable:
+ for name in adapter.variables:
+ if name not in union:
+ union.append(name)
+ variables = tuple(union)
+ valid_variables_or_raise(variables)
+
+ groups = {}
+ for variable in variables:
+ chosen = None
+ for adapter in routable:
+ if variable in adapter.variables:
+ chosen = adapter
+ break
+ if chosen is None:
+ raise ValueError(
+ "No configured source serves '{}'. Sources that serve it:"
+ " {}.".format(variable, ", ".join(sources_serving(variable)) or "none")
+ )
+ groups.setdefault(chosen, []).append(variable)
+ requested = tuple(variables)
+
+ return groups, requested
+
+
+def load_data(
+ station_id: str,
+ start: datetime,
+ end: datetime,
+ frequency="h",
+ variables=None,
+ source=None,
+ sources=("ghcnh",),
+ read_from_cache: bool = True,
+ write_to_cache: bool = True,
+ fetch_from_web: bool = True,
+ raise_when_empty: bool | None = None,
+):
+ """Load a station's weather data between two dates (inclusive).
+
+ Parameters
+ ----------
+ station_id : str
+ Registry station id.
+ start, end : datetime.datetime
+ Request range; must be explicit UTC.
+ frequency : str or pandas offset
+ A pandas offset alias, parsed and validated by pandas itself
+ (e.g. '30min', 'h', 'D', 'W', 'MS', 'YS'). Frequencies coarser
+ than hourly aggregate the hourly values within each UTC period
+ by each variable's vocabulary aggregation: means for
+ point-in-time variables, sums (never gap-interpolated) for
+ accumulations. Frequencies finer than hourly (they must divide
+ the hour evenly) interpolate point-in-time variables linearly
+ between hourly values and spread accumulations evenly, never
+ crossing a missing hour.
+ variables : tuple of str, or 'all'
+ Canonical variable names; 'all' is every variable the configured
+ sources serve (or, pinned, the pinned source's full vocabulary).
+ source : str or source object, optional
+ Pin every requested variable to this source, bypassing routing.
+ Typical-year sources (tmy3, cz2010) are only reachable this way.
+ sources : tuple of (str or source object)
+ Ordered source preference for routing; per variable, the first
+ source declaring it wins.
+ read_from_cache, write_to_cache, fetch_from_web : bool
+ Cache and network controls.
+ raise_when_empty : bool, optional
+ Whether a request yielding no data at all raises
+ DataNotAvailableError; defaults to True for pinned requests and
+ False for routed ones (partial coverage never raises either way).
+
+ Returns
+ -------
+ tuple of (pandas.DataFrame, list of EEWeatherWarning)
+ One column per requested variable, indexed over the full
+ requested range at the requested frequency in UTC; periods
+ without data are NaN. The frame's ``attrs["provenance"]`` maps
+ each source used to a Provenance record.
+ """
+ maybe_update()
+ _validate_range(start, end)
+
+ # the frequency vocabulary is pandas', bound by delegation: pandas
+ # parses the alias, so its spellings, deprecations, and renames apply
+ # here automatically
+ offset = pd.tseries.frequencies.to_offset(frequency)
+
+ pinned = source is not None
+ if raise_when_empty is None:
+ raise_when_empty = pinned
+ if pinned:
+ adapter = resolve_source(source)
+ if variables is None:
+ variables = tuple(adapter.default_variables)
+ elif variables == "all":
+ variables = tuple(adapter.variables)
+ _validate_requested(tuple(variables))
+ unservable = [v for v in variables if v not in adapter.variables]
+ if unservable:
+ raise ValueError(
+ "Source '{}' does not serve: {}. It serves: {}.".format(
+ adapter.name,
+ ", ".join(unservable),
+ ", ".join(adapter.variables),
+ )
+ )
+ groups = {adapter: list(variables)}
+ requested = tuple(variables)
+ else:
+ if variables is None:
+ variables = ("temperature",)
+ adapters = [resolve_source(entry) for entry in sources]
+ groups, requested = _route(variables, adapters)
+ _validate_requested(requested)
+
+ warnings = []
+ frames = []
+ provenance = {}
+ for adapter, group_variables in groups.items():
+ if adapter.kind == "observations":
+ loader = _load_observations
+ elif adapter.kind == "normals":
+ loader = _load_normals
+ else:
+ raise ValueError("Unknown source kind: {}".format(adapter.kind))
+ group_df, group_warnings = loader(
+ adapter, station_id, start, end, tuple(group_variables),
+ read_from_cache, write_to_cache, fetch_from_web,
+ )
+ warnings.extend(group_warnings)
+ frames.append(group_df)
+ provenance[adapter.name] = Provenance(
+ kind=adapter.kind,
+ source=adapter.name,
+ variables=tuple(group_variables),
+ station_id=station_id,
+ distance_meters=None,
+ payload={},
+ )
+
+ df = pd.concat(frames, axis=1)[list(requested)]
+ if offset != pd.tseries.frequencies.to_offset("h"):
+ df = _resample_by_vocabulary(df, offset)
+ df = df[start:end]
+
+ # start and end dates need to fall exactly on period boundaries; a
+ # period partially before start is excluded, end's period is included
+ if isinstance(offset, pd.tseries.offsets.Tick):
+ range_start = pd.Timestamp(start).ceil(offset)
+ range_end = pd.Timestamp(end).floor(offset)
+ else:
+ range_start = offset.rollforward(pd.Timestamp(start).normalize())
+ if range_start < pd.Timestamp(start):
+ range_start = range_start + offset
+ range_end = offset.rollback(pd.Timestamp(end).normalize())
+
+ # cover the full requested range even when no data loaded
+ df = df.reindex(pd.date_range(range_start, range_end, freq=offset))
+
+ # nothing at all for the requested dates (not merely the requested
+ # calendar years) raises for pinned requests
+ if raise_when_empty and len(df) > 0 and df.isna().all().all():
+ source_name = ", ".join(sorted(a.name for a in groups))
+ raise DataNotAvailableError(source_name, station_id=station_id)
+
+ for adapter, group_variables in groups.items():
+ for variable in group_variables:
+ warnings.extend(_data_gap_warnings(df[variable], adapter.name, variable))
+
+ df.attrs["provenance"] = provenance
+
+ return df, warnings
+
+
+def load_cached_data(station_id, source_name="ghcnh"):
+ """All fresh cached hourly observations for a station from one
+ source, or None when nothing is cached. Applies the same staleness
+ rule as loads."""
+ store = _store()
+ prefix = "{}-hourly-{}-".format(source_name, station_id)
+ data = []
+ for key in store.keys(prefix):
+ year = int(key.rsplit("-", 1)[1])
+ if eeweather.cache._expired(store.key_updated(key), year):
+ continue
+ data.append(deserialize_hourly_data(store.retrieve_json(key)))
+ if not data:
+ return None
+ df = pd.concat(data).resample("h").mean()
+
+ return df
+
+
+def _resample_by_vocabulary(df, offset):
+ """Hourly values at the requested frequency, column by column.
+
+ Coarser periods roll up by the column's vocabulary aggregation; a
+ period with no data at all is NaN regardless of aggregation. Every
+ label is its period's start. Sub-hourly slots interpolate
+ point-in-time columns linearly between hourly values and spread
+ accumulations evenly, never crossing a missing hour.
+ """
+ if isinstance(offset, pd.tseries.offsets.Tick) and (
+ pd.Timedelta(offset) < pd.Timedelta(hours=1)
+ ):
+ return _upsample(df, offset)
+
+ resampled = df.resample(offset, label="left", closed="left")
+ columns = {}
+ for column in df.columns:
+ aggregation = aggregation_for(column)
+ if aggregation == "sum":
+ columns[column] = resampled[column].sum(min_count=1)
+ else:
+ columns[column] = getattr(resampled[column], aggregation)()
+ aggregated = pd.DataFrame(columns)
+
+ return aggregated
+
+
+def _upsample(df, offset):
+ """Hourly values at a finer frequency; the offset must divide the
+ hour evenly."""
+ step = pd.Timedelta(offset)
+ if pd.Timedelta(hours=1) % step != pd.Timedelta(0):
+ raise ValueError(
+ "A sub-hourly frequency must divide the hour evenly,"
+ " got: {}".format(offset.freqstr)
+ )
+ slots = int(pd.Timedelta(hours=1) / step)
+
+ up = df.resample(offset).asfreq()
+ columns = {}
+ for column in df.columns:
+ if aggregation_for(column) == "sum":
+ spread = df[column].reindex(up.index, method="ffill", limit=slots - 1)
+ columns[column] = spread / slots
+ else:
+ filled = up[column].interpolate(method="linear", limit_area="inside")
+ valid = df[column].notna()
+ left_valid = valid.reindex(up.index, method="ffill")
+ right_valid = valid.reindex(up.index, method="bfill")
+ columns[column] = filled.where(left_valid & right_valid)
+ upsampled = pd.DataFrame(columns)
+
+ return upsampled
+
+
+def variables() -> pd.DataFrame:
+ """The canonical variable vocabulary and which sources serve each
+ entry, as a DataFrame indexed by variable name."""
+ catalog = all_variables()
+ rows = []
+ for name, variable in sorted(catalog.items()):
+ rows.append(
+ {
+ "unit": variable.unit,
+ "description": variable.description,
+ "aggregation": variable.aggregation,
+ "sources": tuple(sources_serving(name)),
+ }
+ )
+ df = pd.DataFrame(rows, index=sorted(catalog))
+ df.index.name = "variable"
+
+ return df
diff --git a/eeweather/sources/ghcnh.py b/eeweather/sources/ghcnh.py
deleted file mode 100644
index ef15f38..0000000
--- a/eeweather/sources/ghcnh.py
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import io
-import time
-
-import pandas as pd
-import requests
-
-
-
-API_URL = "https://www.ncei.noaa.gov/access/services/data/v1"
-
-API_REQUEST_TRIES = 3
-
-API_RETRY_BACKOFF_SECONDS = 5
-
-DATASET = "global-historical-climatology-network-hourly"
-
-DEFAULT_VARIABLES = ("temperature",)
-
-
-def _get_api_request_params(ghcn_id, year, variables):
- params = {
- "dataset": DATASET,
- # DATE is only included in the response when explicitly requested
- "dataTypes": ",".join(("DATE",) + tuple(variables)),
- "stations": ghcn_id,
- "startDate": "{}-01-01".format(year),
- "endDate": "{}-12-31".format(year),
- }
-
- return params
-
-
-def fetch_ghcnh_hourly(ghcn_id, year, variables=DEFAULT_VARIABLES):
- """Fetch one year of GHCNh observations for a station.
-
- The request is retried on connection and http errors, as the api has
- been shown to intermittently fail.
-
- Parameters
- ----------
- ghcn_id : str
- GHCNh station id, e.g. ``'USW00023234'``.
- year : int
- Calendar year to fetch.
- variables : tuple of str
- GHCNh variable names, e.g. ``('temperature', 'wind_speed')``.
- Temperatures are degrees Celsius; units for other variables follow
- the GHCNh documentation.
-
- Returns
- -------
- pandas.DataFrame
- One column per requested variable, indexed by UTC observation time.
- Observations sharing a timestamp are averaged. Values the station
- did not report are NaN. Empty when the station has no data for the
- year.
- """
- params = _get_api_request_params(ghcn_id, year, variables)
-
- for attempt in range(API_REQUEST_TRIES):
- try:
- resp = requests.get(url=API_URL, params=params)
- resp.raise_for_status()
- except requests.RequestException:
- if attempt == API_REQUEST_TRIES - 1:
- raise
- # the api intermittently returns 5xx bursts; immediate retries
- # land inside the burst
- time.sleep(API_RETRY_BACKOFF_SECONDS * (attempt + 1))
- else:
- break
-
- empty_index = pd.DatetimeIndex([], tz="UTC")
- if resp.text.strip() == "":
- return pd.DataFrame(columns=list(variables), index=empty_index, dtype=float)
-
- raw = pd.read_csv(io.StringIO(resp.text), dtype=str)
- if len(raw) == 0:
- return pd.DataFrame(columns=list(variables), index=empty_index, dtype=float)
-
- index = pd.to_datetime(raw["DATE"]).dt.tz_localize("UTC").rename(None)
- df = pd.DataFrame(index=index)
- for variable in variables:
- if variable in raw.columns:
- df[variable] = pd.to_numeric(raw[variable], errors="coerce").values
- else:
- df[variable] = float("nan")
-
- df = df.groupby(df.index).mean()
- df = df.sort_index()
-
- return df
diff --git a/eeweather/sources/ghcnh/__init__.py b/eeweather/sources/ghcnh/__init__.py
new file mode 100644
index 0000000..a9ec0c5
--- /dev/null
+++ b/eeweather/sources/ghcnh/__init__.py
@@ -0,0 +1,14 @@
+"""GHCNh hourly observations served through the NCEI access API."""
+from ...registry.db import GHCNH_DB_PATH, metadata_db_connection_proxy
+from .source import GHCNhSource
+
+
+
+__all__ = ("GHCNhSource", "DATA_PATH")
+
+# packaged catalog: station facts and zone assignments as GHCNh's material
+# claims them, observation inventory, and quality ratings
+DATA_PATH = GHCNH_DB_PATH
+metadata_db_connection_proxy.register_attachment(
+ "ghcnh", DATA_PATH, catalog=True, inventory=True, quality=True
+)
diff --git a/eeweather/sources/ghcnh/ghcnh.db b/eeweather/sources/ghcnh/ghcnh.db
new file mode 100644
index 0000000..7499dcd
Binary files /dev/null and b/eeweather/sources/ghcnh/ghcnh.db differ
diff --git a/eeweather/sources/ghcnh/source.py b/eeweather/sources/ghcnh/source.py
new file mode 100644
index 0000000..5918ada
--- /dev/null
+++ b/eeweather/sources/ghcnh/source.py
@@ -0,0 +1,113 @@
+"""The GHCNh observation source served through the NCEI access API."""
+import io
+import time
+
+import pandas as pd
+import requests
+
+from ..base import Feed
+
+
+
+API_URL = "https://www.ncei.noaa.gov/access/services/data/v1"
+
+API_REQUEST_TRIES = 3
+
+API_RETRY_BACKOFF_SECONDS = 5
+
+API_TIMEOUT_SECONDS = 120
+
+DATASET = "global-historical-climatology-network-hourly"
+
+# one session for connection reuse across the many per-station-year requests
+_session = requests.Session()
+
+
+def _get(url, params): # pragma: no cover (mocked in tests via this seam)
+ return _session.get(url=url, params=params, timeout=API_TIMEOUT_SECONDS)
+
+
+class GHCNhSource(Feed):
+ """Observation source for the GHCNh dataset.
+
+ GHCNh's names and units for the canonical vocabulary are already
+ canonical, so no translation is applied.
+ """
+
+ name = "ghcnh"
+ id_namespace = "ghcn"
+ variables = (
+ "temperature",
+ "dew_point_temperature",
+ "relative_humidity",
+ "wind_speed",
+ "station_level_pressure",
+ "visibility",
+ )
+
+ def fetch_year(self, external_id, year, variables):
+ """Fetch one year of observations for a station.
+
+ Retried on connection errors and server errors, which the api
+ intermittently returns in bursts; client errors raise immediately.
+
+ Returns
+ -------
+ pandas.DataFrame
+ One column per requested variable, indexed by UTC observation
+ time. Observations sharing a timestamp are averaged. Values
+ the station did not report are NaN. Empty when the station
+ has no data for the year.
+ """
+ params = {
+ "dataset": DATASET,
+ # DATE is only included in the response when explicitly requested
+ "dataTypes": ",".join(("DATE",) + tuple(variables)),
+ "stations": external_id,
+ "startDate": "{}-01-01".format(year),
+ "endDate": "{}-12-31".format(year),
+ }
+
+ for attempt in range(API_REQUEST_TRIES):
+ try:
+ resp = _get(API_URL, params)
+ resp.raise_for_status()
+ except requests.HTTPError:
+ if resp.status_code < 500 or attempt == API_REQUEST_TRIES - 1:
+ raise
+ time.sleep(API_RETRY_BACKOFF_SECONDS * (attempt + 1))
+ except requests.RequestException:
+ if attempt == API_REQUEST_TRIES - 1:
+ raise
+ time.sleep(API_RETRY_BACKOFF_SECONDS * (attempt + 1))
+ else:
+ break
+
+ empty_index = pd.DatetimeIndex([], tz="UTC")
+ empty = pd.DataFrame(columns=list(variables), index=empty_index, dtype=float)
+ if resp.text.strip() == "":
+ return empty
+
+ raw = pd.read_csv(io.StringIO(resp.text), dtype=str)
+ if len(raw) == 0:
+ return empty
+ if "DATE" not in raw.columns:
+ raise ValueError(
+ "Malformed GHCNh response for station {} year {}: no DATE"
+ " column (the api returned a non-csv body).".format(
+ external_id, year
+ )
+ )
+
+ index = pd.to_datetime(raw["DATE"]).dt.tz_localize("UTC").rename(None)
+ df = pd.DataFrame(index=index)
+ for variable in variables:
+ if variable in raw.columns:
+ df[variable] = pd.to_numeric(raw[variable], errors="coerce").values
+ else:
+ df[variable] = float("nan")
+
+ df = df.groupby(df.index).mean()
+ df = df.sort_index()
+
+ return df
diff --git a/eeweather/sources/matching.py b/eeweather/sources/matching.py
new file mode 100644
index 0000000..714aa4b
--- /dev/null
+++ b/eeweather/sources/matching.py
@@ -0,0 +1,446 @@
+"""Candidate-station ranking and data-sufficiency selection."""
+from functools import cached_property
+
+import numpy as np
+import pandas as pd
+import pyproj
+
+from ..exceptions import DataNotAvailableError, EEWeatherWarning
+from ..registry.db import metadata_db_connection_proxy
+from ..registry.quality import get_station_qualities
+from ..registry.update import maybe_update
+from ..registry.zones import zones_at
+from ..station import WeatherStation
+
+
+
+__all__ = ("rank_stations", "combine_ranked_stations", "select_station")
+
+def _ranking_columns():
+ """Output columns: the fixed set plus one availability flag (and any
+ class column) per registered archive source."""
+ proxy = metadata_db_connection_proxy
+ columns = [
+ "rank",
+ "distance_meters",
+ "latitude",
+ "longitude",
+ "iecc_climate_zone",
+ "iecc_moisture_regime",
+ "ba_climate_zone",
+ "ca_climate_zone",
+ "quality",
+ "elevation",
+ "subdivision",
+ ]
+ frame_columns = cached_data.all_station_metadata.columns
+ for avail in sorted(proxy.availability_sources):
+ class_column = "{}_class".format(avail)
+ if class_column in frame_columns:
+ columns.append(class_column)
+ columns.append("is_{}".format(avail))
+ columns.append("difference_elevation_meters")
+
+ return columns
+
+
+class CachedData(object):
+ @cached_property
+ def all_station_metadata(self):
+ proxy = metadata_db_connection_proxy
+ conn = proxy.get_connection()
+ frames = []
+ for alias in proxy.catalogs:
+ frames.append(self._catalog_frame(conn, proxy, alias))
+ df = pd.concat(frames)
+ df = df[~df.index.duplicated(keep="first")].sort_index()
+
+ return df
+
+ def _catalog_frame(self, conn, proxy, alias):
+ availability_selects = "".join(
+ """
+ , max({avail}.station_id) is not null as is_{avail}
+ , max({avail}.class) as {avail}_class""".format(avail=avail)
+ if self._has_class(conn, avail) else
+ """
+ , max({avail}.station_id) is not null as is_{avail}""".format(avail=avail)
+ for avail in proxy.availability_sources
+ )
+ availability_joins = "".join(
+ """
+ left join {avail}.stations as {avail} on
+ s.station_id = {avail}.station_id""".format(avail=avail)
+ for avail in proxy.availability_sources
+ )
+ quality_select = ", null as quality"
+ quality_join = ""
+ if alias in proxy.quality_sources:
+ quality_select = ", max(q.quality) as quality"
+ quality_join = (
+ "\n left join {alias}.quality as q on"
+ " s.station_id = q.station_id".format(alias=alias)
+ )
+ df = pd.read_sql_query(
+ """
+ select
+ s.station_id
+ , s.latitude
+ , s.longitude
+ , max(case when z.system = 'iecc_climate_zone' then z.zone_id end)
+ as iecc_climate_zone
+ , max(case when z.system = 'iecc_moisture_regime' then z.zone_id end)
+ as iecc_moisture_regime
+ , max(case when z.system = 'ba_climate_zone' then z.zone_id end)
+ as ba_climate_zone
+ , max(case when z.system = 'ca_climate_zone' then z.zone_id end)
+ as ca_climate_zone
+ {quality_select}
+ , s.elevation
+ , s.subdivision
+ {availability_selects}
+ from
+ {alias}.stations as s
+ left join {alias}.station_zone as z on s.station_id = z.station_id
+ {quality_join}
+ {availability_joins}
+ group by s.station_id
+ order by s.station_id
+ """.format(
+ alias=alias,
+ quality_select=quality_select,
+ quality_join=quality_join,
+ availability_selects=availability_selects,
+ availability_joins=availability_joins,
+ ),
+ conn,
+ ).set_index("station_id")
+ for avail in proxy.availability_sources:
+ df["is_{}".format(avail)] = df["is_{}".format(avail)].astype(bool)
+
+ return df
+
+ @staticmethod
+ def _has_class(conn, alias):
+ columns = [
+ row[1]
+ for row in conn.execute("pragma {}.table_info(stations)".format(alias))
+ ]
+
+ return "class" in columns
+
+
+cached_data = CachedData()
+
+
+def source_availability_columns():
+ """Availability filters served by the packaged data, by source name.
+
+ Catalog sources map to None (every cataloged station serves them);
+ archive sources map to their availability flag column.
+ """
+ proxy = metadata_db_connection_proxy
+ columns = {alias: None for alias in proxy.catalogs}
+ for alias in proxy.availability_sources:
+ columns[alias] = "is_{}".format(alias)
+
+ return columns
+
+
+def _combine_filters(filters, index):
+ combined_filters = pd.Series(True, index=index)
+ for f in filters:
+ combined_filters &= f
+
+ return combined_filters
+
+
+def rank_stations(
+ site_latitude,
+ site_longitude,
+ site_elevation=None,
+ site_subdivision=None,
+ match_zones=(),
+ match_subdivision=False,
+ has_sources=(),
+ minimum_quality=None,
+ rating_period=None,
+ quality_source="ghcnh",
+ max_distance_meters=None,
+ max_difference_elevation_meters=None,
+):
+ """Get a ranked, filtered set of candidate weather stations and metadata
+ for a site.
+
+ Parameters
+ ----------
+ site_latitude : float
+ Latitude of the target site.
+ site_longitude : float
+ Longitude of the target site.
+ site_elevation : float, optional
+ Elevation of the target site in meters. Ignored unless
+ ``max_difference_elevation_meters`` is set.
+ site_subdivision : str, optional
+ Country subdivision of the target site (e.g. a US state
+ abbreviation). Ignored unless ``match_subdivision=True``.
+ match_zones : tuple of str
+ Zone systems the candidates must match the site in, e.g.
+ ``('iecc_climate_zone',)``. Candidates match a system the site has
+ no zone for only when they also have none.
+ match_subdivision : bool
+ If True, filter candidates to the site's subdivision.
+ has_sources : tuple of str
+ Sources the candidates must be able to serve, e.g. ``('tmy3',)``.
+ minimum_quality : {'high', 'medium', 'low'}, optional
+ Filter candidates to those meeting or exceeding this quality.
+ rating_period : datetime, optional
+ When given, quality is rated from observation counts over the five
+ calendar years ending two years after this anchor date (sliding
+ back to end no later than the last full year), and the ``quality``
+ column and ``minimum_quality`` filter use that rating. When None,
+ the build-time rating over the last five full years is used.
+ quality_source : str
+ Source whose observation counts rate quality for
+ ``rating_period``.
+ max_distance_meters : float, optional
+ Filter candidates to those within this distance of the site.
+ max_difference_elevation_meters : float, optional
+ Filter candidates to those with elevations within this difference
+ of ``site_elevation``.
+
+ Returns
+ -------
+ pandas.DataFrame
+ Indexed by station id, sorted by distance, one row per candidate:
+ rank, distance_meters, latitude, longitude, one column per zone
+ system, quality, elevation, subdivision, tmy3_class,
+ is_tmy3/is_cz2010, difference_elevation_meters.
+ """
+ maybe_update()
+ candidates = cached_data.all_station_metadata.copy()
+
+ candidates_defined_lat_long = candidates[
+ candidates.latitude.notnull() & candidates.longitude.notnull()
+ ]
+ candidates_latitude = candidates_defined_lat_long.latitude
+ candidates_longitude = candidates_defined_lat_long.longitude
+ tiled_site_latitude = np.tile(site_latitude, candidates_latitude.shape)
+ tiled_site_longitude = np.tile(site_longitude, candidates_longitude.shape)
+ geod = pyproj.Geod(ellps="WGS84")
+ dists = geod.inv(
+ tiled_site_longitude,
+ tiled_site_latitude,
+ candidates_longitude.values,
+ candidates_latitude.values,
+ )[2]
+ distance_meters = pd.Series(
+ dists, index=candidates_defined_lat_long.index
+ ).reindex(candidates.index)
+ candidates["distance_meters"] = distance_meters
+
+ if site_elevation is not None:
+ difference_elevation_meters = (candidates.elevation - site_elevation).abs()
+ else:
+ difference_elevation_meters = None
+ candidates["difference_elevation_meters"] = difference_elevation_meters
+
+ filters = []
+
+ if match_zones:
+ site_zones = zones_at(site_latitude, site_longitude)
+ for system in match_zones:
+ if system not in candidates.columns:
+ raise ValueError("Unknown zone system: {}".format(system))
+ site_zone = site_zones.get(system)
+ if site_zone is None:
+ filters.append(candidates[system].isnull())
+ else:
+ filters.append(candidates[system] == site_zone)
+
+ if match_subdivision:
+ if site_subdivision is None:
+ filters.append(candidates.subdivision.isnull())
+ else:
+ filters.append(candidates.subdivision == site_subdivision)
+
+ availability_columns = source_availability_columns()
+ for source in has_sources:
+ column = availability_columns.get(source, "unknown")
+ if column == "unknown":
+ raise ValueError("Unknown source: {}".format(source))
+ if column is not None:
+ filters.append(candidates[column])
+
+ if rating_period is not None:
+ period_qualities = get_station_qualities(rating_period, source=quality_source)
+ candidates["quality"] = period_qualities.reindex(
+ candidates.index, fill_value="low"
+ )
+
+ if minimum_quality is None:
+ pass
+ elif minimum_quality == "low":
+ filters.append(candidates.quality.isin(["high", "medium", "low"]))
+ elif minimum_quality == "medium":
+ filters.append(candidates.quality.isin(["high", "medium"]))
+ elif minimum_quality == "high":
+ filters.append(candidates.quality.isin(["high"]))
+ else:
+ raise ValueError("Unknown minimum_quality: {}".format(minimum_quality))
+
+ if max_distance_meters is not None:
+ filters.append(candidates.distance_meters <= max_distance_meters)
+
+ if max_difference_elevation_meters is not None and site_elevation is not None:
+ filters.append(
+ candidates.difference_elevation_meters <= max_difference_elevation_meters
+ )
+
+ combined_filters = _combine_filters(filters, candidates.index)
+ filtered_candidates = candidates[combined_filters]
+ ranked_filtered_candidates = filtered_candidates.sort_values(
+ by=["distance_meters"]
+ )
+
+ ranks = range(1, 1 + len(ranked_filtered_candidates))
+ ranked_filtered_candidates.insert(0, "rank", ranks)
+
+ return ranked_filtered_candidates[_ranking_columns()]
+
+
+def combine_ranked_stations(rankings):
+ """Combine ranked candidate frames into one fallback-ordered ranking.
+
+ Parameters
+ ----------
+ rankings : list of pandas.DataFrame
+ Ranked candidate frames of the form given by rank_stations, each
+ sorted by rank. Stations already present in an earlier frame are
+ dropped from later ones.
+
+ Returns
+ -------
+ pandas.DataFrame
+ One frame with a recomputed rank column.
+ """
+ if len(rankings) == 0:
+ raise ValueError("Requires at least one ranking.")
+
+ combined_ranking = rankings[0]
+ for ranking in rankings[1:]:
+ filtered_ranking = ranking[~ranking.index.isin(combined_ranking.index)]
+ combined_ranking = pd.concat([combined_ranking, filtered_ranking])
+
+ combined_ranking["rank"] = range(1, 1 + len(combined_ranking))
+
+ return combined_ranking
+
+
+def load_hourly_temp_data(
+ station, start_date, end_date, fetch_from_web, source=None
+): # pragma: no cover
+ df, warnings = station.load_data(
+ start_date, end_date, fetch_from_web=fetch_from_web, source=source
+ )
+ temps = df["temperature"]
+
+ return temps, warnings
+
+
+def select_station(
+ candidates,
+ coverage_range=None,
+ min_fraction_coverage=0.9,
+ distance_warnings=(50000, 200000),
+ rank=1,
+ fetch_from_web=True,
+ coverage_source=None,
+):
+ """Select a station from ranked candidates that meets data-sufficiency
+ criteria.
+
+ Parameters
+ ----------
+ candidates : pandas.DataFrame
+ A frame of the form given by rank_stations, having at least a
+ station-id index and a ``distance_meters`` column.
+ coverage_range : tuple of (datetime, datetime), optional
+ When given, candidates must serve temperature data covering at
+ least ``min_fraction_coverage`` of this period.
+ coverage_source : str or source object, optional
+ The source coverage is tested against; defaults to the
+ candidate's default routing (the source actually being served
+ should be passed, so a station rich in one source but absent
+ from another cannot pass selection wrongly).
+
+ Returns
+ -------
+ tuple of (WeatherStation or None, list of EEWeatherWarning)
+ The first candidate passing the criteria; None if none passes.
+ """
+
+ def _test_station(station):
+ no_warnings = []
+ if coverage_range is None:
+ return True, no_warnings
+
+ start_date, end_date = coverage_range
+ try:
+ tempC, warnings = load_hourly_temp_data(
+ station, start_date, end_date, fetch_from_web,
+ source=coverage_source,
+ )
+ except DataNotAvailableError:
+ return False, no_warnings
+
+ if len(tempC) == 0:
+ return False, no_warnings
+ fraction_coverage = tempC.notnull().sum() / float(len(tempC))
+ passed = fraction_coverage > min_fraction_coverage
+
+ return passed, warnings
+
+ def _station_warnings(station, distance_meters):
+ warnings = [
+ EEWeatherWarning(
+ qualified_name="eeweather.exceeds_maximum_distance",
+ description=(
+ "Distance from target to weather station is greater"
+ " than the specified km."
+ ),
+ data={
+ "distance_meters": distance_meters,
+ "max_distance_meters": d,
+ "rank": rank,
+ },
+ )
+ for d in distance_warnings
+ if distance_meters > d
+ ]
+
+ return warnings
+
+ n_stations_passed = 0
+ for station_id, row in candidates.iterrows():
+ station = WeatherStation(station_id)
+ test_result, warnings = _test_station(station)
+ if test_result:
+ n_stations_passed += 1
+ if n_stations_passed == rank:
+ warnings.extend(_station_warnings(station, row.distance_meters))
+
+ return station, warnings
+
+ no_station_warnings = [
+ EEWeatherWarning(
+ qualified_name="eeweather.no_weather_station_selected",
+ description=(
+ "No weather station found with the specified rank and"
+ " minimum fractional coverage."
+ ),
+ data={"rank": rank, "min_fraction_coverage": min_fraction_coverage},
+ )
+ ]
+
+ return None, no_station_warnings
diff --git a/eeweather/sources/station_source.py b/eeweather/sources/station_source.py
new file mode 100644
index 0000000..f41aa9c
--- /dev/null
+++ b/eeweather/sources/station_source.py
@@ -0,0 +1,204 @@
+"""The nearest-suitable-station estimation strategy."""
+from __future__ import annotations
+
+from datetime import datetime
+
+import pyproj
+
+from ..exceptions import NoQualifiedStationError
+from .base import Source
+from .engine import load_data as _engine_load_data
+from .engine import resolve_source
+from ..exceptions import EEWeatherWarning
+from .matching import rank_stations, select_station, source_availability_columns
+from ..station import WeatherStation
+
+
+
+# beyond this, a station is not a defensible temperature proxy for a
+# location without explicit acknowledgment; every US ZCTA has a station
+# within 124 km, so no ordinary location is stranded by the default
+DEFAULT_MAX_DISTANCE_METERS = 150_000
+
+
+class StationSource(Source):
+ """Estimates weather at a point from the nearest suitable station.
+
+ Parameters
+ ----------
+ dataset : str or Feed
+ The observation source served: a built-in name or a Feed object.
+ rank_kwargs, select_kwargs : dict, optional
+ Forwarded to rank_stations and select_station to control
+ filtering and coverage requirements.
+ """
+
+ def __init__(self, dataset="ghcnh", rank_kwargs=None, select_kwargs=None):
+ self.adapter = resolve_source(dataset)
+ self.name = self.adapter.name
+ self.kind = self.adapter.kind
+ self.variables = self.adapter.variables
+ self.default_variables = self.adapter.default_variables
+ rank_kwargs = dict(rank_kwargs or {})
+ if self.name in source_availability_columns():
+ # only consider stations that can serve this dataset
+ rank_kwargs.setdefault("has_sources", (self.name,))
+ rank_kwargs.setdefault("max_distance_meters", DEFAULT_MAX_DISTANCE_METERS)
+ self.rank_kwargs = rank_kwargs
+ self.select_kwargs = select_kwargs or {}
+ self._resolutions = {}
+
+ @property
+ def period_dependent(self):
+ """Whether a coverage or rating-period filter is configured, so
+ resolution depends on more than coordinates. Such a source is
+ pinned at load (where the request is known and any coverage fetch
+ is expected), not at serialization."""
+ configured = (
+ "coverage_range" in self.select_kwargs
+ or "rating_period" in self.rank_kwargs
+ )
+
+ return configured
+
+ def resolve(
+ self,
+ latitude: float,
+ longitude: float,
+ period: tuple[datetime, datetime] | None = None,
+ ignore_disqualification: bool = False,
+ station: str | None = None,
+ ):
+ """The station serving this point, with its distance and any
+ selection warnings.
+
+ Resolutions (including their warnings) are memoized per location
+ and period year-span; the same request reuses its station, a
+ different era re-resolves. Call :meth:`reset` to force
+ re-resolution.
+
+ With ``ignore_disqualification``, a point no station qualifies
+ for (distance cap, coverage test) gets the best available
+ station anyway, plus an ``eeweather.station_disqualified``
+ warning carrying what failed; NoQualifiedStationError then only
+ remains for a point with no candidates at all.
+ """
+ if station is not None:
+ return self._resolve_pinned(latitude, longitude, station)
+
+ key = (round(latitude, 6), round(longitude, 6), ignore_disqualification)
+ if period is not None:
+ start, end = period
+ key = key + (start.year, end.year)
+ if key in self._resolutions:
+ station, distance_meters, select_warnings = self._resolutions[key]
+
+ return station, distance_meters, list(select_warnings)
+
+ select_kwargs = dict(self.select_kwargs)
+ if "coverage_range" in select_kwargs:
+ select_kwargs.setdefault("coverage_source", self.adapter)
+ candidates = rank_stations(latitude, longitude, **self.rank_kwargs)
+ station, select_warnings = select_station(candidates, **select_kwargs)
+
+ if station is None and ignore_disqualification:
+ station, select_warnings, candidates = self._resolve_disqualified(
+ latitude, longitude
+ )
+ if station is None:
+ raise NoQualifiedStationError(latitude, longitude)
+ distance_meters = float(candidates.loc[station.id, "distance_meters"])
+ self._resolutions[key] = (station, distance_meters, list(select_warnings))
+
+ return station, distance_meters, list(select_warnings)
+
+ def _resolve_pinned(self, latitude, longitude, station_id):
+ """The pinned station, with its true distance to the point; an
+ explicit pin is its own acceptance, so qualification is skipped."""
+ station = WeatherStation(station_id)
+ geod = pyproj.Geod(ellps="WGS84")
+ distance_meters = float(
+ geod.inv(longitude, latitude, station.longitude, station.latitude)[2]
+ )
+ no_warnings = []
+
+ return station, distance_meters, no_warnings
+
+ def _resolve_disqualified(self, latitude, longitude):
+ """The best station ignoring the distance cap and coverage test,
+ with a warning stating which qualifications it failed."""
+ rank_kwargs = dict(self.rank_kwargs)
+ max_distance = rank_kwargs.pop("max_distance_meters", None)
+ candidates = rank_stations(latitude, longitude, **rank_kwargs)
+ select_kwargs = {
+ k: v for k, v in self.select_kwargs.items()
+ if k not in ("coverage_range", "min_fraction_coverage")
+ }
+ station, select_warnings = select_station(candidates, **select_kwargs)
+ if station is not None:
+ failed = []
+ distance_meters = float(candidates.loc[station.id, "distance_meters"])
+ if max_distance is not None and distance_meters > max_distance:
+ failed.append("distance")
+ if "coverage_range" in self.select_kwargs:
+ failed.append("coverage")
+ select_warnings = list(select_warnings)
+ select_warnings.append(
+ EEWeatherWarning(
+ qualified_name="eeweather.station_disqualified",
+ description=(
+ "No station qualified; using the best available"
+ " with qualification ignored."
+ ),
+ data={
+ "station_id": station.id,
+ "failed": failed,
+ "distance_meters": distance_meters,
+ "max_distance_meters": max_distance,
+ },
+ )
+ )
+
+ return station, select_warnings, candidates
+
+ def reset(self) -> None:
+ """Drop memoized station resolutions."""
+ self._resolutions = {}
+
+ def estimate(
+ self,
+ latitude: float,
+ longitude: float,
+ start: datetime,
+ end: datetime,
+ frequency="h",
+ variables=None,
+ raise_when_empty=True,
+ ignore_disqualification=False,
+ station=None,
+ **load_kwargs,
+ ):
+ station, distance_meters, select_warnings = self.resolve(
+ latitude, longitude, period=(start, end),
+ ignore_disqualification=ignore_disqualification,
+ station=station,
+ )
+
+ df, load_warnings = _engine_load_data(
+ station.id,
+ start,
+ end,
+ frequency=frequency,
+ variables=variables,
+ source=self.adapter,
+ raise_when_empty=raise_when_empty,
+ **load_kwargs,
+ )
+ provenance = {
+ name: record._replace(distance_meters=distance_meters)
+ for name, record in df.attrs["provenance"].items()
+ }
+ df.attrs["provenance"] = provenance
+ warnings = select_warnings + load_warnings
+
+ return df, warnings, provenance
diff --git a/eeweather/sources/tmy3/__init__.py b/eeweather/sources/tmy3/__init__.py
new file mode 100644
index 0000000..b9155c2
--- /dev/null
+++ b/eeweather/sources/tmy3/__init__.py
@@ -0,0 +1,15 @@
+"""NREL TMY3 typical-year temperatures."""
+import os
+
+from ...registry.db import metadata_db_connection_proxy
+from .source import TMY3Source
+
+
+
+__all__ = ("TMY3Source", "DATA_PATH")
+
+# packaged archive station list
+DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tmy3.db")
+metadata_db_connection_proxy.register_attachment(
+ "tmy3", DATA_PATH, availability=True
+)
diff --git a/eeweather/sources/tmy3/source.py b/eeweather/sources/tmy3/source.py
new file mode 100644
index 0000000..29efe7b
--- /dev/null
+++ b/eeweather/sources/tmy3/source.py
@@ -0,0 +1,15 @@
+"""NREL TMY3 typical-year temperatures."""
+from ..base import NormalsSource
+
+
+
+class TMY3Source(NormalsSource):
+ name = "tmy3"
+
+ def _url(self, usaf_id):
+ url = (
+ "https://storage.googleapis.com/openeemeter-public-resources/"
+ "tmy3_archive/{}TYA.CSV".format(usaf_id)
+ )
+
+ return url
diff --git a/eeweather/sources/tmy3/tmy3.db b/eeweather/sources/tmy3/tmy3.db
new file mode 100644
index 0000000..cf6ebce
Binary files /dev/null and b/eeweather/sources/tmy3/tmy3.db differ
diff --git a/eeweather/sources/vocabulary.py b/eeweather/sources/vocabulary.py
new file mode 100644
index 0000000..d2a41a2
--- /dev/null
+++ b/eeweather/sources/vocabulary.py
@@ -0,0 +1,111 @@
+"""The variable vocabulary.
+
+Every variable a source can serve has exactly one name, unit, and
+definition. The canonical entries are owned here; external sources add
+entries at runtime through ``eeweather.sources.register``, with the same
+one-definition rule (canonical names are reserved, and the first
+registration of a name freezes its unit). Adapters translate their
+native names and units into vocabulary forms at ingest; users never see
+native forms. Columns ending in the reserved suffixes are
+engine-produced companions of a variable and are exempt from vocabulary
+validation.
+"""
+from collections import namedtuple
+
+
+
+Variable = namedtuple(
+ "Variable", ["name", "unit", "description", "aggregation"]
+)
+
+# how hourly values roll up to daily: point-in-time variables average;
+# accumulation variables ("sum") add up and are never gap-interpolated
+AGGREGATIONS = ("mean", "sum", "min", "max")
+
+VARIABLES = {
+ variable.name: variable
+ for variable in (
+ Variable("temperature", "degC", "Air temperature.", "mean"),
+ Variable(
+ "dew_point_temperature", "degC", "Dew point temperature.", "mean"
+ ),
+ Variable("relative_humidity", "%", "Relative humidity.", "mean"),
+ Variable("wind_speed", "m/s", "Wind speed.", "mean"),
+ Variable(
+ "station_level_pressure",
+ "hPa",
+ "Atmospheric pressure at station level.",
+ "mean",
+ ),
+ Variable("visibility", "km", "Horizontal visibility distance.", "mean"),
+ )
+}
+
+RESERVED_SUFFIXES = ("_imputed_fraction", "_is_imputed")
+
+_registered_variables = {}
+
+
+def register_variables(entries):
+ """Add external vocabulary entries; called through sources.register.
+
+ All entries are validated before any is added: canonical names are
+ reserved, every entry needs a name, unit, and description, and a
+ name registered twice must carry an identical definition, so a
+ variable's unit is frozen by whichever source registers it first.
+ """
+ validated = {}
+ for entry in entries:
+ entry = Variable(*entry)
+ if not entry.name or not entry.unit or not entry.description:
+ raise ValueError(
+ "A variable needs a name, unit, description, and"
+ " aggregation, got: {}.".format(tuple(entry))
+ )
+ if entry.aggregation not in AGGREGATIONS:
+ raise ValueError(
+ "Unknown aggregation '{}' for '{}'; one of: {}.".format(
+ entry.aggregation, entry.name, ", ".join(AGGREGATIONS)
+ )
+ )
+ if entry.name in VARIABLES:
+ raise ValueError(
+ "'{}' is canonical; serve it in its canonical unit"
+ " ({}).".format(entry.name, VARIABLES[entry.name].unit)
+ )
+ existing = _registered_variables.get(entry.name) or validated.get(entry.name)
+ if existing is not None and existing != entry:
+ raise ValueError(
+ "'{}' is already registered as {}; definitions must"
+ " agree.".format(entry.name, tuple(existing))
+ )
+ validated[entry.name] = entry
+ _registered_variables.update(validated)
+
+
+def all_variables():
+ """The full vocabulary, canonical plus registered, by name."""
+ return {**VARIABLES, **_registered_variables}
+
+
+def valid_variables_or_raise(variables):
+ """Raise ValueError when a requested variable is not in the
+ vocabulary."""
+ known = all_variables()
+ unknown = [name for name in variables if name not in known]
+ if unknown:
+ raise ValueError(
+ "Unknown variables: {}. Known variables: {}.".format(
+ ", ".join(sorted(unknown)), ", ".join(sorted(known))
+ )
+ )
+
+
+def aggregation_for(column):
+ """The daily-rollup aggregation for a frame column; engine-produced
+ companion columns and anything else outside the vocabulary average."""
+ entry = all_variables().get(column)
+ if entry is None:
+ return "mean"
+
+ return entry.aggregation
diff --git a/eeweather/station.py b/eeweather/station.py
new file mode 100644
index 0000000..7d375a8
--- /dev/null
+++ b/eeweather/station.py
@@ -0,0 +1,265 @@
+"""The pinned-station handle."""
+from __future__ import annotations
+
+from datetime import datetime
+
+from .registry.db import valid_station_id_or_raise
+from .registry.identifiers import resolve_station
+from .registry.identifiers import translate as _translate
+from .registry.metadata import get_station_metadata
+from .registry.quality import get_station_quality
+from .registry.summaries import search_stations
+from .sources.engine import load_data as _engine_load_data
+from .sources.engine import resolve_source
+
+
+
+__all__ = ("WeatherStation",)
+
+
+def _validate_sources(sources):
+ """Reject a preference tuple with a duplicate or non-observation
+ source; routing only ever considers observation sources."""
+ adapters = [resolve_source(source) for source in sources]
+ names = [adapter.name for adapter in adapters]
+ if len(set(names)) != len(names):
+ raise ValueError(
+ "Duplicate source names in preference tuple: {}.".format(
+ ", ".join(names)
+ )
+ )
+ wrong_kind = [adapter.name for adapter in adapters if adapter.kind != "observations"]
+ if wrong_kind:
+ raise ValueError(
+ "Sources preference only routes observation sources; pin"
+ " normals sources (e.g. 'tmy3', 'cz2010') with load_data("
+ "source=...) instead: {}.".format(", ".join(wrong_kind))
+ )
+
+
+class WeatherStation(object):
+ """A known weather station in the registry.
+
+ Stations are keyed by their GHCN id; other identifier systems are
+ aliases — use the ``from_id``, ``from_usaf``, ``from_wban``, or
+ ``from_icao`` constructors to look a station up by one.
+
+ Attributes
+ ----------
+ id : str
+ The station's GHCN id, the registry key.
+ ids : dict of str to list of str
+ External identifiers by namespace, e.g.
+ ``{"usaf": ["722880"], "wban": ["23152"], "icao": ["KBUR"]}``.
+ name : str
+ Station name.
+ latitude, longitude, elevation : float
+ Station location; elevation in meters.
+ coords : tuple of (float, float)
+ Latitude/longitude pair.
+ country : str
+ ISO 3166-1 alpha-2 country code.
+ subdivision : str or None
+ Country subdivision (e.g. a US state abbreviation); None where the
+ registry has none.
+ quality : str
+ Build-time data-quality rating: "high", "medium", or "low".
+ zones : dict of str to str
+ Zone id by zone system, e.g. ``{"iecc_climate_zone": "3"}``.
+ inventory_years : dict of str to tuple of (int, int)
+ First and last inventory year by source.
+ """
+
+ def __init__(self, station_id: str, sources=("ghcnh",), load_metadata: bool = True):
+ self.id = station_id
+ if isinstance(sources, str):
+ sources = (sources,)
+ sources = tuple(sources)
+ _validate_sources(sources)
+ self.sources = sources
+ self.provenance = None
+
+ if load_metadata:
+ self._load_metadata()
+ else:
+ valid_station_id_or_raise(station_id)
+ self.ids = None
+ self.name = None
+ self.latitude = None
+ self.longitude = None
+ self.elevation = None
+ self.coords = None
+ self.country = None
+ self.subdivision = None
+ self.quality = None
+ self.zones = {}
+ self.inventory_years = {}
+
+ @classmethod
+ def from_id(cls, namespace: str, external_id: str, load_metadata: bool = True) -> "WeatherStation":
+ """Construct a station from an external identifier.
+
+ When the identifier maps to several stations, the mapping marked
+ recent wins; unresolvable identifiers raise
+ AmbiguousIdentifierError.
+ """
+ station_id = resolve_station(namespace, external_id)
+
+ return cls(station_id, load_metadata=load_metadata)
+
+ @classmethod
+ def from_usaf(cls, usaf_id: str, load_metadata: bool = True) -> "WeatherStation":
+ """Construct a station from a historical ISD USAF id."""
+ return cls.from_id("usaf", usaf_id, load_metadata=load_metadata)
+
+ @classmethod
+ def from_wban(cls, wban_id: str, load_metadata: bool = True) -> "WeatherStation":
+ """Construct a station from a historical WBAN id."""
+ return cls.from_id("wban", wban_id, load_metadata=load_metadata)
+
+ @classmethod
+ def from_icao(cls, icao_code: str, load_metadata: bool = True) -> "WeatherStation":
+ """Construct a station from an ICAO airport code."""
+ return cls.from_id("icao", icao_code, load_metadata=load_metadata)
+
+ @classmethod
+ def search(cls, country: str | None = None, subdivision: str | None = None, has_sources=()):
+ """Registry stations as a DataFrame indexed by station id.
+
+ Parameters
+ ----------
+ country : str, optional
+ ISO 3166-1 alpha-2 country code, e.g. ``'US'``.
+ subdivision : str, optional
+ Country subdivision, e.g. a US state abbreviation.
+ has_sources : tuple of str
+ Sources the stations must be able to serve, e.g.
+ ``('tmy3',)``.
+ """
+ stations = search_stations(
+ country=country, subdivision=subdivision, has_sources=has_sources
+ )
+
+ return stations
+
+ @staticmethod
+ def translate(ids, from_namespace: str, to_namespace: str) -> dict[str, tuple[str, ...]]:
+ """Translate external station ids between identifier systems.
+
+ Values are always tuples; mappings can be one-to-many in either
+ direction. Ids with no mapping are absent from the result.
+ """
+ return _translate(ids, from_namespace, to_namespace)
+
+ def __str__(self) -> str:
+ return self.id
+
+ def __repr__(self) -> str:
+ return "WeatherStation('{}')".format(self.id)
+
+ def _load_metadata(self):
+ metadata = get_station_metadata(self.id)
+
+ self.ids = metadata["ids"]
+ self.name = metadata["name"]
+ self.latitude = metadata["latitude"]
+ self.longitude = metadata["longitude"]
+ self.elevation = metadata["elevation"]
+ self.coords = (self.latitude, self.longitude)
+ self.country = metadata["country"]
+ self.subdivision = metadata["subdivision"]
+ self.quality = metadata["quality"]
+ self.zones = metadata["zones"]
+ self.inventory_years = metadata["inventory_years"]
+
+ def json(self) -> dict:
+ """Return a JSON-serializeable object containing station metadata."""
+ serialized = {
+ "id": self.id,
+ "ids": self.ids,
+ "name": self.name,
+ "latitude": self.latitude,
+ "longitude": self.longitude,
+ "elevation": self.elevation,
+ "country": self.country,
+ "subdivision": self.subdivision,
+ "quality": self.quality,
+ "zones": self.zones,
+ "inventory_years": self.inventory_years,
+ }
+
+ return serialized
+
+ def load_data(
+ self,
+ start: datetime,
+ end: datetime,
+ frequency="h",
+ variables=None,
+ source=None,
+ read_from_cache: bool = True,
+ write_to_cache: bool = True,
+ fetch_from_web: bool = True,
+ ):
+ """Load this station's weather data between two dates (inclusive).
+
+ Parameters
+ ----------
+ start : datetime.datetime
+ The earliest date from which to load data. Must be UTC.
+ end : datetime.datetime
+ The latest date until which to load data. Must be UTC.
+ frequency : str or pandas offset
+ A pandas offset alias (e.g. ``'30min'``, ``'h'``, ``'D'``,
+ ``'W'``, ``'MS'``, ``'YS'``). Coarser-than-hourly
+ frequencies aggregate the hourly values within each UTC
+ period by each variable's vocabulary aggregation;
+ finer-than-hourly frequencies interpolate.
+ variables : tuple of str, or 'all'
+ Canonical variable names; defaults to ``('temperature',)``.
+ Each is routed to the first source in this station's
+ ``sources`` preference that serves it; ``'all'`` is every
+ variable those sources serve.
+ source : str or source object, optional
+ Pin every requested variable to this source, bypassing
+ routing. Typical-year sources (``'tmy3'``, ``'cz2010'``) are
+ only reachable this way. A pinned load with nothing at all
+ for the requested dates raises ``DataNotAvailableError``; a
+ routed load (``source`` unset) never raises for missing
+ data, returning NaN plus warnings instead.
+ read_from_cache : bool
+ Whether or not to load data from cache.
+ write_to_cache : bool
+ Whether or not to write newly loaded data to cache.
+ fetch_from_web : bool
+ Whether or not to fetch data from the web.
+
+ Returns
+ -------
+ tuple of (pandas.DataFrame, list of EEWeatherWarning)
+ One column per requested variable, indexed over the full
+ requested range at the requested frequency; periods without
+ data are NaN. The frame's ``attrs["provenance"]`` (also
+ recorded on ``self.provenance``) maps each source used to a
+ Provenance record.
+ """
+ df, warnings = _engine_load_data(
+ self.id,
+ start,
+ end,
+ frequency=frequency,
+ variables=variables,
+ source=source,
+ sources=self.sources,
+ read_from_cache=read_from_cache,
+ write_to_cache=write_to_cache,
+ fetch_from_web=fetch_from_web,
+ )
+ self.provenance = df.attrs["provenance"]
+
+ return df, warnings
+
+ def get_quality(self, anchor: datetime, source: str | None = None) -> str:
+ """Station quality anchored to a date, from a source's observation
+ counts; defaults to the first registered source with inventory."""
+ return get_station_quality(self.id, anchor, source=source)
diff --git a/eeweather/stations.py b/eeweather/stations.py
deleted file mode 100644
index 2158127..0000000
--- a/eeweather/stations.py
+++ /dev/null
@@ -1,1272 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-from datetime import datetime, timedelta, timezone
-
-import numpy as np
-import pandas as pd
-import pytz
-
-import requests
-
-from .exceptions import (
- UnrecognizedUSAFIDError,
- DataNotAvailableError,
- TMY3DataNotAvailableError,
- CZ2010DataNotAvailableError,
- NonUTCTimezoneInfoError,
-)
-from .validation import valid_usaf_id_or_raise
-from .warnings import EEWeatherWarning
-import eeweather.connections
-from eeweather.connections import metadata_db_connection_proxy
-import eeweather.mockable
-from .sources.ghcnh import DEFAULT_VARIABLES, fetch_ghcnh_hourly
-
-DATA_EXPIRATION_DAYS = 1
-
-__all__ = (
- "WeatherStation",
- "get_ghcn_id",
- "get_isd_station_metadata",
- "get_station_quality",
- "get_station_qualities",
- "get_isd_file_metadata",
- "fetch_hourly_data",
- "get_hourly_data_cache_key",
- "get_tmy3_hourly_temp_data_cache_key",
- "get_cz2010_hourly_temp_data_cache_key",
- "cached_hourly_data_is_expired",
- "validate_hourly_data_cache",
- "validate_tmy3_hourly_temp_data_cache",
- "validate_cz2010_hourly_temp_data_cache",
- "serialize_hourly_data",
- "serialize_tmy3_hourly_temp_data",
- "serialize_cz2010_hourly_temp_data",
- "deserialize_hourly_data",
- "deserialize_tmy3_hourly_temp_data",
- "deserialize_cz2010_hourly_temp_data",
- "read_hourly_data_from_cache",
- "read_tmy3_hourly_temp_data_from_cache",
- "read_cz2010_hourly_temp_data_from_cache",
- "write_hourly_data_to_cache",
- "write_tmy3_hourly_temp_data_to_cache",
- "write_cz2010_hourly_temp_data_to_cache",
- "destroy_cached_hourly_data",
- "destroy_cached_tmy3_hourly_temp_data",
- "destroy_cached_cz2010_hourly_temp_data",
- "load_hourly_data_cached_proxy",
- "load_tmy3_hourly_temp_data_cached_proxy",
- "load_cz2010_hourly_temp_data_cached_proxy",
- "load_data",
- "load_tmy3_hourly_temp_data",
- "load_cz2010_hourly_temp_data",
- "load_cached_hourly_data",
- "load_cached_tmy3_hourly_temp_data",
- "load_cached_cz2010_hourly_temp_data",
-)
-
-
-def _datetime_is_utc(dt):
- orig_tzinfo = dt.tzinfo
- return False if orig_tzinfo is None else dt.utcoffset().seconds == 0
-
-
-TRAILING_GAP_WARNING_THRESHOLD = timedelta(days=1)
-INTERNAL_GAP_WARNING_THRESHOLD = timedelta(days=7)
-
-
-def _data_gap_warnings(ts, variable):
- """EEWeatherWarnings for requested ranges the returned data does not cover.
-
- Emitted when the series is entirely empty, when it ends more than
- TRAILING_GAP_WARNING_THRESHOLD before the end of the requested range, or
- when it contains an internal gap longer than INTERNAL_GAP_WARNING_THRESHOLD.
- """
- warnings = []
- if len(ts) == 0:
- return warnings
-
- if ts.isna().all():
- warnings.append(
- EEWeatherWarning(
- qualified_name="eeweather.no_data_in_requested_range",
- description="No data was available within the requested range.",
- data={
- "variable": variable,
- "requested_start": ts.index[0].isoformat(),
- "requested_end": ts.index[-1].isoformat(),
- },
- )
- )
-
- return warnings
-
- last_valid = ts.last_valid_index()
- trailing_gap = ts.index[-1] - last_valid
- if trailing_gap > TRAILING_GAP_WARNING_THRESHOLD:
- warnings.append(
- EEWeatherWarning(
- qualified_name="eeweather.data_truncated",
- description=(
- "Data ends {} before the end of the requested range.".format(
- trailing_gap
- )
- ),
- data={
- "variable": variable,
- "last_valid": last_valid.isoformat(),
- "requested_end": ts.index[-1].isoformat(),
- },
- )
- )
-
- interior = ts.loc[ts.first_valid_index() : last_valid]
- if len(interior) > 1:
- period = interior.index[1] - interior.index[0]
- is_missing = interior.isna()
- max_gap_periods = int(is_missing.groupby((~is_missing).cumsum()).sum().max())
- max_gap = max_gap_periods * period
- if max_gap > INTERNAL_GAP_WARNING_THRESHOLD:
- warnings.append(
- EEWeatherWarning(
- qualified_name="eeweather.data_gap",
- description=(
- "Data contains an internal gap of {}.".format(max_gap)
- ),
- data={
- "variable": variable,
- "max_gap_days": max_gap / timedelta(days=1),
- },
- )
- )
-
- return warnings
-
-
-def get_ghcn_id(usaf_id):
- """GHCNh station id mapped to this USAF id."""
- metadata = get_isd_station_metadata(usaf_id)
-
- return metadata["ghcn_id"]
-
-
-def fetch_hourly_data(usaf_id, year, variables=DEFAULT_VARIABLES):
- """Fetch one year of GHCNh observations resampled to an hourly frame.
-
- Raises DataNotAvailableError when the station has no observations at
- all for the year.
- """
- ghcn_id = get_ghcn_id(usaf_id)
- raw = fetch_ghcnh_hourly(ghcn_id, year, variables)
- if len(raw) == 0:
- raise DataNotAvailableError(usaf_id, year)
-
- # CalTRACK 2.3.3
- df = (
- raw.resample("min")
- .mean()
- .interpolate(method="linear", limit=60, limit_direction="both")
- .resample("h")
- .mean()
- )
-
- return df
-
-
-def get_hourly_data_cache_key(usaf_id, year):
- return "ghcnh-hourly-{}-{}".format(usaf_id, year)
-
-
-def cached_hourly_data_is_expired(usaf_id, year):
- key = get_hourly_data_cache_key(usaf_id, year)
- store = eeweather.connections.key_value_store_proxy.get_store()
- last_updated = store.key_updated(key)
-
- return _expired(last_updated, year)
-
-
-def validate_hourly_data_cache(usaf_id, year):
- key = get_hourly_data_cache_key(usaf_id, year)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- # fail if no key
- if not store.key_exists(key):
- return False
-
- # check for expired data, fail if so
- if cached_hourly_data_is_expired(usaf_id, year):
- store.clear(key)
- return False
-
- return True
-
-
-def serialize_hourly_data(df):
- rows = [
- [index.strftime("%Y%m%d%H")] + values
- for index, values in zip(
- df.index, df.astype(object).where(df.notna(), None).values.tolist()
- )
- ]
-
- return {"columns": list(df.columns), "rows": rows}
-
-
-def deserialize_hourly_data(data):
- index = pd.to_datetime(
- [row[0] for row in data["rows"]], format="%Y%m%d%H", utc=True
- )
- df = pd.DataFrame(
- [row[1:] for row in data["rows"]],
- index=index,
- columns=data["columns"],
- dtype=float,
- )
-
- return df.sort_index().resample("h").mean()
-
-
-def read_hourly_data_from_cache(usaf_id, year):
- key = get_hourly_data_cache_key(usaf_id, year)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- return deserialize_hourly_data(store.retrieve_json(key))
-
-
-def write_hourly_data_to_cache(usaf_id, year, df):
- key = get_hourly_data_cache_key(usaf_id, year)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- return store.save_json(key, serialize_hourly_data(df))
-
-
-def destroy_cached_hourly_data(usaf_id, year):
- key = get_hourly_data_cache_key(usaf_id, year)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- return store.clear(key)
-
-
-def load_hourly_data_cached_proxy(
- usaf_id,
- year,
- variables=DEFAULT_VARIABLES,
- read_from_cache=True,
- write_to_cache=True,
- fetch_from_web=True,
-):
- """One year of hourly data, from cache when it covers the request.
-
- A cache entry serves the request when it is fresh and holds every
- requested variable. Fetches request the union of the requested and
- already-cached variables so a cache refresh never drops columns.
- """
- variables = tuple(variables)
- cached = None
- if validate_hourly_data_cache(usaf_id, year):
- cached = read_hourly_data_from_cache(usaf_id, year)
-
- cache_covers_request = cached is not None and set(variables) <= set(cached.columns)
- if read_from_cache and cache_covers_request:
- return cached[list(variables)]
-
- if not fetch_from_web:
- raise DataNotAvailableError(usaf_id, year)
-
- cached_columns = () if cached is None else tuple(cached.columns)
- fetch_variables = tuple(dict.fromkeys(variables + cached_columns))
- df = fetch_hourly_data(usaf_id, year, fetch_variables)
- if write_to_cache:
- write_hourly_data_to_cache(usaf_id, year, df)
-
- return df[list(variables)]
-
-
-def load_data(
- usaf_id,
- start,
- end,
- frequency="hourly",
- variables=DEFAULT_VARIABLES,
- read_from_cache=True,
- write_to_cache=True,
- fetch_from_web=True,
- error_on_missing_years=True,
-):
- """Load a station's weather data between two dates (inclusive).
-
- This is the primary interface for loading observed weather data.
-
- Parameters
- ----------
- usaf_id : str
- Station USAF id.
- start : datetime.datetime
- The earliest date from which to load data. Must be UTC.
- end : datetime.datetime
- The latest date until which to load data. Must be UTC.
- frequency : str
- ``'hourly'`` or ``'daily'``. Daily values are means of the hourly
- values within each day.
- variables : tuple of str
- GHCNh variable names. Temperatures are degrees Celsius; units for
- other variables follow the GHCNh documentation.
- read_from_cache : bool
- Whether or not to load data from cache.
- write_to_cache : bool
- Whether or not to write newly loaded data to cache.
- fetch_from_web : bool
- Whether or not to fetch data from the web.
- error_on_missing_years : bool
- Whether to raise when data is unavailable for a year in the range,
- or to warn and fill that year with NaN.
-
- Returns
- -------
- tuple of (pandas.DataFrame, list of EEWeatherWarning)
- One column per requested variable, indexed over the full requested
- range at the requested frequency; periods without data are NaN.
- Warnings describe years with no data and gaps in the returned data.
- """
- # CalTRACK 2.3.3
- if not _datetime_is_utc(start):
- raise NonUTCTimezoneInfoError(start)
- if not _datetime_is_utc(end):
- raise NonUTCTimezoneInfoError(end)
-
- if frequency == "hourly":
- freq = "h"
- elif frequency == "daily":
- freq = "D"
- else:
- raise ValueError(
- "frequency must be 'hourly' or 'daily', got: {}".format(frequency)
- )
-
- variables = tuple(variables)
- warnings = []
- data = []
- for year in range(start.year, end.year + 1):
- try:
- data.append(
- load_hourly_data_cached_proxy(
- usaf_id,
- year,
- variables=variables,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- )
- )
- except DataNotAvailableError:
- if error_on_missing_years:
- raise
- warnings.append(
- EEWeatherWarning(
- qualified_name="eeweather.data_not_available",
- description="Data not available",
- data={"usaf_id": usaf_id, "year": year},
- )
- )
-
- if data:
- df = pd.concat(data)
- if frequency == "daily":
- df = df.resample("D").mean()
- df = df[start:end]
- else:
- empty_index = pd.DatetimeIndex([], tz=pytz.UTC)
- df = pd.DataFrame(columns=list(variables), index=empty_index, dtype=float)
-
- # because start and end dates need to fall exactly on period boundaries
- if frequency == "hourly":
- range_start = datetime(
- start.year, start.month, start.day, start.hour, tzinfo=pytz.UTC
- )
- if range_start < start:
- range_start += timedelta(hours=1)
- range_end = datetime(end.year, end.month, end.day, end.hour, tzinfo=pytz.UTC)
- else:
- range_start = datetime(start.year, start.month, start.day, tzinfo=pytz.UTC)
- if range_start < start:
- range_start += timedelta(days=1)
- range_end = datetime(end.year, end.month, end.day, tzinfo=pytz.UTC)
-
- # fill in gaps, covering the full requested range even when no data loaded
- df = df.reindex(pd.date_range(range_start, range_end, freq=freq, tz=pytz.UTC))
- for variable in variables:
- warnings.extend(_data_gap_warnings(df[variable], variable))
-
- return df, warnings
-
-
-def load_cached_hourly_data(usaf_id):
- """All cached hourly data for a station, or None when none is cached."""
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- data = [
- read_hourly_data_from_cache(usaf_id, year)
- for year in range(2000, datetime.now().year + 1)
- if store.key_exists(get_hourly_data_cache_key(usaf_id, year))
- ]
- if data == []:
- return None
-
- return pd.concat(data).resample("h").mean()
-
-
-def get_isd_station_metadata(usaf_id):
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- *
- from
- isd_station_metadata
- where
- usaf_id = ?
- """,
- (usaf_id,),
- )
- row = cur.fetchone()
- if row is None:
- raise UnrecognizedUSAFIDError(usaf_id)
- return {col[0]: row[i] for i, col in enumerate(cur.description)}
-
-
-GHCN_INVENTORY_MONTH_COLUMNS = (
- "jan", "feb", "mar", "apr", "may", "jun",
- "jul", "aug", "sep", "oct", "nov", "dec",
-)
-
-
-QUALITY_WINDOW_YEARS = 5
-
-# every month of the rating window above these observation counts
-HIGH_MONTHLY_OBSERVATIONS = 600
-MEDIUM_MONTHLY_OBSERVATIONS = 360
-
-
-def _quality_rating_window(start, end):
- """Calendar years rating a request: five years ending two years after
- the request's last date, sliding back to end no later than the last
- full year."""
- last_full_year = datetime.now().year - 1
- window_end = min(end.year + 2, last_full_year)
- window_start = window_end - (QUALITY_WINDOW_YEARS - 1)
-
- return window_start, window_end
-
-
-def _quality_from_minimum(minimum):
- if minimum > HIGH_MONTHLY_OBSERVATIONS:
- return "high"
- elif minimum > MEDIUM_MONTHLY_OBSERVATIONS:
- return "medium"
-
- return "low"
-
-
-def get_station_quality(usaf_id, start, end):
- """Station quality for a request period, from GHCNh observation counts.
-
- Rates the five calendar years ending two years after the request's
- last date (sliding back so the window ends no later than the last
- full year): every month over 600 observations is high, over 360 is
- medium; anything less, including absent months or years, is low.
- """
- window_start, window_end = _quality_rating_window(start, end)
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select year, {}
- from ghcn_inventory
- where usaf_id = ? and year between ? and ?
- """.format(
- ", ".join(GHCN_INVENTORY_MONTH_COLUMNS)
- ),
- (usaf_id, window_start, window_end),
- )
- counts_by_year = {row[0]: row[1:] for row in cur.fetchall()}
-
- minimum = None
- for year in range(window_start, window_end + 1):
- year_counts = counts_by_year.get(year, (0,) * 12)
- year_minimum = min(year_counts)
- if minimum is None or year_minimum < minimum:
- minimum = year_minimum
-
- return _quality_from_minimum(minimum)
-
-
-def get_station_qualities(start, end):
- """Quality for a request period for every station, as a usaf_id-indexed
- Series.
-
- Same rating as get_station_quality, computed for the whole registry in
- one query.
- """
- window_start, window_end = _quality_rating_window(start, end)
- conn = metadata_db_connection_proxy.get_connection()
- inventory = pd.read_sql_query(
- """
- select usaf_id, year, {}
- from ghcn_inventory
- where year between ? and ?
- """.format(
- ", ".join(GHCN_INVENTORY_MONTH_COLUMNS)
- ),
- conn,
- params=(window_start, window_end),
- )
-
- months = list(GHCN_INVENTORY_MONTH_COLUMNS)
- year_min = inventory[months].min(axis=1)
- observed_min = year_min.groupby(inventory.usaf_id).min()
-
- # a station must have a row for every year of the window
- n_years = window_end - window_start + 1
- year_counts = inventory.groupby("usaf_id").year.nunique()
- observed_min = observed_min.where(year_counts >= n_years, 0)
-
- qualities = pd.Series("low", index=observed_min.index)
- qualities[observed_min > MEDIUM_MONTHLY_OBSERVATIONS] = "medium"
- qualities[observed_min > HIGH_MONTHLY_OBSERVATIONS] = "high"
-
- return qualities
-
-
-def get_isd_file_metadata(usaf_id):
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- *
- from
- isd_file_metadata
- where
- usaf_id = ?
- """,
- (usaf_id,),
- )
- rows = cur.fetchall()
- if rows == []:
- raise UnrecognizedUSAFIDError(usaf_id)
- return [{col[0]: row[i] for i, col in enumerate(cur.description)} for row in rows]
-
-
-def get_tmy3_station_metadata(usaf_id):
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- *
- from
- tmy3_station_metadata
- where
- usaf_id = ?
- """,
- (usaf_id,),
- )
- row = cur.fetchone()
- if row is None:
- raise TMY3DataNotAvailableError(usaf_id)
- return {col[0]: row[i] for i, col in enumerate(cur.description)}
-
-
-def get_cz2010_station_metadata(usaf_id):
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
- cur.execute(
- """
- select
- *
- from
- cz2010_station_metadata
- where
- usaf_id = ?
- """,
- (usaf_id,),
- )
- row = cur.fetchone()
- if row is None:
- raise CZ2010DataNotAvailableError(usaf_id)
- return {col[0]: row[i] for i, col in enumerate(cur.description)}
-
-
-def fetch_tmy3_hourly_temp_data(usaf_id):
- url = (
- "https://storage.googleapis.com/openeemeter-public-resources/"
- "tmy3_archive/{}TYA.CSV".format(usaf_id)
- )
-
- # checks that the station has TMY3 data associated with it.
- tmy3_metadata = get_tmy3_station_metadata(usaf_id)
-
- return fetch_hourly_normalized_temp_data(usaf_id, url, "TMY3")
-
-
-def fetch_cz2010_hourly_temp_data(usaf_id):
- url = "https://storage.googleapis.com/oee-cz2010/csv/{}_CZ2010.CSV".format(usaf_id)
-
- # checks that the station has CZ2010 data associated with it.
- cz2010_metadata = get_cz2010_station_metadata(usaf_id)
-
- return fetch_hourly_normalized_temp_data(usaf_id, url, "CZ2010")
-
-
-@eeweather.mockable.mockable()
-def request_text(url): # pragma: no cover
- response = requests.get(url)
- if response.ok:
- return response.text
- else:
- raise RuntimeError("Could not find {}.".format(url))
-
-
-def fetch_hourly_normalized_temp_data(usaf_id, url, source_name):
- index = pd.date_range("1900-01-01 00:00", "1900-12-31 23:00", freq="h", tz=pytz.UTC)
- ts = pd.Series(None, index=index, dtype=float)
-
- lines = eeweather.mockable.request_text(url).splitlines()
-
- utc_offset_str = lines[0].split(",")[3]
- utc_offset = timedelta(seconds=3600 * float(utc_offset_str))
-
- for line in lines[2:]:
- row = line.split(",")
- month = row[0][0:2]
- day = row[0][3:5]
- hour = int(row[1][0:2]) - 1
-
- # YYYYMMDDHH
- date_string = "1900{}{}{:02d}".format(month, day, hour)
-
- dt = datetime.strptime(date_string, "%Y%m%d%H") - utc_offset
-
- # Only a little redundant to make year 1900 again - matters for
- # first or last few hours of the year depending UTC on offset
- dt = pytz.UTC.localize(dt.replace(year=1900))
- temp_C = float(row[31])
-
- ts[dt] = temp_C
-
- return ts
-
-
-def get_tmy3_hourly_temp_data_cache_key(usaf_id):
- return "tmy3-hourly-{}".format(usaf_id)
-
-
-def get_cz2010_hourly_temp_data_cache_key(usaf_id):
- return "cz2010-hourly-{}".format(usaf_id)
-
-
-def _expired(last_updated, year):
- if last_updated is None:
- return True
- expiration_limit = pytz.UTC.localize(
- datetime.now() - timedelta(days=DATA_EXPIRATION_DAYS)
- )
- updated_during_data_year = year == last_updated.year
- return expiration_limit > last_updated and updated_during_data_year
-
-
-def validate_tmy3_hourly_temp_data_cache(usaf_id):
- key = get_tmy3_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- # fail if no key
- if not store.key_exists(key):
- return False
-
- return True
-
-
-def validate_cz2010_hourly_temp_data_cache(usaf_id):
- key = get_cz2010_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- # fail if no key
- if not store.key_exists(key):
- return False
-
- return True
-
-
-def _serialize(ts, freq):
- if freq == "h":
- dt_format = "%Y%m%d%H"
- elif freq == "D":
- dt_format = "%Y%m%d"
- else: # pragma: no cover
- raise ValueError('Unrecognized frequency "{}"'.format(freq))
-
- return [
- [d.strftime(dt_format), round(temp, 4) if pd.notnull(temp) else None]
- for d, temp in ts.items()
- ]
-
-
-def serialize_tmy3_hourly_temp_data(ts):
- return _serialize(ts, "h")
-
-
-def serialize_cz2010_hourly_temp_data(ts):
- return _serialize(ts, "h")
-
-
-def _deserialize(data, freq):
- if freq == "h":
- dt_format = "%Y%m%d%H"
- elif freq == "D":
- dt_format = "%Y%m%d"
- else: # pragma: no cover
- raise ValueError('Unrecognized frequency "{}"'.format(freq))
-
- dates, values = zip(*data)
- index = pd.to_datetime(dates, format=dt_format, utc=True)
- return (
- pd.Series(values, index=index, dtype=float).sort_index().resample(freq).mean()
- )
-
-
-def deserialize_tmy3_hourly_temp_data(data):
- return _deserialize(data, "h")
-
-
-def deserialize_cz2010_hourly_temp_data(data):
- return _deserialize(data, "h")
-
-
-def read_tmy3_hourly_temp_data_from_cache(usaf_id):
- key = get_tmy3_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return deserialize_tmy3_hourly_temp_data(store.retrieve_json(key))
-
-
-def read_cz2010_hourly_temp_data_from_cache(usaf_id):
- key = get_cz2010_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return deserialize_cz2010_hourly_temp_data(store.retrieve_json(key))
-
-
-def write_tmy3_hourly_temp_data_to_cache(usaf_id, ts):
- key = get_tmy3_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return store.save_json(key, serialize_tmy3_hourly_temp_data(ts))
-
-
-def write_cz2010_hourly_temp_data_to_cache(usaf_id, ts):
- key = get_cz2010_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return store.save_json(key, serialize_cz2010_hourly_temp_data(ts))
-
-
-def destroy_cached_tmy3_hourly_temp_data(usaf_id):
- key = get_tmy3_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return store.clear(key)
-
-
-def destroy_cached_cz2010_hourly_temp_data(usaf_id):
- key = get_cz2010_hourly_temp_data_cache_key(usaf_id)
- store = eeweather.connections.key_value_store_proxy.get_store()
- return store.clear(key)
-
-
-def load_tmy3_hourly_temp_data_cached_proxy(
- usaf_id, read_from_cache=True, write_to_cache=True, fetch_from_web=True
-):
- # take from cache?
- data_ok = validate_tmy3_hourly_temp_data_cache(usaf_id)
-
- if not fetch_from_web and not data_ok:
- raise TMY3DataNotAvailableError(usaf_id)
- elif fetch_from_web and (not read_from_cache or not data_ok):
- # need to actually fetch the data
- ts = fetch_tmy3_hourly_temp_data(usaf_id)
- if write_to_cache:
- write_tmy3_hourly_temp_data_to_cache(usaf_id, ts)
- else:
- # read_from_cache=True and data_ok=True
- ts = read_tmy3_hourly_temp_data_from_cache(usaf_id)
- return ts
-
-
-def load_cz2010_hourly_temp_data_cached_proxy(
- usaf_id, read_from_cache=True, write_to_cache=True, fetch_from_web=True
-):
- # take from cache?
- data_ok = validate_cz2010_hourly_temp_data_cache(usaf_id)
-
- if not fetch_from_web and not data_ok:
- raise CZ2010DataNotAvailableError(usaf_id)
- elif fetch_from_web and (not read_from_cache or not data_ok):
- # need to actually fetch the data
- ts = fetch_cz2010_hourly_temp_data(usaf_id)
- if write_to_cache:
- write_cz2010_hourly_temp_data_to_cache(usaf_id, ts)
- else:
- # read_from_cache=True and data_ok=True
- ts = read_cz2010_hourly_temp_data_from_cache(usaf_id)
- return ts
-
-
-def load_tmy3_hourly_temp_data(
- usaf_id, start, end, read_from_cache=True, write_to_cache=True, fetch_from_web=True
-):
- # CalTRACK 2.3.3
- if not _datetime_is_utc(start):
- raise NonUTCTimezoneInfoError(start)
- if not _datetime_is_utc(end):
- raise NonUTCTimezoneInfoError(end)
- single_year_data = load_tmy3_hourly_temp_data_cached_proxy(
- usaf_id,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- )
-
- # dealing with year replacement
- data = []
- for year in range(start.year, end.year + 1):
- single_year_index = single_year_data.index.map(lambda t: t.replace(year=year))
-
- data.append(pd.Series(single_year_data.values, index=single_year_index))
-
- # get raw data
- ts = pd.concat(data).resample("h").mean()
-
- # whittle down
- ts = ts[start:end]
-
- # fill in gaps
- ts = ts.reindex(pd.date_range(start, end, freq="h", tz=pytz.UTC))
- return ts
-
-
-def load_cz2010_hourly_temp_data(
- usaf_id, start, end, read_from_cache=True, write_to_cache=True, fetch_from_web=True
-):
- # CalTRACK 2.3.3
- if not _datetime_is_utc(start):
- raise NonUTCTimezoneInfoError(start)
- if not _datetime_is_utc(end):
- raise NonUTCTimezoneInfoError(end)
- single_year_data = load_cz2010_hourly_temp_data_cached_proxy(
- usaf_id,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- )
-
- # dealing with year replacement
- data = []
- for year in range(start.year, end.year + 1):
- single_year_index = single_year_data.index.map(lambda t: t.replace(year=year))
-
- data.append(pd.Series(single_year_data.values, index=single_year_index))
-
- # get raw data
- ts = pd.concat(data).resample("h").mean()
-
- # whittle down
- ts = ts[start:end]
-
- # fill in gaps
- ts = ts.reindex(pd.date_range(start, end, freq="h", tz=pytz.UTC))
- return ts
-
-
-def load_cached_tmy3_hourly_temp_data(usaf_id):
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- if store.key_exists(get_tmy3_hourly_temp_data_cache_key(usaf_id)):
- return read_tmy3_hourly_temp_data_from_cache(usaf_id)
- else:
- return None
-
-
-def load_cached_cz2010_hourly_temp_data(usaf_id):
- store = eeweather.connections.key_value_store_proxy.get_store()
-
- if store.key_exists(get_cz2010_hourly_temp_data_cache_key(usaf_id)):
- return read_cz2010_hourly_temp_data_from_cache(usaf_id)
- else:
- return None
-
-
-class WeatherStation(object):
- """A representation of a weather station.
-
- Contains data about a particular weather station, as well as methods to
- pull data for this station. Stations are keyed by their ISD-registry
- USAF id; observed data is served from the station's GHCNh record.
-
- Parameters
- ----------
- usaf_id : str
- Station USAF ID
- load_metatdata : bool, optional
- Whether or not to auto-load metadata for this station
-
- Attributes
- ----------
- usaf_id : str
- Station USAF ID
- ghcn_id : str
- GHCNh station id observed data is fetched with
- iecc_climate_zone : str
- IECC Climate Zone
- iecc_moisture_regime : str
- IECC Moisture Regime
- ba_climate_zone : str
- Building America Climate Zone
- ca_climate_zone : str
- California Building Climate Zone
- elevation : float
- elevation of station
- latitude : float
- latitude of station
- longitude : float
- longitude of station
- coords : tuple of (float, float)
- lat/long coordinates of station
- name : str
- name of the station
- quality : str
- "high", "medium", "low"
- wban_ids : list of str
- list of WBAN IDs, or "99999" which have been used to identify the station.
- recent_wban_id = None
- WBAN ID most recently used to identify the station.
- climate_zones = {}
- dict of all climate zones.
- """
-
- def __init__(self, usaf_id, load_metadata=True):
- self.usaf_id = usaf_id
-
- if load_metadata:
- self._load_metadata()
- else:
- valid_usaf_id_or_raise(usaf_id)
- self.iecc_climate_zone = None
- self.iecc_moisture_regime = None
- self.ba_climate_zone = None
- self.ca_climate_zone = None
- self.elevation = None
- self.latitude = None
- self.longitude = None
- self.coords = None
- self.name = None
- self.quality = None
- self.wban_ids = None
- self.recent_wban_id = None
- self.ghcn_id = None
- self.ghcn_first_year = None
- self.ghcn_last_year = None
- self.climate_zones = {}
-
- def __str__(self):
- return self.usaf_id
-
- def __repr__(self):
- return "WeatherStation('{}')".format(self.usaf_id)
-
- def _load_metadata(self):
- metadata = get_isd_station_metadata(self.usaf_id)
-
- def _float_or_none(field):
- value = metadata.get(field)
- return None if value is None else float(value)
-
- self.iecc_climate_zone = metadata.get("iecc_climate_zone")
- self.iecc_moisture_regime = metadata.get("iecc_moisture_regime")
- self.ba_climate_zone = metadata.get("ba_climate_zone")
- self.ca_climate_zone = metadata.get("ca_climate_zone")
- self.icao_code = metadata.get("icao_code")
- self.elevation = _float_or_none("elevation") # meters
- self.latitude = _float_or_none("latitude")
- self.longitude = _float_or_none("longitude")
- self.coords = (self.latitude, self.longitude)
- self.name = metadata.get("name")
- self.quality = metadata.get("quality")
- self.wban_ids = metadata.get("wban_ids", "").split(",")
- self.recent_wban_id = metadata.get("recent_wban_id")
- self.ghcn_id = metadata.get("ghcn_id")
- self.ghcn_first_year = metadata.get("ghcn_first_year")
- self.ghcn_last_year = metadata.get("ghcn_last_year")
- self.climate_zones = {
- "iecc_climate_zone": metadata.get("iecc_climate_zone"),
- "iecc_moisture_regime": metadata.get("iecc_moisture_regime"),
- "ba_climate_zone": metadata.get("ba_climate_zone"),
- "ca_climate_zone": metadata.get("ca_climate_zone"),
- }
-
- def json(self):
- """Return a JSON-serializeable object containing station metadata."""
- return {
- "elevation": self.elevation,
- "latitude": self.latitude,
- "longitude": self.longitude,
- "icao_code": self.icao_code,
- "name": self.name,
- "quality": self.quality,
- "wban_ids": self.wban_ids,
- "recent_wban_id": self.recent_wban_id,
- "ghcn_id": self.ghcn_id,
- "ghcn_first_year": self.ghcn_first_year,
- "ghcn_last_year": self.ghcn_last_year,
- "climate_zones": {
- "iecc_climate_zone": self.iecc_climate_zone,
- "iecc_moisture_regime": self.iecc_moisture_regime,
- "ba_climate_zone": self.ba_climate_zone,
- "ca_climate_zone": self.ca_climate_zone,
- },
- }
-
- def load_data(
- self,
- start,
- end,
- frequency="hourly",
- variables=DEFAULT_VARIABLES,
- read_from_cache=True,
- write_to_cache=True,
- fetch_from_web=True,
- error_on_missing_years=True,
- ):
- """Load this station's weather data between two dates (inclusive).
-
- This is the primary interface for loading observed weather data.
-
- Parameters
- ----------
- start : datetime.datetime
- The earliest date from which to load data. Must be UTC.
- end : datetime.datetime
- The latest date until which to load data. Must be UTC.
- frequency : str
- ``'hourly'`` or ``'daily'``. Daily values are means of the
- hourly values within each day.
- variables : tuple of str
- GHCNh variable names. Temperatures are degrees Celsius; units
- for other variables follow the GHCNh documentation.
- read_from_cache : bool
- Whether or not to load data from cache.
- write_to_cache : bool
- Whether or not to write newly loaded data to cache.
- fetch_from_web : bool
- Whether or not to fetch data from the web.
- error_on_missing_years : bool
- Whether to raise when data is unavailable for a year in the
- range, or to warn and fill that year with NaN.
-
- Returns
- -------
- tuple of (pandas.DataFrame, list of EEWeatherWarning)
- One column per requested variable, indexed over the full
- requested range at the requested frequency; periods without
- data are NaN.
- """
- return load_data(
- self.usaf_id,
- start,
- end,
- frequency=frequency,
- variables=variables,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- error_on_missing_years=error_on_missing_years,
- )
-
- def get_quality(self, start, end):
- """Station quality over a period, from GHCNh observation counts."""
- return get_station_quality(self.usaf_id, start, end)
-
- def load_cached_data(self):
- """Load all cached hourly data for this station."""
- return load_cached_hourly_data(self.usaf_id)
-
- def destroy_cached_hourly_data(self, year):
- """Remove cached hourly data for this station for the given year."""
- return destroy_cached_hourly_data(self.usaf_id, year)
-
- def get_isd_file_metadata(self):
- """Get raw file metadata for the station."""
- return get_isd_file_metadata(self.usaf_id)
-
- # fetch raw data
- # fetch raw data then frequency-normalize
- def fetch_tmy3_hourly_temp_data(self):
- """Pull hourly TMY3 temperature hourly time series directly from NREL."""
- return fetch_tmy3_hourly_temp_data(self.usaf_id)
-
- def fetch_cz2010_hourly_temp_data(self):
- """Pull hourly CZ2010 temperature hourly time series from URL."""
- return fetch_cz2010_hourly_temp_data(self.usaf_id)
-
- # get key-value store key
- def get_tmy3_hourly_temp_data_cache_key(self):
- """Get key used to cache TMY3 weather-normalized temperature data."""
- return get_tmy3_hourly_temp_data_cache_key(self.usaf_id)
-
- def get_cz2010_hourly_temp_data_cache_key(self):
- """Get key used to cache CZ2010 weather-normalized temperature data."""
- return get_cz2010_hourly_temp_data_cache_key(self.usaf_id)
-
- # is cached data expired? boolean. true if expired or not in cache
- # check if data is available and delete data in the cache if it's expired
- def validate_tmy3_hourly_temp_data_cache(self):
- """Check if TMY3 data exists in cache."""
- return validate_tmy3_hourly_temp_data_cache(self.usaf_id)
-
- def validate_cz2010_hourly_temp_data_cache(self):
- """Check if CZ2010 data exists in cache."""
- return validate_cz2010_hourly_temp_data_cache(self.usaf_id)
-
- # pandas time series to json
- def serialize_tmy3_hourly_temp_data(self, ts):
- """Serialize hourly TMY3 pandas time series as JSON for caching."""
- return serialize_tmy3_hourly_temp_data(ts)
-
- def serialize_cz2010_hourly_temp_data(self, ts):
- """Serialize hourly CZ2010 pandas time series as JSON for caching."""
- return serialize_cz2010_hourly_temp_data(ts)
-
- # json to pandas time series
- def deserialize_tmy3_hourly_temp_data(self, data):
- """Deserialize JSON representation of hourly TMY3 into pandas time series."""
- return deserialize_tmy3_hourly_temp_data(data)
-
- def deserialize_cz2010_hourly_temp_data(self, data):
- """Deserialize JSON representation of hourly CZ2010 into pandas time series."""
- return deserialize_cz2010_hourly_temp_data(data)
-
- # return pandas time series of data from cache
- def read_tmy3_hourly_temp_data_from_cache(self):
- """Get cached version of hourly TMY3 temperature data."""
- return read_tmy3_hourly_temp_data_from_cache(self.usaf_id)
-
- def read_cz2010_hourly_temp_data_from_cache(self):
- """Get cached version of hourly TMY3 temperature data."""
- return read_cz2010_hourly_temp_data_from_cache(self.usaf_id)
-
- # write pandas time series of data to cache for a particular year
- def write_tmy3_hourly_temp_data_to_cache(self, ts):
- """Write hourly TMY3 temperature data to cache for given year."""
- return write_tmy3_hourly_temp_data_to_cache(self.usaf_id, ts)
-
- def write_cz2010_hourly_temp_data_to_cache(self, ts):
- """Write hourly CZ2010 temperature data to cache for given year."""
- return write_cz2010_hourly_temp_data_to_cache(self.usaf_id, ts)
-
- # delete cached data for a particular year
- def destroy_cached_tmy3_hourly_temp_data(self):
- """Remove cached hourly TMY3 temperature data to cache."""
- return destroy_cached_tmy3_hourly_temp_data(self.usaf_id)
-
- def destroy_cached_cz2010_hourly_temp_data(self):
- """Remove cached hourly CZ2010 temperature data to cache."""
- return destroy_cached_cz2010_hourly_temp_data(self.usaf_id)
-
- # load data either from cache if valid or directly from source
- def load_tmy3_hourly_temp_data_cached_proxy(self, fetch_from_web=True):
- """Load hourly TMY3 temperature data from cache, or if it is expired or hadn't been cached, fetch from NREL."""
- return load_tmy3_hourly_temp_data_cached_proxy(self.usaf_id, fetch_from_web)
-
- def load_cz2010_hourly_temp_data_cached_proxy(self, fetch_from_web=True):
- """Load hourly CZ2010 temperature data from cache, or if it is expired or hadn't been cached, fetch from URL."""
- return load_cz2010_hourly_temp_data_cached_proxy(self.usaf_id, fetch_from_web)
-
- # main interface: load data from start date to end date
- def load_tmy3_hourly_temp_data(
- self, start, end, read_from_cache=True, write_to_cache=True, fetch_from_web=True
- ):
- """Load hourly TMY3 temperature data from start date to end date (inclusive).
-
- This is the primary convenience method for loading hourly TMY3 temperature data.
-
- Parameters
- ----------
- start : datetime.datetime
- The earliest date from which to load data.
- end : datetime.datetime
- The latest date until which to load data.
- read_from_cache : bool
- Whether or not to load data from cache.
- write_to_cache : bool
- Whether or not to write newly loaded data to cache.
- fetch_from_web : bool
- Whether or not to fetch data from ftp.
- """
- return load_tmy3_hourly_temp_data(
- self.usaf_id,
- start,
- end,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- )
-
- def load_cz2010_hourly_temp_data(
- self, start, end, read_from_cache=True, write_to_cache=True, fetch_from_web=True
- ):
- """Load hourly CZ2010 temperature data from start date to end date (inclusive).
-
- This is the primary convenience method for loading hourly CZ2010 temperature data.
-
- Parameters
- ----------
- start : datetime.datetime
- The earliest date from which to load data.
- end : datetime.datetime
- The latest date until which to load data.
- read_from_cache : bool
- Whether or not to load data from cache.
- write_to_cache : bool
- Whether or not to write newly loaded data to cache.
- fetch_from_web : bool
- Whether or not to fetch data from ftp.
- """
- return load_cz2010_hourly_temp_data(
- self.usaf_id,
- start,
- end,
- read_from_cache=read_from_cache,
- write_to_cache=write_to_cache,
- fetch_from_web=fetch_from_web,
- )
-
- # load all cached data for this station
- def load_cached_tmy3_hourly_temp_data(self):
- """Load all cached hourly TMY3 temperature data (the year is set to 1900)"""
- return load_cached_tmy3_hourly_temp_data(self.usaf_id)
-
- def load_cached_cz2010_hourly_temp_data(self):
- """Load all cached hourly TMY3 temperature data (the year is set to 1900)"""
- return load_cached_cz2010_hourly_temp_data(self.usaf_id)
diff --git a/eeweather/summaries.py b/eeweather/summaries.py
deleted file mode 100644
index a97fd33..0000000
--- a/eeweather/summaries.py
+++ /dev/null
@@ -1,88 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-from .connections import metadata_db_connection_proxy
-
-__all__ = ("get_zcta_ids", "get_isd_station_usaf_ids")
-
-
-def get_zcta_ids(state=None):
- """Get ids of all supported ZCTAs, optionally by state.
-
- Parameters
- ----------
- state : str, optional
- Select zipcodes only from this state or territory, given as 2-letter
- abbreviation (e.g., ``'CA'``, ``'PR'``).
-
- Returns
- -------
- results : list of str
- List of all supported selected ZCTA IDs.
- """
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- if state is None:
- cur.execute(
- """
- select zcta_id from zcta_metadata
- """
- )
- else:
- cur.execute(
- """
- select zcta_id from zcta_metadata where state = ?
- """,
- (state,),
- )
- return [row[0] for row in cur.fetchall()]
-
-
-def get_isd_station_usaf_ids(state=None):
- """Get USAF IDs of all supported ISD stations, optionally by state.
-
- Parameters
- ----------
- state : str, optional
- Select ISD station USAF IDs only from this state or territory, given
- as 2-letter abbreviation (e.g., ``'CA'``, ``'PR'``).
-
- Returns
- -------
- results : list of str
- List of all supported selected ISD station USAF IDs.
- """
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- if state is None:
- cur.execute(
- """
- select usaf_id from isd_station_metadata
- """
- )
- else:
- cur.execute(
- """
- select usaf_id from isd_station_metadata where state = ?
- """,
- (state,),
- )
- return [row[0] for row in cur.fetchall()]
diff --git a/eeweather/utils.py b/eeweather/utils.py
deleted file mode 100644
index 71a4cfc..0000000
--- a/eeweather/utils.py
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import pandas as pd
-
-from .connections import metadata_db_connection_proxy
-
-
-
-class lazy_property(object):
- """
- Meant to be used for lazy evaluation of an object attribute.
- Property should represent non-mutable data, as it replaces itself.
-
- e.g.,
-
- class Test(object):
-
- @lazy_property
- def results(self):
- calcs = 1 # Do a lot of calculation here
- return calcs
-
- from https://stackoverflow.com/a/6849299/1965736
- """
-
- def __init__(self, fget):
- self.fget = fget
- self.func_name = fget.__name__
-
- def __get__(self, obj, cls):
- value = self.fget(obj)
- setattr(obj, self.func_name, value)
- return value
-
-
-def get_ghcn_ids(usaf_ids=None):
- """GHCNh station ids mapped to ISD USAF ids, as a usaf_id-indexed Series.
-
- Parameters
- ----------
- usaf_ids : list of str, optional
- USAF ids to map. When None, the whole registry is returned.
- Unrecognized ids are absent from the result.
- """
- conn = metadata_db_connection_proxy.get_connection()
- mapping = pd.read_sql_query(
- "select usaf_id, ghcn_id from isd_station_metadata", conn
- ).set_index("usaf_id")["ghcn_id"]
- if usaf_ids is not None:
- mapping = mapping[mapping.index.isin(list(usaf_ids))]
-
- return mapping
diff --git a/eeweather/validation.py b/eeweather/validation.py
deleted file mode 100644
index b0195a9..0000000
--- a/eeweather/validation.py
+++ /dev/null
@@ -1,74 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-from .connections import metadata_db_connection_proxy
-from .exceptions import UnrecognizedZCTAError, UnrecognizedUSAFIDError
-
-
-__all__ = ("valid_zcta_or_raise", "valid_usaf_id_or_raise")
-
-
-def valid_zcta_or_raise(zcta):
- """Check if ZCTA is valid and raise eeweather.UnrecognizedZCTAError if not."""
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- cur.execute(
- """
- select exists (
- select
- zcta_id
- from
- zcta_metadata
- where
- zcta_id = ?
- )
- """,
- (zcta,),
- )
- (exists,) = cur.fetchone()
- if exists:
- return True
- else:
- raise UnrecognizedZCTAError(zcta)
-
-
-def valid_usaf_id_or_raise(usaf_id):
- """Check if USAF ID is valid and raise eeweather.UnrecognizedUSAFIDError if not."""
- conn = metadata_db_connection_proxy.get_connection()
- cur = conn.cursor()
-
- cur.execute(
- """
- select exists (
- select
- usaf_id
- from
- isd_station_metadata
- where
- usaf_id = ?
- )
- """,
- (usaf_id,),
- )
- (exists,) = cur.fetchone()
- if exists:
- return True
- else:
- raise UnrecognizedUSAFIDError(usaf_id)
diff --git a/eeweather/visualization.py b/eeweather/visualization.py
deleted file mode 100644
index 7643114..0000000
--- a/eeweather/visualization.py
+++ /dev/null
@@ -1,289 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-import numpy as np
-
-from .connections import metadata_db_connection_proxy
-from .exceptions import UnrecognizedUSAFIDError
-from .stations import WeatherStation
-
-__all__ = ("plot_station_mapping", "plot_station_mappings")
-
-
-def plot_station_mapping(
- target_latitude,
- target_longitude,
- isd_station,
- distance_meters,
- target_label="target",
-): # pragma: no cover
- """Plots this mapping on a map."""
- try:
- import matplotlib.pyplot as plt
- except ImportError:
- raise ImportError("Plotting requires matplotlib.")
-
- try:
- import cartopy.crs as ccrs
- import cartopy.feature as cfeature
- import cartopy.io.img_tiles as cimgt
- except ImportError:
- raise ImportError("Plotting requires cartopy.")
-
- lat, lng = isd_station.coords
- t_lat, t_lng = float(target_latitude), float(target_longitude)
-
- # fiture
- fig = plt.figure(figsize=(16, 8))
-
- # axes
- tiles = cimgt.Stamen("terrain-background")
- ax = plt.subplot(1, 1, 1, projection=tiles.crs)
-
- # offsets for labels
- x_max = max([lng, t_lng])
- x_min = min([lng, t_lng])
- x_diff = x_max - x_min
-
- y_max = max([lat, t_lat])
- y_min = min([lat, t_lat])
- y_diff = y_max - y_min
-
- xoffset = x_diff * 0.05
- yoffset = y_diff * 0.05
-
- # minimum
- left = x_min - x_diff * 0.5
- right = x_max + x_diff * 0.5
- bottom = y_min - y_diff * 0.3
- top = y_max + y_diff * 0.3
-
- width_ratio = 2.0
- height_ratio = 1.0
-
- if (right - left) / (top - bottom) > width_ratio / height_ratio:
- # too short
- goal = (right - left) * height_ratio / width_ratio
- diff = goal - (top - bottom)
- bottom = bottom - diff / 2.0
- top = top + diff / 2.0
- else:
- # too skinny
- goal = (top - bottom) * width_ratio / height_ratio
- diff = goal - (right - left)
- left = left - diff / 2.0
- right = right + diff / 2.0
-
- ax.set_extent([left, right, bottom, top])
-
- # determine zoom level
- # tile size at level 1 = 64 km
- # level 2 = 32 km, level 3 = 16 km, etc, i.e. 128/(2^n) km
- N_TILES = 600 # (how many tiles approximately fit in distance)
- km = distance_meters / 1000.0
- zoom_level = int(np.log2(128 * N_TILES / km))
-
- ax.add_image(tiles, zoom_level)
-
- # line between
- plt.plot(
- [lng, t_lng],
- [lat, t_lat],
- linestyle="-",
- dashes=[2, 2],
- transform=ccrs.Geodetic(),
- )
-
- # station
- ax.plot(lng, lat, "ko", markersize=7, transform=ccrs.Geodetic())
-
- # target
- ax.plot(t_lng, t_lat, "ro", markersize=7, transform=ccrs.Geodetic())
-
- # station label
- station_label = "{} ({})".format(isd_station.usaf_id, isd_station.name)
- ax.text(lng + xoffset, lat + yoffset, station_label, transform=ccrs.Geodetic())
-
- # target label
- ax.text(t_lng + xoffset, t_lat + yoffset, target_label, transform=ccrs.Geodetic())
-
- # distance labels
- mid_lng = (lng + t_lng) / 2
- mid_lat = (lat + t_lat) / 2
- dist_text = "{:.01f} km".format(km)
- ax.text(mid_lng + xoffset, mid_lat + yoffset, dist_text, transform=ccrs.Geodetic())
-
- plt.show()
-
-
-def plot_station_mappings(mapping_results): # pragma: no cover
- """Plot a list of mapping results on a map.
-
- Requires matplotlib and cartopy.
-
- Parameters
- ----------
- mapping_results : list of MappingResult objects
- Mapping results to plot
- """
- try:
- import matplotlib.pyplot as plt
- except ImportError:
- raise ImportError("Plotting requires matplotlib.")
-
- try:
- import cartopy.crs as ccrs
- import cartopy.feature as cfeature
- except ImportError:
- raise ImportError("Plotting requires cartopy.")
-
- lats = []
- lngs = []
- t_lats = []
- t_lngs = []
- n_discards = 0
- for mapping_result in mapping_results:
- if not mapping_result.is_empty():
- lat, lng = mapping_result.isd_station.coords
- t_lat, t_lng = map(float, mapping_result.target_coords)
- lats.append(lat)
- lngs.append(lng)
- t_lats.append(t_lat)
- t_lngs.append(t_lng)
- else:
- n_discards += 1
-
- print("Discarded {} empty mappings".format(n_discards))
-
- # figure
- fig = plt.figure(figsize=(60, 60))
-
- # axes
- ax = plt.subplot(1, 1, 1, projection=ccrs.Mercator())
-
- # offsets for labels
- all_lngs = lngs + t_lngs
- all_lats = lats + t_lats
- x_max = max(all_lngs) # lists
- x_min = min(all_lngs)
- x_diff = x_max - x_min
-
- y_max = max(all_lats)
- y_min = min(all_lats)
- y_diff = y_max - y_min
-
- # minimum
- x_pad = 0.1 * x_diff
- y_pad = 0.1 * y_diff
- left = x_min - x_pad
- right = x_max + x_pad
- bottom = y_min - y_pad
- top = y_max + y_pad
-
- width_ratio = 2.0
- height_ratio = 1.0
-
- if (right - left) / (top - bottom) > height_ratio / width_ratio:
- # too short
- goal = (right - left) * height_ratio / width_ratio
- diff = goal - (top - bottom)
- bottom = bottom - diff / 2.0
- top = top + diff / 2.0
- else:
- # too skinny
- goal = (top - bottom) * width_ratio / height_ratio
- diff = goal - (right - left)
- left = left - diff / 2.0
- right = right + diff / 2.0
-
- left = max(left, -179.9)
- right = min(right, 179.9)
- bottom = max([bottom, -89.9])
- top = min([top, 89.9])
-
- ax.set_extent([left, right, bottom, top])
-
- # OCEAN
- ax.add_feature(
- cfeature.NaturalEarthFeature(
- "physical",
- "ocean",
- "50m",
- edgecolor="face",
- facecolor=cfeature.COLORS["water"],
- )
- )
-
- # LAND
- ax.add_feature(
- cfeature.NaturalEarthFeature(
- "physical",
- "land",
- "50m",
- edgecolor="face",
- facecolor=cfeature.COLORS["land"],
- )
- )
-
- # BORDERS
- ax.add_feature(
- cfeature.NaturalEarthFeature(
- "cultural",
- "admin_0_boundary_lines_land",
- "50m",
- edgecolor="black",
- facecolor="none",
- )
- )
-
- # LAKES
- ax.add_feature(
- cfeature.NaturalEarthFeature(
- "physical",
- "lakes",
- "50m",
- edgecolor="face",
- facecolor=cfeature.COLORS["water"],
- )
- )
-
- # COASTLINE
- ax.add_feature(
- cfeature.NaturalEarthFeature(
- "physical", "coastline", "50m", edgecolor="black", facecolor="none"
- )
- )
-
- # lines between
- # for lat, t_lat, lng, t_lng in zip(lats, t_lats, lngs, t_lngs):
- ax.plot(
- [lngs, t_lngs],
- [lats, t_lats],
- color="k",
- linestyle="-",
- transform=ccrs.Geodetic(),
- linewidth=0.3,
- )
-
- # stations
- ax.plot(lngs, lats, "bo", markersize=1, transform=ccrs.Geodetic())
-
- plt.title("Location to weather station mapping")
-
- plt.show()
diff --git a/eeweather/warnings.py b/eeweather/warnings.py
deleted file mode 100644
index 0e3399c..0000000
--- a/eeweather/warnings.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-
-Copyright 2018-2023 OpenEEmeter contributors
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-"""
-
-
-class EEWeatherWarning(object):
- """An object representing a warning and data associated with it.
-
- Attributes
- ----------
- qualified_name : :any:`str`
- Qualified name, e.g., `'eeweather.method_abc.missing_data'`.
- description : :any:`str`
- Prose describing the nature of the warning.
- data : :any:`dict`
- Data that reproducibly shows why the warning was issued.
- """
-
- def __init__(self, qualified_name, description, data):
- self.qualified_name = qualified_name
- self.description = description
- self.data = data
-
- def __repr__(self):
- return "EEWeatherWarning(qualified_name={})".format(self.qualified_name)
-
- def json(self):
- """Return a JSON-serializable representation of this result.
-
- The output of this function can be converted to a serialized string
- with :any:`json.dumps`.
- """
- return {
- "qualified_name": self.qualified_name,
- "description": self.description,
- "data": self.data,
- }
diff --git a/pyproject.toml b/pyproject.toml
index 049f632..eaa2480 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "eeweather"
dynamic = ["version"]
-description = "Weather station wrangling for EEmeter"
+description = "Obtain weather station data for OpenDSM"
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
@@ -20,10 +20,10 @@ classifiers = [
]
dependencies = [
- "click",
- "pandas>=1.0.0",
+ "numpy",
+ "pandas>=2.2",
+ "platformdirs",
"pyproj>=1.9.6",
- "pytz",
"requests",
"shapely",
]
@@ -33,15 +33,14 @@ dev = [
"black",
"coverage",
"pytest",
+ "pytz",
"pytest-cov",
"pytest-xdist",
+ "ruff",
"syrupy>=5,<6",
"tox",
]
-[project.scripts]
-eeweather = "eeweather.cli:cli"
-
[project.urls]
Homepage = "https://github.com/opendsm/eeweather"
Documentation = "https://opendsm.energy"
@@ -64,12 +63,20 @@ addopts = [
filterwarnings = [
"error",
]
+# pytest's default norecursedirs includes "build", which would silently
+# skip tests/build/; keep the rest of the defaults
+norecursedirs = [".*", "*.egg", "dist", "venv"]
+
+[tool.ruff.lint]
+# logic checks only; the project is deliberately not auto-formatted
+select = ["F"]
[tool.coverage.run]
omit = [
".tox/*",
- # metadata-db rebuild code; runs against live primary sources, not unit-testable
- "eeweather/database.py",
+ # registry maintenance runs against live primary sources or the packaged
+ # database of a previous schema; not unit-testable
+ "eeweather/build/*",
]
[tool.coverage.report]
@@ -78,6 +85,5 @@ exclude_lines = [
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
-# Conservative floor for `tox -e coverage`. Raise it as test coverage is
-# added. The full suite measures 95.3%.
-fail_under = 94
+# Floor for `tox -e coverage`; the full suite measures 98.2%.
+fail_under = 97
diff --git a/scripts/create_ca_climate_zone_geojson.sh b/scripts/create_ca_climate_zone_geojson.sh
deleted file mode 100755
index e845dff..0000000
--- a/scripts/create_ca_climate_zone_geojson.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-
-DATA_DIR=${1:-data}
-
-mkdir -p $DATA_DIR
-
-# download and install CA climate zone raw data
-wget -N https://community.esri.com/ccqpr47374/attachments/ccqpr47374/coordinate-reference-systemsforum-board/1814/1/CA_Building_Standards_Climate_Zones.zip -P $DATA_DIR -q --show-progress
-unzip -q -o $DATA_DIR/CA_Building_Standards_Climate_Zones.zip -d $DATA_DIR
-
-# reproject to ESRI Shapefile
-ogr2ogr -q -f "ESRI Shapefile" -t_srs EPSG:4326 $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.shp $DATA_DIR/CA_Building_Standards_Climate_Zones.shp
-
-# convert to geojson
-mapshaper -quiet -i $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.shp -o format=geojson $DATA_DIR/CA_Building_Standards_Climate_Zones.json
-
-# clean up
-rm -f $DATA_DIR/CA\ Building\ Standards\ Climate\ Zones.lyr
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.cpg
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.dbf
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.prj
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.sbn
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.sbx
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.shp
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.shp.xml
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.shx
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones.zip
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.dbf
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.prj
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.shp
-rm -f $DATA_DIR/CA_Building_Standards_Climate_Zones_reprojected.shx
diff --git a/scripts/create_county_geojson.sh b/scripts/create_county_geojson.sh
deleted file mode 100755
index 988e8bb..0000000
--- a/scripts/create_county_geojson.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/bash
-
-DATA_DIR=${1:-data}
-
-mkdir -p $DATA_DIR
-
-# download and unzip county shapefiles
-wget -N http://www2.census.gov/geo/tiger/GENZ2016/shp/cb_2016_us_county_500k.zip -P $DATA_DIR -q --show-progress
-unzip -q -o $DATA_DIR/cb_2016_us_county_500k.zip -d $DATA_DIR
-
-# convert to GEOJSON - requires mapshaper (https://github.com/mbloch/mapshaper)
-mapshaper -quiet -i $DATA_DIR/cb_2016_us_county_500k.shp -o format=geojson $DATA_DIR/cb_2016_us_county_500k.json
-
-# clean up
-rm $DATA_DIR/cb_2016_us_county_500k.cpg
-rm $DATA_DIR/cb_2016_us_county_500k.dbf
-rm $DATA_DIR/cb_2016_us_county_500k.prj
-rm $DATA_DIR/cb_2016_us_county_500k.shp
-rm $DATA_DIR/cb_2016_us_county_500k.shp.ea.iso.xml
-rm $DATA_DIR/cb_2016_us_county_500k.shp.iso.xml
-rm $DATA_DIR/cb_2016_us_county_500k.shp.xml
-rm $DATA_DIR/cb_2016_us_county_500k.shx
-rm $DATA_DIR/cb_2016_us_county_500k.zip
diff --git a/scripts/create_zipcode_geojson.sh b/scripts/create_zipcode_geojson.sh
deleted file mode 100755
index d8ab27c..0000000
--- a/scripts/create_zipcode_geojson.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/bash
-
-DATA_DIR=${1:-data}
-
-mkdir -p $DATA_DIR
-
-# Download and unzip
-wget -N http://www2.census.gov/geo/tiger/GENZ2016/shp/cb_2016_us_zcta510_500k.zip -P $DATA_DIR -q --show-progress
-unzip -q -o $DATA_DIR/cb_2016_us_zcta510_500k.zip -d $DATA_DIR
-
-# Convert to GEOJSON - requires mapshaper (https://github.com/mbloch/mapshaper)
-mapshaper -quiet -i $DATA_DIR/cb_2016_us_zcta510_500k.shp -o format=geojson $DATA_DIR/cb_2016_us_zcta510_500k.json
-
-# Clean up
-rm $DATA_DIR/cb_2016_us_zcta510_500k.cpg
-rm $DATA_DIR/cb_2016_us_zcta510_500k.dbf
-rm $DATA_DIR/cb_2016_us_zcta510_500k.prj
-rm $DATA_DIR/cb_2016_us_zcta510_500k.shp
-rm $DATA_DIR/cb_2016_us_zcta510_500k.shp.ea.iso.xml
-rm $DATA_DIR/cb_2016_us_zcta510_500k.shp.iso.xml
-rm $DATA_DIR/cb_2016_us_zcta510_500k.shp.xml
-rm $DATA_DIR/cb_2016_us_zcta510_500k.shx
-rm $DATA_DIR/cb_2016_us_zcta510_500k.zip
diff --git a/scripts/data/tmy3-stations.html b/scripts/data/tmy3-stations.html
deleted file mode 100644
index c00ffe9..0000000
--- a/scripts/data/tmy3-stations.html
+++ /dev/null
@@ -1,6238 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-NSRDB Update - TMY3: Numerical List by USAF#
-
-
-
-
-
-
-
-
-
-
-
The Wayback Machine -
-https://web.archive.org/web/20181119091712/https://rredc.nrel.gov/solar/old_data/nsrdb/1991-2005/tmy3/by_USAFN.html
-
-
-
-
-
-
-
-
-
National
- Solar Radiation Data Base
-
- 1991- 2005 Update:
-
- Typical Meteorological Year 3
-
-
-
-
-
-
-
-
-
-
- List of Sites in Numerical Order by USAF Number
-
- Click on a site number to access the Typical Meteorological Year version 3 files for that site.
-
-