4. Building the weather layer

The weather layer takes coordinates from the geocoder and returns forecast data. Same shape as geocoding, same three-phase build (minimal request, field expansion, hardened APIClient wrapper), and the same single-check return contract so the coordination page on the other side stays simple. The new wrinkles are a more complex response (nested objects and parallel arrays side by side) and WMO weather codes that need translating before a human sees them.

Phase 1: prove the endpoint works

Same approach as geocoding: required parameters plus one data field, print what comes back, understand the shape before building anything else. If you created the implementation notes on page 2, keep weather_spec.txt open beside this file. Use Dublin's coordinates from the last page.

Save this as weather_basic.py:

weather_basic.py
import requests

# From documentation analysis
weather_url = "https://api.open-meteo.com/v1/forecast"

# Test coordinates (Dublin from geocoding)
latitude = 53.33306
longitude = -6.24889

# Minimum required parameters plus one data field
params = {
    "latitude": latitude,
    "longitude": longitude,
    "current": ["temperature_2m"]  # Just temperature to start
}

# Make the request
response = requests.get(weather_url, params=params, timeout=10)
response.raise_for_status()

data = response.json()
print(f"Response type: {type(data)}")
print(f"Top-level keys: {list(data.keys())}")

# Examine current weather structure
current = data["current"]
print(f"\nCurrent weather data: {current}")

# Check units information
units = data["current_units"]
print(f"Units: {units}")

Run it:

Terminal
$ python weather_basic.py
Response type: <class 'dict'>
Top-level keys: ['latitude', 'longitude', 'generationtime_ms', 'utc_offset_seconds', 'timezone', 'timezone_abbreviation', 'elevation', 'current_units', 'current']

Current weather data: {'time': '2026-05-05T14:00', 'interval': 900, 'temperature_2m': 8.3}
Units: {'time': 'iso8601', 'interval': 'seconds', 'temperature_2m': '°C'}

Four things worth noticing. The endpoint works. Current data lives in its own current object, and its units live in a parallel current_units object -- unit information is separated from the values themselves, which means display code has to pair them back up. Every reading has an associated timestamp. And the response carries more than you asked for (timezone, elevation, generationtime_ms) but that's the API being generous, not a problem.

If the call raises before printing anything, the network's unreachable or a corporate proxy is in the way. If the script reaches data["current"] and KeyErrors out, the current parameter wasn't recognised in the URL -- usually a typo against the docs.

Phase 2: pin the current-weather field set

Phase 1 proved the endpoint works against a single field. Now add the rest of the current-weather parameters from the documentation analysis so the request locks in the field set the dashboard will actually use. Save this as weather_current.py:

weather_current.py
import requests

weather_url = "https://api.open-meteo.com/v1/forecast"
latitude = 53.33306
longitude = -6.24889

# Complete current weather parameters
params = {
    "latitude": latitude,
    "longitude": longitude,
    "current": [
        "temperature_2m",
        "relative_humidity_2m",
        "apparent_temperature",
        "weather_code",
        "wind_speed_10m",
        "wind_direction_10m"
    ]
}

print("Requesting complete current weather data...")
response = requests.get(weather_url, params=params, timeout=10)
response.raise_for_status()

data = response.json()
current = data["current"]
units = data["current_units"]

print("Current weather data:")
for field, value in current.items():
    if field == "time":
        print(f"  {field}: {value}")
    else:
        unit = units.get(field, "")
        print(f"  {field}: {value} {unit}")

Run it:

Terminal
$ python weather_current.py
Requesting complete current weather data...
Current weather data:
  time: 2026-05-05T14:00
  interval: 900 seconds
  temperature_2m: 8.3 °C
  relative_humidity_2m: 87 %
  apparent_temperature: 6.1 °C
  weather_code: 3 
  wind_speed_10m: 11.2 km/h
  wind_direction_10m: 225 °

Everything you need for a current-conditions display is now in the response. One field still looks cryptic: weather_code: 3. Those are WMO codes, and they need a lookup table before a user sees them. That comes at the end of the page.

If the iteration prints fewer fields than you expect, one of the names in your current list didn't match Open-Meteo's docs and the API silently dropped it; cross-check the spelling against the documentation page from §2.

The docs show the current values as a comma-separated string. Passing a Python list is fine here: requests serializes it as repeated query parameters, and Open-Meteo accepts both forms.

Phase 2 continued: adding the daily forecast

Daily forecasts come back in a different shape: parallel arrays, one per field, aligned by index. daily['time'][0], daily['temperature_2m_max'][0], and daily['weather_code'][0] all describe the same day. That's a different access pattern from current-weather's named-field object, and display code has to iterate through indices rather than navigate nested objects.

Save this as weather_daily.py:

weather_daily.py
import requests

weather_url = "https://api.open-meteo.com/v1/forecast"
latitude = 53.33306
longitude = -6.24889

# Combined current + daily parameters
params = {
    "latitude": latitude,
    "longitude": longitude,
    
    # Current weather
    "current": [
        "temperature_2m",
        "relative_humidity_2m",
        "apparent_temperature",
        "weather_code",
        "wind_speed_10m",
        "wind_direction_10m"
    ],
    
    # Daily forecast
    "daily": [
        "temperature_2m_max",
        "temperature_2m_min",
        "weather_code",
        "precipitation_sum",
        "wind_speed_10m_max"
    ],
    
    # Configuration
    "timezone": "auto",
    "forecast_days": 5
}

print("Requesting current weather + daily forecast...")
response = requests.get(weather_url, params=params, timeout=15)
response.raise_for_status()

data = response.json()

# Display current conditions summary
print("\nCurrent conditions:")
current = data["current"]
temp = current["temperature_2m"]
humidity = current["relative_humidity_2m"]
print(f"  Temperature: {temp}°C")
print(f"  Humidity: {humidity}%")

# Display daily forecast structure
print(f"\nDaily forecast:")
daily = data["daily"]
daily_units = data["daily_units"]

print(f"  Number of days: {len(daily['time'])}")
print(f"  Date array: {daily['time']}")

# Show first day's data
print(f"\n  First day details:")
print(f"    Date: {daily['time'][0]}")
print(f"    High: {daily['temperature_2m_max'][0]}{daily_units['temperature_2m_max']}")
print(f"    Low: {daily['temperature_2m_min'][0]}{daily_units['temperature_2m_min']}")
print(f"    Precipitation: {daily['precipitation_sum'][0]}{daily_units['precipitation_sum']}")

Run it:

Terminal
$ python weather_daily.py
Requesting current weather + daily forecast...

Current conditions:
  Temperature: 8.3°C
  Humidity: 87%

Daily forecast:
  Number of days: 5
  Date array: ['2026-05-05', '2026-05-06', '2026-05-07', '2026-05-08', '2026-05-09']

  First day details:
    Date: 2026-05-05
    High: 10.2°C
    Low: 5.8°C
    Precipitation: 0.0mm

All arrays have the same length (five here). Loop over indices to iterate days, not over field names. If an array's shorter than the others, that's a signal the API dropped a day; don't assume every field lines up without checking. If daily is missing from the response entirely, the daily parameter group didn't make it into the request -- usually a typo, or it ended up nested under current by accident.

With the request shape understood, two more pieces complete the layer: a WMO code lookup that turns the cryptic weather_code: 3 into "Overcast", and the Phase 3 wrapper class that mirrors the GeocodingClient shape from page 3.

Interpreting weather codes

The weather API returns conditions as WMO numeric codes. Zero means clear sky, ninety-five means thunderstorm, and users shouldn't need to know that. A lookup function turns those codes into human-readable descriptions.

Save this as weather_codes.py:

weather_codes.py
def interpret_weather_code(code):
    """
    Convert WMO weather code to readable description.
    
    Args:
        code: Integer weather code from API
        
    Returns:
        String description of weather conditions
    """
    weather_codes = {
        # Clear conditions
        0: "Clear sky",
        1: "Mainly clear",
        2: "Partly cloudy",
        3: "Overcast",
        
        # Fog
        45: "Fog",
        48: "Depositing rime fog",
        
        # Drizzle
        51: "Light drizzle",
        53: "Moderate drizzle",
        55: "Dense drizzle",
        56: "Light freezing drizzle",
        57: "Dense freezing drizzle",
        
        # Rain
        61: "Slight rain",
        63: "Moderate rain",
        65: "Heavy rain",
        66: "Light freezing rain",
        67: "Heavy freezing rain",
        
        # Snow
        71: "Slight snow",
        73: "Moderate snow",
        75: "Heavy snow",
        77: "Snow grains",
        
        # Rain showers
        80: "Slight rain showers",
        81: "Moderate rain showers",
        82: "Violent rain showers",
        
        # Snow showers
        85: "Slight snow showers",
        86: "Heavy snow showers",
        
        # Thunderstorm
        95: "Thunderstorm",
        96: "Thunderstorm with slight hail",
        99: "Thunderstorm with heavy hail"
    }
    
    return weather_codes.get(code, f"Unknown weather code: {code}")

if __name__ == "__main__":
    # Test weather code interpretation
    test_codes = [0, 3, 61, 71, 95]

    print("Weather code interpretations:")
    for code in test_codes:
        description = interpret_weather_code(code)
        print(f"  Code {code}: {description}")

Run it:

Terminal
$ python weather_codes.py
Weather code interpretations:
  Code 0: Clear sky
  Code 3: Overcast
  Code 61: Slight rain
  Code 71: Slight snow
  Code 95: Thunderstorm

The table covers the WMO codes Open-Meteo documents today. Unknown codes still fall through to a descriptive default rather than crashing -- the API could always introduce a new code before your code is updated. The dashboard on a later page imports this function to turn weather_code: 3 into "Overcast" in the display.

Phase 3: the production weather layer

Time to wrap the ad-hoc request into a production class that matches the geocoding layer's shape. Same approach: subclass Chapter 7's APIClient, let it carry the retry and timeout logic, expose a single method that returns data or None.

Save this as weather_client.py:

weather_client.py
from auth_module import APIClient
from weather_codes import interpret_weather_code

CURRENT_FIELDS = [
    "temperature_2m",
    "relative_humidity_2m",
    "apparent_temperature",
    "weather_code",
    "wind_speed_10m",
    "wind_direction_10m",
]

DAILY_FIELDS = [
    "temperature_2m_max",
    "temperature_2m_min",
    "weather_code",
    "precipitation_sum",
    "wind_speed_10m_max",
]

class WeatherClient(APIClient):
    """Open-Meteo forecast layer. No API key required."""

    def __init__(self):
        super().__init__(
            api_key_env_var=None,  # Open-Meteo is key-less
            base_url="https://api.open-meteo.com/v1",
        )

    def get_weather_data(self, latitude, longitude, days=5):
        """
        Fetch current conditions plus a multi-day forecast.

        Returns the full response dict on success, or None on failure.
        """
        print(f"Fetching {days}-day weather forecast...")

        success, data, message = self._make_request(
            "/forecast",
            params={
                "latitude": latitude,
                "longitude": longitude,
                "current": CURRENT_FIELDS,
                "daily": DAILY_FIELDS,
                "timezone": "auto",
                "forecast_days": days,
            },
        )
        if not success:
            print(f"Weather request failed: {message}")
            return None

        required_sections = ["current", "current_units", "daily", "daily_units"]
        missing = [section for section in required_sections if section not in data]
        if missing:
            print(f"Weather response missing sections: {', '.join(missing)}")
            return None

        daily = data["daily"]
        required_daily_fields = ["time", "temperature_2m_max", "temperature_2m_min"]
        missing_daily = [field for field in required_daily_fields if field not in daily]
        if missing_daily:
            print(f"Weather daily forecast missing fields: {', '.join(missing_daily)}")
            return None

        print("Weather data retrieved successfully")
        return data

if __name__ == "__main__":
    client = WeatherClient()
    test_locations = [
        (53.33306, -6.24889, "Dublin"),
        (35.6895, 139.69171, "Tokyo"),
    ]

    for lat, lon, name in test_locations:
        print(f"\nTesting weather forecast for {name}:")
        weather_data = client.get_weather_data(lat, lon, days=3)

        if weather_data:
            current = weather_data["current"]
            temp = current["temperature_2m"]
            conditions = interpret_weather_code(current["weather_code"])
            print(f"  Current: {temp}°C, {conditions}")

            daily = weather_data["daily"]
            avg_high = sum(daily["temperature_2m_max"]) / len(daily["temperature_2m_max"])
            avg_low = sum(daily["temperature_2m_min"]) / len(daily["temperature_2m_min"])
            print(f"  Forecast: {len(daily['time'])} days, avg {avg_low:.1f}°C to {avg_high:.1f}°C")
        else:
            print("  Weather forecast failed")

        print("-" * 50)

Run it:

Terminal
$ python weather_client.py

Testing weather forecast for Dublin:
Fetching 3-day weather forecast...
Weather data retrieved successfully
  Current: 8.3°C, Overcast
  Forecast: 3 days, avg 5.4°C to 9.7°C
--------------------------------------------------

Testing weather forecast for Tokyo:
Fetching 3-day weather forecast...
Weather data retrieved successfully
  Current: 7.2°C, Clear sky
  Forecast: 3 days, avg 3.2°C to 11.8°C
--------------------------------------------------

Two layers, same shape. Both subclass APIClient. Both expose a single method that returns data or None. Both leave retry and timeout handling to the Chapter 7 spine. The coordination page on the other side only has to handle two possible answers from each layer, which is exactly what clean interfaces buy you.