Skip to content

Commit 6094ca2

Browse files
authored
Ingest weather data from OpenMeteo (#177)
* Ingest weather data from OpenMeteo * ruff format
1 parent 1c0ef37 commit 6094ca2

9 files changed

Lines changed: 377 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ So far we have:
1515
- Store Bluebikes station status data every 5 min.
1616
- Store ridership data
1717
- Process and store speed restrictions
18+
- Store hourly Boston weather data (temperature + condition) for chart overlays
1819

1920
To add a new lambda function, put the methods you need in a new file in chalicelib/.
2021
Then add your trigger in app.py.

ingestor/.chalice/config.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@
125125
"iam_policy_file": "policy-service-ridership-dashboard.json",
126126
"lambda_timeout": 900,
127127
"lambda_memory_size": 1024
128+
},
129+
"store_hourly_weather": {
130+
"iam_policy_file": "policy-weather.json",
131+
"lambda_memory_size": 192,
132+
"lambda_timeout": 30
133+
},
134+
"backfill_weather": {
135+
"iam_policy_file": "policy-weather.json",
136+
"lambda_memory_size": 256,
137+
"lambda_timeout": 900
128138
}
129139
}
130140
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"Version": "2012-10-17",
3+
"Statement": [
4+
{
5+
"Action": [
6+
"logs:CreateLogGroup",
7+
"logs:CreateLogStream",
8+
"logs:PutLogEvents"
9+
],
10+
"Effect": "Allow",
11+
"Resource": "arn:*:logs:*:*:*"
12+
},
13+
{
14+
"Action": "s3:ListBucket",
15+
"Effect": "Allow",
16+
"Resource": ["arn:aws:s3:::tm-mbta-performance"]
17+
},
18+
{
19+
"Action": "s3:*",
20+
"Effect": "Allow",
21+
"Resource": [
22+
"arn:aws:s3:::tm-mbta-performance/Weather",
23+
"arn:aws:s3:::tm-mbta-performance/Weather/*",
24+
"arn:aws:s3:::tm-mbta-performance/Weather/hourly/*"
25+
]
26+
}
27+
]
28+
}

ingestor/.chalice/resources.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,50 @@
746746
"Properties": {
747747
"BucketName": "tm-shuttle-positions"
748748
}
749+
},
750+
"StoreHourlyWeather": {
751+
"Type": "AWS::Serverless::Function",
752+
"Properties": {
753+
"Description": "Stores hourly Boston weather data from Open-Meteo for chart overlays",
754+
"Environment": {
755+
"Variables": {
756+
"DD_API_KEY": {
757+
"Ref": "DDApiKey"
758+
},
759+
"DD_VERSION": {
760+
"Ref": "GitVersion"
761+
},
762+
"DD_TAGS": {
763+
"Ref": "DDTags"
764+
},
765+
"DD_GIT_REPOSITORY_URL": {
766+
"Ref": "DDGitRepositoryUrl"
767+
}
768+
}
769+
}
770+
}
771+
},
772+
"BackfillWeather": {
773+
"Type": "AWS::Serverless::Function",
774+
"Properties": {
775+
"Description": "Manually-triggered backfill of historical hourly weather from Open-Meteo archive",
776+
"Environment": {
777+
"Variables": {
778+
"DD_API_KEY": {
779+
"Ref": "DDApiKey"
780+
},
781+
"DD_VERSION": {
782+
"Ref": "GitVersion"
783+
},
784+
"DD_TAGS": {
785+
"Ref": "DDTags"
786+
},
787+
"DD_GIT_REPOSITORY_URL": {
788+
"Ref": "DDGitRepositoryUrl"
789+
}
790+
}
791+
}
792+
}
749793
}
750794
}
751795
}

ingestor/app.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
service_ridership_dashboard,
1717
speed_restrictions,
1818
trip_metrics,
19+
weather,
1920
)
2021
from datadog_lambda.wrapper import datadog_lambda_wrapper
2122

@@ -177,3 +178,18 @@ def store_landing_data(event):
177178
@app.schedule(Cron(30, 9, "*", "*", "?", "*"))
178179
def update_service_ridership_dashboard(event):
179180
service_ridership_dashboard.create_service_ridership_dash_json()
181+
182+
183+
#################
184+
# STORE HOURLY WEATHER
185+
# Every hour at :05 — fetch latest Boston hourly weather and merge into today's S3 file.
186+
@app.schedule(Cron(5, "*", "*", "*", "?", "*"))
187+
def store_hourly_weather(event):
188+
weather.ingest_hourly_weather()
189+
190+
191+
# Manually triggered lambda for backfilling historical weather from Open-Meteo's archive API.
192+
# Payload: {"start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD"}
193+
@app.lambda_function()
194+
def backfill_weather(params, context):
195+
weather.backfill_weather(params["start_date"], params["end_date"])
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from ..weather.constants import WEATHER_CODE_TO_CONDITION, key
2+
from ..weather.ingest import _parse_hourly
3+
4+
5+
HOURLY_FIXTURE = {
6+
"time": [
7+
"2026-04-22T00:00",
8+
"2026-04-22T01:00",
9+
"2026-04-23T00:00",
10+
],
11+
"temperature_2m": [45.23, 44.1, 50.0],
12+
"weather_code": [0, 61, 75],
13+
"precipitation": [0.0, 0.05, 0.0],
14+
"relative_humidity_2m": [78, 82, 60],
15+
"wind_speed_10m": [8.47, 9.2, 5.0],
16+
}
17+
18+
19+
def test_parse_hourly_groups_by_date():
20+
by_date = _parse_hourly(HOURLY_FIXTURE)
21+
assert set(by_date.keys()) == {"2026-04-22", "2026-04-23"}
22+
assert set(by_date["2026-04-22"].keys()) == {"2026-04-22T00:00", "2026-04-22T01:00"}
23+
24+
25+
def test_parse_hourly_record_shape():
26+
record = _parse_hourly(HOURLY_FIXTURE)["2026-04-22"]["2026-04-22T00:00"]
27+
assert record["temperature_f"] == 45.2
28+
assert record["weather_code"] == 0
29+
assert record["condition"] == "clear"
30+
assert record["precipitation_in"] == 0.0
31+
assert record["humidity_pct"] == 78
32+
assert record["wind_mph"] == 8.5
33+
34+
35+
def test_weather_code_mapping():
36+
assert WEATHER_CODE_TO_CONDITION[0] == "clear"
37+
assert WEATHER_CODE_TO_CONDITION[3] == "cloudy"
38+
assert WEATHER_CODE_TO_CONDITION[45] == "fog"
39+
assert WEATHER_CODE_TO_CONDITION[63] == "rain"
40+
assert WEATHER_CODE_TO_CONDITION[75] == "snow"
41+
assert WEATHER_CODE_TO_CONDITION[95] == "storm"
42+
43+
44+
def test_unknown_code_falls_back():
45+
fixture = {
46+
"time": ["2026-04-22T00:00"],
47+
"temperature_2m": [40.0],
48+
"weather_code": [999],
49+
"precipitation": [0.0],
50+
"relative_humidity_2m": [50],
51+
"wind_speed_10m": [0.0],
52+
}
53+
record = _parse_hourly(fixture)["2026-04-22"]["2026-04-22T00:00"]
54+
assert record["condition"] == "unknown"
55+
56+
57+
def test_merge_preserves_other_hours_and_overwrites_same_hour():
58+
existing = {
59+
"2026-04-22T00:00": {"temperature_f": 40.0, "condition": "clear"},
60+
"2026-04-22T01:00": {"temperature_f": 39.0, "condition": "clear"},
61+
}
62+
new = _parse_hourly(
63+
{
64+
"time": ["2026-04-22T01:00", "2026-04-22T02:00"],
65+
"temperature_2m": [41.0, 42.0],
66+
"weather_code": [61, 2],
67+
"precipitation": [0.1, 0.0],
68+
"relative_humidity_2m": [80, 75],
69+
"wind_speed_10m": [7.0, 6.0],
70+
}
71+
)["2026-04-22"]
72+
73+
existing.update(new)
74+
75+
assert existing["2026-04-22T00:00"]["temperature_f"] == 40.0
76+
assert existing["2026-04-22T01:00"]["condition"] == "rain"
77+
assert existing["2026-04-22T01:00"]["temperature_f"] == 41.0
78+
assert existing["2026-04-22T02:00"]["condition"] == "cloudy"
79+
80+
81+
def test_key_format():
82+
assert key("2026-04-22") == "Weather/hourly/2026-04-22.json.gz"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from chalicelib.weather.ingest import backfill_weather, ingest_hourly_weather
2+
3+
__all__ = ["backfill_weather", "ingest_hourly_weather"]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
BUCKET = "tm-mbta-performance"
2+
3+
# Downtown Boston (near City Hall)
4+
LATITUDE = 42.3601
5+
LONGITUDE = -71.0589
6+
7+
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
8+
ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
9+
10+
HOURLY_FIELDS = "temperature_2m,weather_code,precipitation,relative_humidity_2m,wind_speed_10m"
11+
12+
# WMO weather codes → coarse condition labels the frontend can rely on.
13+
# https://open-meteo.com/en/docs — "Weather variable documentation"
14+
WEATHER_CODE_TO_CONDITION = {
15+
0: "clear", # Clear sky
16+
1: "clear", # Mainly clear
17+
2: "cloudy", # Partly cloudy
18+
3: "cloudy", # Overcast
19+
45: "fog", # Fog
20+
48: "fog", # Depositing rime fog
21+
51: "rain", # Light drizzle
22+
53: "rain", # Moderate drizzle
23+
55: "rain", # Dense drizzle
24+
56: "rain", # Light freezing drizzle
25+
57: "rain", # Dense freezing drizzle
26+
61: "rain", # Slight rain
27+
63: "rain", # Moderate rain
28+
65: "rain", # Heavy rain
29+
66: "rain", # Light freezing rain
30+
67: "rain", # Heavy freezing rain
31+
71: "snow", # Slight snow fall
32+
73: "snow", # Moderate snow fall
33+
75: "snow", # Heavy snow fall
34+
77: "snow", # Snow grains
35+
80: "rain", # Slight rain showers
36+
81: "rain", # Moderate rain showers
37+
82: "rain", # Violent rain showers
38+
85: "snow", # Slight snow showers
39+
86: "snow", # Heavy snow showers
40+
95: "storm", # Thunderstorm (slight or moderate)
41+
96: "storm", # Thunderstorm with slight hail
42+
99: "storm", # Thunderstorm with heavy hail
43+
}
44+
# Thunderstorm forecast with hail is only available in Central Europe
45+
46+
47+
def key(day):
48+
return f"Weather/hourly/{str(day)}.json.gz"

0 commit comments

Comments
 (0)