Skip to content

Repository files navigation

PyWeatherEnriched

Real geocoding + real historical weather, wired into a Rust core with pandas/numpy climate feature engineering on top.

PyPI Python 3.10+ Tests License: Apache 2.0

Given a location name and a timestamp, PyWeatherEnriched geocodes the location (OpenStreetMap Nominatim) and looks up the real, genuinely observed historical weather for that place and hour (Open-Meteo's free Archive API) — no formula-generated or fabricated numbers. The lookup/geocoding core is Rust (via PyO3) for speed and a small memory footprint; a Python layer on top adds pandas/numpy feature engineering (rolling aggregates, heating/cooling degree-days, cyclical time encoding, anomaly z-scores) useful for feeding weather into an ML pipeline.

Use cases

  • Enriching a training dataset with real historical weather for a location/timestamp pair, instead of a synthetic or averaged fill-in.
  • Feature engineering for weather-sensitive ML models — rolling aggregates, heating/cooling degree-days, cyclical time encoding, and anomaly z-scores, ready to feed into a pipeline.
  • Not yet a good fit for: real-time/forecast weather (this is historical, observed data only) — see What's real vs. not (yet) below for the full list.

Install

pip install pyweatherenriched

Requires Python 3.10+. CI builds prebuilt wheels for Linux (x86_64/ARM64), macOS (Intel/Apple Silicon), and Windows (x86_64) on every release, but the CI→PyPI publish step is currently broken (PyPI rejects the upload with 403 Invalid or non-existent authentication information — a stale/invalid API token), so only a macOS ARM64 wheel is actually published on PyPI today. On any other platform, pip install pyweatherenriched will fall back to building the sdist from source, which does need a Rust toolchain (see Requirements below) despite the other-platform wheels existing as CI build artifacts.

Quick start

import pyweatherenriched as pwe

enricher = pwe.WeatherEnricher()
result = enricher.enrich_row("New York", "2024-06-15T12:00:00")

print(result)
# {'location': 'New York', 'latitude': 40.7127281, 'longitude': -74.0060152,
#  'temperature': 19.4, 'humidity': 74.0, 'condition': 'Clear',
#  'timestamp': '2024-06-15T12:00:00'}

enrich_row makes two real network calls the first time it sees a location/timestamp pair (one to Nominatim to geocode location, one to Open-Meteo for the historical weather at those coordinates); the in-process cache means repeating the same lookup doesn't hit the network again:

fresh = pwe.WeatherEnricher()
fresh.enrich_row("New York", "2024-06-15T12:00:00")  # network
fresh.enrich_row("New York", "2024-06-15T12:00:00")  # cache hit
print(fresh.cache_stats())  # {'hits': 1, 'misses': 1, 'size': 1}

Historical backfill

enrich_row/enrich_batch fetch one day of data per call. To backfill a date range for a location, enrich_range geocodes once and fetches every hourly observation across the whole range in a single Open-Meteo request:

enricher = pwe.WeatherEnricher()
rows = enricher.enrich_range("Chicago", "2024-06-01", "2024-06-07")
print(len(rows))  # ~168 (7 days x 24 hours, minus any hours Open-Meteo has no data for)

Hours Open-Meteo has no observation for (e.g. the tail of a range that runs past the latest available data) are omitted from the result rather than filled with a fabricated value.

Feature engineering on a DataFrame

enrich_dataframe bridges the Rust core to pandas — fetch real weather for every row of a DataFrame in one call — and the features functions build standard climate ML features on top of the result:

import pandas as pd
import pyweatherenriched as pwe

orders = pd.DataFrame({
    "order_id": ["A1", "A2", "A3"],
    "location": ["Chicago", "Miami", "Denver"],
    "timestamp": ["2024-01-15T12:00:00", "2024-06-15T12:00:00", "2024-03-15T12:00:00"],
})

enricher = pwe.WeatherEnricher()
enriched = pwe.enrich_dataframe(enricher, orders)     # + latitude/longitude/temperature/humidity/condition
features = pwe.build_features(enriched)                # + hdd/cdd, cyclical time, rolling stats, anomaly z-score

print(features[["order_id", "temperature", "hdd", "cdd", "temperature_zscore"]])

Rows whose lookup fails (unresolvable location, upstream error) get NaN in the new columns instead of raising or fabricating a value, so one bad row doesn't lose the batch.

build_features is a convenience pipeline; each step is also a standalone function you can call individually with more control:

Function What it adds
add_degree_days(df, temp_col, base_temp=18.0) hdd, cdd — heating/cooling degree-days (standard energy-demand/agriculture signal: max(0, base - t) / max(0, t - base))
add_cyclical_time_features(df, timestamp_col) hour_sin/cos, day_of_week_sin/cos, day_of_year_sin/cos — sin/cos encoding so e.g. 23:59 and 00:00 stay numerically adjacent
add_rolling_features(df, value_col, window, group_col=None, stats=(...)) trailing rolling aggregates (mean/std/min/max/...), optionally computed independently per group (e.g. per location)
add_anomaly_features(df, value_col, group_col=None, baseline="expanding"|"rolling"|"global") a z-score measuring how unusual each reading is relative to a chosen baseline

Caching

WeatherEnricher has a small built-in LRU cache. For larger workloads, EnhancedCache adds a second, SQLite-backed persistent tier with geospatial-proximity matching, TTL expiration, batch deduplication, and date-range queries:

from pyweatherenriched import EnhancedCache

cache = EnhancedCache(cache_size=5000, db_path="weather_cache.db")
cache.set_proximity_radius(10.0)  # treat lookups within 10km as cache hits
cache.set_ttl(72)                 # hours before an entry expires

cache.put("New York", 40.7128, -74.0060, 15.2, 65.0, "Partly Cloudy", "2024-01-15T12:00:00Z")
result = cache.get("New York", 40.7128, -74.0060, "2024-01-15T12:00:00Z")

# Deduplicate a batch before making any API calls.
batch = [
    ("New York", 40.7128, -74.0060, "2024-01-15T12:00:00Z"),
    ("New York", 40.7128, -74.0060, "2024-01-15T12:00:00Z"),  # duplicate
]
missing_indices, cache_hits = cache.deduplicate_batch(batch)

# Every observation in a date range near a point (requires db_path).
rows = cache.get_range(40.7128, -74.0060, "2024-01-01T00:00:00Z", "2024-01-31T23:59:59Z")

stats = cache.stats()
print(f"hit ratio: {stats['hit_ratio']:.1%}")

get() checks the memory tier, then the persistent tier (if db_path was given), then falls back to a proximity search; get_range only queries the persistent tier, so it always returns [] without db_path.

What's real vs. not (yet)

  • Real: forward geocoding (Nominatim), historical weather (Open-Meteo Archive API), in-memory LRU cache, SQLite-backed EnhancedCache (proximity matching, TTL, dedup, date-range queries), pandas/numpy feature engineering.
  • Not yet exposed to Python: the crate has additional Rust-side, independently unit-tested building blocks — elevation/lapse-rate adjustment (real SRTM GeoTIFF parsing), urban-heat-island modeling (real OSM building-density analysis), and OSM-based reverse geocoding — that aren't wired into the Python API yet. They live under src/geospatial/ if you want to build on them.
  • Deliberately unimplemented: vegetation/NDVI, soil, and flood-risk layers, plus additional commercial reverse-geocoding provider backends, are framework stubs (src/geospatial/optional.rs) that return a clear "not yet implemented" error rather than fake data.
  • Fixed (external critique, verified real gaps): src/enricher.rs and src/geocoder.rs now retry through src/http_retry.rs — exponential backoff with jitter on 429/5xx responses and transport errors, honoring a numeric Retry-After header when Nominatim/Open-Meteo send one — where every request previously went out once with no retry at all. Range-based historical backfill is real too: WeatherEnricher.enrich_range(location, start_date, end_date) (exposed to Python as enrich_range) geocodes once and fetches the entire date range in a single Open-Meteo request, instead of the one-call-per-day pattern enrich/enrich_batch still use for their single-timestamp use case. See "Historical backfill" above. (Note: the "no local caching layer" critique item never held — EnhancedCache is a real SQLite-backed persistent cache with TTL and proximity matching; it's just opt-in rather than auto-wired into WeatherEnricher.enrich(), which remains its own small, separate TODO.)

Development

git clone https://github.com/Mullassery/PyWeatherEnriched.git
cd PyWeatherEnriched

maturin develop --release   # build the Rust extension + install editable
cargo test --lib            # Rust unit tests (32 tests)
pip install -e ".[dev]"
pytest tests/ -v            # Python tests (33 tests, incl. live-network ones)

Note: plain cargo test (without --lib) currently fails to compile — tests/phase2_integration_test.rs references ParallelEnricher, BatchResolver, StreamingReader/StreamingWriter, and DatabaseConfig/ DatabaseType from src/parallel.rs, src/batch_resolver.rs, src/streaming_io.rs, and src/database.rs — source files that exist on disk but aren't declared as mods in src/lib.rs, so they're not part of the compiled crate and that integration test is stale. Use cargo test --lib to run the real, passing unit test suite.

cargo run --bin pyweatherenriched-validate is a small smoke-test CLI that exercises real geocoding + weather fetch end to end — useful to confirm a build/environment can actually reach Nominatim and Open-Meteo.

See BUILD.md for wheel-building/release details.

Requirements

  • Python 3.10+
  • Rust 1.75+ (only if building from source — not needed to pip install)
  • Network access to nominatim.openstreetmap.org and archive-api.open-meteo.com at call time (no API key needed for either)

License

This project is licensed under the Apache License 2.0.

Support

About

Weather data enrichment for ML pipelines. Real-time forecasting, climate-aware feature engineering, global weather integration.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages