"""
title: Norwegian Meteorological Institute weather fetcher
date created: 2025-04-14
date last updated: 2025-04-14
author: GoldenLeafBird & Google Gemini
author_url: https://github.com/open-webui/open-webui
funding_url: https://github.com/open-webui/open-webui
version: 1.0.0 # First release version
requirements: requests
description: >
Fetches weather using the Norwegian Meteorological Institute weather API (incl. apparent temp via JAG/TI Wind Chill or Heat Index when applicable) and forecast.
Requires admin email config. Allows user unit system selection.
"""
import requests
import json
import datetime
import urllib.parse
from math import floor, isnan, pow # Keep pow, removed exp
from typing import Optional, Dict, Any, Tuple, Literal
from pydantic import BaseModel, Field
# --- Constants ---
MET_API_URL_FORMAT = (
"https://api.met.no/weatherapi/locationforecast/2.0/complete?lat={lat}&lon={lon}"
)
GEOCODING_API_URL_FORMAT = "https://geocoding-api.open-meteo.com/v1/search?name={location}&count=1&language=en&format=json"
USER_AGENT_FORMAT = "OpenWebUI/1.0 (User: {email}; Tool: Current Weather)"
# --- Conversion Factors ---
CELSIUS_TO_KELVIN_OFFSET = 273.15
MPS_TO_KMH = 3.6
MPS_TO_MPH = 2.23694
KMH_TO_MPS = 1 / MPS_TO_KMH # Needed for wind threshold check
HPA_TO_PSI = 0.01450377
HPA_TO_PA = 100.0
# --- Apparent Temperature Formula Thresholds (JAG/TI & Steadman/Rothfusz) ---
# Wind Chill (JAG/TI) Applicability
WIND_CHILL_TEMP_THRESHOLD_C = 10.0
WIND_CHILL_WIND_THRESHOLD_KMH = 4.8 # Approx 3 mph
# Heat Index Applicability (Steadman via Rothfusz regression)
HEAT_INDEX_TEMP_THRESHOLD_C = 26.67 # 80°F
HEAT_INDEX_HUMIDITY_THRESHOLD_PERCENT = 40.0
# --- Helper Functions ---
# Basic Temperature Conversions
def _c_to_f(celsius: float) -> float:
"""Converts Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
def _f_to_c(fahrenheit: float) -> float:
"""Converts Fahrenheit to Celsius."""
return (fahrenheit - 32) * 5 / 9
def _c_to_k(celsius: float) -> float:
"""Converts Celsius to Kelvin."""
return celsius + CELSIUS_TO_KELVIN_OFFSET
# Basic Speed/Pressure Conversions
def _ms_to_kmh(ms: float) -> float:
"""Converts meters per second to kilometers per hour."""
return ms * MPS_TO_KMH
def _ms_to_mph(ms: float) -> float:
"""Converts meters per second to miles per hour."""
return ms * MPS_TO_MPH
def _hpa_to_psi(hpa: float) -> float:
"""Converts hectopascals to pounds per square inch."""
return hpa * HPA_TO_PSI
def _hpa_to_pa(hpa: float) -> float:
"""Converts hectopascals to Pascals."""
return hpa * HPA_TO_PA
# Unit System Formatting Helpers
def _format_temperature(
celsius: Optional[float], system: Literal["metric", "imperial", "scientific"]
) -> str:
"""Formats temperature in Celsius to the target unit system string."""
if celsius is None or isnan(celsius):
return "N/A"
if system == "imperial":
return f"{round(_c_to_f(celsius))}°F"
if system == "scientific":
return f"{round(_c_to_k(celsius))}K"
# Default to metric
return f"{round(celsius)}°C"
def _format_wind_speed(
speed_ms: Optional[float], system: Literal["metric", "imperial", "scientific"]
) -> str:
"""Formats wind speed in m/s to the target unit system string."""
if speed_ms is None or isnan(speed_ms):
return "N/A"
if system == "imperial":
return f"{round(_ms_to_mph(speed_ms))} mph"
if system == "scientific":
return f"{round(speed_ms, 1)} m/s"
# Default to metric
return f"{round(_ms_to_kmh(speed_ms))} km/h"
def _format_pressure(
hpa: Optional[float], system: Literal["metric", "imperial", "scientific"]
) -> str:
"""Formats pressure in hPa to the target unit system string."""
if hpa is None or isnan(hpa):
return "N/A"
if system == "imperial":
return f"{_hpa_to_psi(hpa):.1f} psi"
if system == "scientific":
return f"{round(_hpa_to_pa(hpa))} Pa"
# Default to metric
return f"{hpa:.1f} hPa"
# Apparent Temperature Calculation Helpers
def _calculate_wind_chill_celsius(celsius: float, speed_ms: float) -> Optional[float]:
"""
Calculates Wind Chill in Celsius using the standard JAG/TI formula.
Returns None if calculation is not applicable or fails.
Args:
celsius: Air temperature in Celsius.
speed_ms: Wind speed in meters per second.
Returns:
Calculated wind chill in Celsius, or None.
"""
speed_kmh = _ms_to_kmh(speed_ms)
# Check applicability thresholds
if (
celsius >= WIND_CHILL_TEMP_THRESHOLD_C
or speed_kmh < WIND_CHILL_WIND_THRESHOLD_KMH
):
return None # Not applicable
try:
# JAG/TI formula using km/h
v_pow_016 = pow(speed_kmh, 0.16)
wind_chill = (
13.12
+ (0.6215 * celsius)
- (11.37 * v_pow_016)
+ (0.3965 * celsius * v_pow_016)
)
# Wind chill should not be warmer than the air temperature
return min(celsius - 0.0001, wind_chill)
except (ValueError, OverflowError):
print(f"Warning: Error calculating Wind Chill for T={celsius}, V={speed_kmh}")
return None
def _calculate_heat_index_celsius(
celsius: float, humidity_percent: float
) -> Optional[float]:
"""
Calculates Heat Index in Celsius using the Steadman formula via Rothfusz regression.
Returns None if calculation is not applicable or fails.
Args:
celsius: Air temperature in Celsius.
humidity_percent: Relative humidity in percent.
Returns:
Calculated heat index in Celsius, or None.
"""
fahrenheit = _c_to_f(celsius)
# Check applicability thresholds (using Fahrenheit for formula basis)
if fahrenheit < 80.0 or humidity_percent < HEAT_INDEX_HUMIDITY_THRESHOLD_PERCENT:
return None # Not applicable
try:
# Rothfusz regression (simplified Steadman) - uses Fahrenheit and RH%
T = fahrenheit
RH = humidity_percent
# Full regression formula for accuracy
HI_f = (
-42.379
+ (2.04901523 * T)
+ (10.14333127 * RH)
- (0.22475541 * T * RH)
- (0.00683783 * T * T)
- (0.05481717 * RH * RH)
+ (0.00122874 * T * T * RH)
+ (0.00085282 * T * RH * RH)
- (0.00000199 * T * T * RH * RH)
)
# Convert the result back to Celsius
HI_c = _f_to_c(HI_f)
# Heat index should not be cooler than the air temperature
return max(celsius + 0.0001, HI_c)
except (ValueError, OverflowError):
print(
f"Warning: Error calculating Heat Index for T={celsius}, RH={humidity_percent}"
)
return None
def _calculate_apparent_temperature(
celsius: Optional[float],
speed_ms: Optional[float],
humidity_percent: Optional[float],
) -> Optional[float]:
"""
Calculates apparent temperature ('feels like') in Celsius.
Conditionally applies JAG/TI Wind Chill or Steadman/Rothfusz Heat Index formulas.
Returns the original air temperature if neither formula applies or modifies the value.
Returns None only if the input Celsius temperature is None.
Args:
celsius: Air temperature in Celsius.
speed_ms: Wind speed in meters per second.
humidity_percent: Relative humidity in percent.
Returns:
The calculated apparent temperature in Celsius, the original temperature if
no adjustments apply, or None if input temperature is None.
"""
if celsius is None or isnan(celsius):
return None
apparent_temp_c = celsius # Start with the actual air temperature
# Try applying Wind Chill if conditions are met and it lowers the temp
if speed_ms is not None and not isnan(speed_ms):
wind_chill_c = _calculate_wind_chill_celsius(celsius, speed_ms)
if wind_chill_c is not None and wind_chill_c < celsius:
# If Wind Chill applies and makes it feel colder, use it and return
return wind_chill_c # Prioritize wind chill if applicable
# Try applying Heat Index if conditions are met and it raises the temp
if humidity_percent is not None and not isnan(humidity_percent):
heat_index_c = _calculate_heat_index_celsius(celsius, humidity_percent)
if heat_index_c is not None and heat_index_c > celsius:
# If Heat Index applies and makes it feel hotter, use it
return heat_index_c # Use heat index if applicable (and wind chill wasn't)
# If neither Wind Chill nor Heat Index applied or significantly changed the temp,
# return the original air temperature.
return apparent_temp_c
# Wind Helpers
def _degrees_to_direction(degrees: Optional[float]) -> str:
"""Converts wind direction in degrees to a cardinal direction string."""
if degrees is None or isnan(degrees):
return "N/A"
# Map degrees to 8 primary directions
directions = [
"north",
"northeast",
"east",
"southeast",
"south",
"southwest",
"west",
"northwest",
]
# Calculate index, wrapping around using modulo
index = floor((degrees + 22.5) / 45) % 8
return directions[index]
def _get_wind_description(
speed_ms: Optional[float],
direction_deg: Optional[float],
system: Literal["metric", "imperial", "scientific"],
) -> str:
"""Creates a human-readable wind description using the Beaufort scale and direction."""
if speed_ms is None or isnan(speed_ms):
return "No wind data available"
# Determine Beaufort scale description based on m/s
if speed_ms < 0.3:
beaufort = "Calm"
elif speed_ms < 1.6:
beaufort = "Light air"
elif speed_ms < 3.4:
beaufort = "Light breeze"
elif speed_ms < 5.5:
beaufort = "Gentle breeze"
elif speed_ms < 8.0:
beaufort = "Moderate breeze"
elif speed_ms < 10.8:
beaufort = "Fresh breeze"
elif speed_ms < 13.9:
beaufort = "Strong breeze"
else:
beaufort = "High wind" # Simplified scale for higher winds
# Get direction string
direction = _degrees_to_direction(direction_deg)
direction_str = f"from the {direction}" if direction != "N/A" else ""
# Get formatted speed string
speed_str = _format_wind_speed(speed_ms, system)
if speed_str == "N/A":
# If speed is somehow N/A but we have a Beaufort scale, show that and direction
return f"{beaufort} {direction_str}".strip()
# Combine Beaufort scale, direction, and speed
separator = " " if direction_str else "" # Add space only if direction exists
return f"{beaufort}{separator}{direction_str} at {speed_str}"
# Weather Condition Mapping
def _map_condition_code(code: Optional[str]) -> str:
"""Maps Met.no symbol codes to human-readable weather conditions."""
if code is None:
return "Unknown condition"
# Comprehensive mapping based on Met.no documentation
mapping = {
"clearsky_day": "Clear sky",
"clearsky_night": "Clear sky",
"clearsky_polartwilight": "Clear sky",
"fair_day": "Fair",
"fair_night": "Fair",
"fair_polartwilight": "Fair",
"partlycloudy_day": "Partly cloudy",
"partlycloudy_night": "Partly cloudy",
"partlycloudy_polartwilight": "Partly cloudy",
"cloudy": "Cloudy",
"rainshowers_day": "Rain showers",
"rainshowers_night": "Rain showers",
"rainshowers_polartwilight": "Rain showers",
"rainshowersandthunder_day": "Rain showers and thunder",
"rainshowersandthunder_night": "Rain showers and thunder",
"rainshowersandthunder_polartwilight": "Rain showers and thunder",
"sleetshowers_day": "Sleet showers",
"sleetshowers_night": "Sleet showers",
"sleetshowers_polartwilight": "Sleet showers",
"snowshowers_day": "Snow showers",
"snowshowers_night": "Snow showers",
"snowshowers_polartwilight": "Snow showers",
"rain": "Rain",
"heavyrain": "Heavy rain",
"heavyrainandthunder": "Heavy rain and thunder",
"sleet": "Sleet",
"snow": "Snow",
"snowandthunder": "Snow and thunder",
"fog": "Fog",
"sleetshowersandthunder_day": "Sleet showers and thunder",
"sleetshowersandthunder_night": "Sleet showers and thunder",
"sleetshowersandthunder_polartwilight": "Sleet showers and thunder",
"snowshowersandthunder_day": "Snow showers and thunder",
"snowshowersandthunder_night": "Snow showers and thunder",
"snowshowersandthunder_polartwilight": "Snow showers and thunder",
"rainandthunder": "Rain and thunder",
"sleetandthunder": "Sleet and thunder",
"lightrainshowersandthunder_day": "Light rain showers and thunder",
"lightrainshowersandthunder_night": "Light rain showers and thunder",
"lightrainshowersandthunder_polartwilight": "Light rain showers and thunder",
"heavyrainshowersandthunder_day": "Heavy rain showers and thunder",
"heavyrainshowersandthunder_night": "Heavy rain showers and thunder",
"heavyrainshowersandthunder_polartwilight": "Heavy rain showers and thunder",
"lightssleetshowersandthunder_day": "Light sleet showers and thunder",
"lightssleetshowersandthunder_night": "Light sleet showers and thunder",
"lightssleetshowersandthunder_polartwilight": "Light sleet showers and thunder",
"heavysleetshowersandthunder_day": "Heavy sleet showers and thunder",
"heavysleetshowersandthunder_night": "Heavy sleet showers and thunder",
"heavysleetshowersandthunder_polartwilight": "Heavy sleet showers and thunder",
"lightssnowshowersandthunder_day": "Light snow showers and thunder",
"lightssnowshowersandthunder_night": "Light snow showers and thunder",
"lightssnowshowersandthunder_polartwilight": "Light snow showers and thunder",
"heavysnowshowersandthunder_day": "Heavy snow showers and thunder",
"heavysnowshowersandthunder_night": "Heavy snow showers and thunder",
"heavysnowshowersandthunder_polartwilight": "Heavy snow showers and thunder",
"lightrain": "Light rain",
"lightsleet": "Light sleet",
"heavysleet": "Heavy sleet",
"lightsnow": "Light snow",
"heavysnow": "Heavy snow",
}
# Fallback: try to use the base code part (e.g., "rain" from "rain_day")
base_code = code.split("_")[0]
return mapping.get(code, base_code.replace("-", " ").capitalize())
# Forecast Parsing
def _parse_forecasts(timeseries: list) -> Dict[str, Any]:
"""
Parses the timeseries data to extract simplified daily and nightly forecasts.
Args:
timeseries: List of forecast data points from the Met.no API.
Returns:
Dictionary containing 'today_condition', 'today_high_c',
'tonight_condition', and 'tonight_low_c'. Values can be None.
"""
today_high_c: Optional[float] = None
tonight_low_c: Optional[float] = None
today_condition: Optional[str] = None
tonight_condition: Optional[str] = None
now_hour = datetime.datetime.now(datetime.timezone.utc).hour
processed_today_condition = False
processed_tonight_condition = False
# Iterate through timeseries to find highs, lows, and representative conditions
for item in timeseries:
time_str = item.get("time", "")
instant_details = item.get("data", {}).get("instant", {}).get("details", {})
instant_temp = instant_details.get("air_temperature")
# Get condition summary code (prefer next 6/12 hours, fallback to 1 hour)
symbol_code = None
summary_data = item.get("data", {}).get("next_6_hours") or item.get(
"data", {}
).get("next_12_hours")
if summary_data:
symbol_code = summary_data.get("summary", {}).get("symbol_code")
if not symbol_code:
summary_data_1hr = item.get("data", {}).get("next_1_hours")
if summary_data_1hr:
symbol_code = summary_data_1hr.get("summary", {}).get("symbol_code")
try:
item_time = datetime.datetime.fromisoformat(time_str.replace("Z", "+00:00"))
item_hour = item_time.hour
is_daytime = 6 <= item_hour < 18 # Define daytime hours (UTC)
is_nighttime = not is_daytime
# Capture the first representative condition for day/night
if is_daytime and not processed_today_condition and symbol_code:
today_condition = _map_condition_code(symbol_code)
processed_today_condition = True
elif is_nighttime and not processed_tonight_condition and symbol_code:
tonight_condition = _map_condition_code(symbol_code)
processed_tonight_condition = True
# Update high/low temps based on daytime/nighttime
if instant_temp is not None and not isnan(instant_temp):
if is_daytime:
if today_high_c is None or instant_temp > today_high_c:
today_high_c = instant_temp
# Note: This captures lows during any 'nighttime' hour in the forecast
if is_nighttime:
if tonight_low_c is None or instant_temp < tonight_low_c:
tonight_low_c = instant_temp
except (ValueError, TypeError):
# Ignore items with invalid time format or temperature
pass
# Special handling for 'tonight's low if current time is already evening/night
# Look for the lowest temperature in the remaining *future* nighttime hours
if 18 <= now_hour or now_hour < 6: # If currently night in UTC
future_low: Optional[float] = None
now_utc = datetime.datetime.now(datetime.timezone.utc)
for item in timeseries:
try:
item_time = datetime.datetime.fromisoformat(
item.get("time", "").replace("Z", "+00:00")
)
instant_temp = (
item.get("data", {})
.get("instant", {})
.get("details", {})
.get("air_temperature")
)
# Check if temp is valid, is in the future, and falls in a nighttime hour
if (
instant_temp is not None
and not isnan(instant_temp)
and item_time > now_utc
):
item_hour = item_time.hour
if item_hour < 6 or item_hour >= 18: # Nighttime hour check
if future_low is None or instant_temp < future_low:
future_low = instant_temp
except (ValueError, TypeError):
pass
# If we found a valid future low, use it as 'tonight's low
if future_low is not None:
tonight_low_c = future_low
# Provide reasonable default conditions if none were found
if not today_condition:
today_condition = "Partly cloudy" # Default daytime condition
if not tonight_condition:
# Default tonight condition depends on whether it's currently night or day
tonight_condition = (
"Clear" if 18 <= now_hour or now_hour < 6 else "Partly cloudy"
)
return {
"today_condition": today_condition,
"today_high_c": today_high_c,
"tonight_condition": tonight_condition,
"tonight_low_c": tonight_low_c,
}
# Geocoding Helper
def _get_coordinates(
location: str, user_agent: str
) -> Optional[Tuple[float, float, str]]:
"""
Gets latitude, longitude, and a resolved location name for a given location string.
Args:
location: The location name string (e.g., "London", "Paris, France").
user_agent: The User-Agent string to use for the API request.
Returns:
A tuple containing (latitude, longitude, resolved_name), or None if not found or error.
"""
if not location:
return None
# Encode location for URL and construct API request URL
encoded_location = urllib.parse.quote(location)
geocode_url = GEOCODING_API_URL_FORMAT.format(location=encoded_location)
headers = {"User-Agent": user_agent}
try:
# Make request to geocoding API
response = requests.get(
geocode_url, headers=headers, timeout=5
) # Short timeout for geocoding
response.raise_for_status() # Check for HTTP errors
data = response.json()
# Parse response to find the first result
if (
"results" in data
and isinstance(data["results"], list)
and len(data["results"]) > 0
):
result = data["results"][0]
lat = result.get("latitude")
lon = result.get("longitude")
# Construct a resolved name from available parts
name_parts = [
result.get("name"),
result.get("admin1"), # State/Region
result.get("country"),
]
resolved_name = ", ".join(
part for part in name_parts if part
) # Join non-empty parts
# Ensure essential data is present
if lat is not None and lon is not None and resolved_name:
return float(lat), float(lon), resolved_name
# If no results or essential data missing
return None
except requests.exceptions.RequestException as e:
print(f"Error during geocoding request: {e}")
return None
except (json.JSONDecodeError, KeyError, IndexError, ValueError, TypeError) as e:
print(f"Error parsing geocoding response: {e}")
return None
# --- Main Tool Class ---
class Tools:
"""
Open WebUI Tool to fetch current weather conditions and a brief forecast
using the Met.no LocationForecast API and Open-Meteo Geocoding API.
Includes calculation for apparent temperature (wind chill/heat index).
"""
# Admin-configurable Valves
class Valves(BaseModel):
"""Valves configured by the Open WebUI admin."""
admin_email: str = Field(
default="",
description="Admin contact email for API User-Agent (Required by Met.no policy).",
)
pass # Recommended practice for parser
# User-configurable Valves
class UserValves(BaseModel):
"""Valves configurable by the end-user per chat."""
unit_system: Literal["metric", "imperial", "scientific"] = Field(
default="metric",
description="Output units (metric: °C, km/h, hPa; imperial: °F, mph, psi; scientific: K, m/s, Pa).",
)
pass # Recommended practice for parser
def __init__(self):
"""
Initializes the Tool, setting up the User-Agent based on admin configuration.
"""
self.valves = self.Valves() # Load admin valves
admin_email = self.valves.admin_email
# Set User-Agent string based on admin email presence (Met.no requirement)
if admin_email and "@" in admin_email:
self.user_agent = USER_AGENT_FORMAT.format(email=admin_email)
else:
# Use a generic agent if email is not configured, print warning
print(
f"WARNING: Weather Tool admin_email valve not configured. Using generic User-Agent."
)
self.user_agent = "OpenWebUI-Tool/Weather/1.4.0 (Email Not Configured)"
def get_current_weather(self, location: str, __user__: dict) -> str:
"""
Fetches weather (incl. apparent temp via JAG/TI Wind Chill or Heat Index
when applicable) and forecast for a specified location.
Args:
location (str): The name of the location (e.g., "Oslo", "New York City").
__user__ (dict): Dictionary containing user info and UserValves.
Returns:
str: A formatted string containing the weather report or an error message.
"""
# --- 1. Configuration and Input Validation ---
# Check if admin email is configured (critical for Met.no API)
admin_email = self.valves.admin_email
if not admin_email or "@" not in admin_email:
return "Error: Weather Tool requires admin configuration (contact email)."
# Validate location input
if not location or not isinstance(location, str) or not location.strip():
return "Error: No location provided."
# --- 2. User Preferences ---
# Determine the preferred unit system from UserValves, default to metric
preferred_system: Literal["metric", "imperial", "scientific"] = "metric"
try:
user_valves_data = __user__.get("valves") # Access UserValves dictionary
if user_valves_data:
# Safely convert Pydantic model to dict if needed (or access attributes directly)
valves_dict = dict(user_valves_data)
preferred_system_raw = valves_dict.get("unit_system", "metric")
# Validate the user's choice
if preferred_system_raw in ["metric", "imperial", "scientific"]:
preferred_system = preferred_system_raw
except Exception as e:
# Log error if needed, but proceed with default units
print(f"Warning: Could not read user valves for unit system: {e}")
pass # Silently ignore errors reading user valves, default to metric
# --- 3. Geocoding ---
# Get coordinates for the location
geocode_result = _get_coordinates(location.strip(), self.user_agent)
if geocode_result is None:
# Inform user if location couldn't be found
return f"Sorry, couldn't find coordinates for '{location}'. Try being more specific."
# Unpack coordinates and resolved name if successful
latitude, longitude, resolved_location_name = geocode_result
# --- 4. Fetch Weather Data ---
# Construct the Met.no API URL
met_api_url = MET_API_URL_FORMAT.format(lat=latitude, lon=longitude)
headers = {"User-Agent": self.user_agent} # Use configured User-Agent
try:
# Make the request to the weather API
response = requests.get(
met_api_url, headers=headers, timeout=10
) # 10-second timeout
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
data = response.json() # Parse the JSON response
# --- 5. Data Extraction ---
# Navigate the JSON structure to get timeseries data
properties = data.get("properties")
timeseries = properties.get("timeseries") if properties else None
if not timeseries or len(timeseries) < 1:
return "Error: Missing 'timeseries' data in API response."
# Get the current data point (usually the first item)
current_data = timeseries[0].get("data", {})
current_details = current_data.get("instant", {}).get("details", {})
if not current_details:
return "Error: Missing 'instant.details' data in API response."
# Get the weather condition code (try 1-hour, then 6-hour summary)
next_hour_summary = current_data.get("next_1_hours", {}).get("summary", {})
condition_code = next_hour_summary.get("symbol_code")
if not condition_code:
next_6_hours_summary = current_data.get("next_6_hours", {}).get(
"summary", {}
)
if next_6_hours_summary:
condition_code = next_6_hours_summary.get("symbol_code")
# Extract individual weather parameters
temperature_celsius = current_details.get("air_temperature")
humidity = current_details.get("relative_humidity")
wind_speed_ms = current_details.get("wind_speed")
wind_from_direction = current_details.get("wind_from_direction")
pressure_hpa = current_details.get("air_pressure_at_sea_level")
# Map condition code to readable string
condition = _map_condition_code(condition_code)
# --- 6. Calculations & Formatting ---
# Format current temperature
temp_str = _format_temperature(temperature_celsius, preferred_system)
# Calculate and format apparent temperature
apparent_temp_c = _calculate_apparent_temperature(
temperature_celsius, wind_speed_ms, humidity
)
apparent_temp_str = _format_temperature(apparent_temp_c, preferred_system)
# Format humidity, wind, and pressure
humidity_str = (
f"{round(humidity)}%"
if humidity is not None and not isnan(humidity)
else "N/A"
)
wind_description = _get_wind_description(
wind_speed_ms, wind_from_direction, preferred_system
)
pressure_str = _format_pressure(pressure_hpa, preferred_system)
# --- 7. Forecast Parsing & Formatting ---
# Parse the timeseries for simplified forecast info
forecasts = _parse_forecasts(timeseries)
# Format forecast temperatures
today_high_str = _format_temperature(
forecasts.get("today_high_c"), preferred_system
)
tonight_low_str = _format_temperature(
forecasts.get("tonight_low_c"), preferred_system
)
# Build today's forecast string
forecast_today_str = "Forecast today unavailable."
today_cond = forecasts.get("today_condition")
if today_cond and today_high_str != "N/A":
forecast_today_str = f"{today_cond}. High {today_high_str}."
elif today_high_str != "N/A": # High temp available, no condition
forecast_today_str = f"Expected high {today_high_str}."
elif today_cond: # Condition available, no high temp
forecast_today_str = f"{today_cond}."
# Build tonight's forecast string
forecast_tonight_str = "Forecast tonight unavailable."
tonight_cond = forecasts.get("tonight_condition")
if tonight_cond and tonight_low_str != "N/A":
forecast_tonight_str = f"{tonight_cond}, low {tonight_low_str}."
elif tonight_low_str != "N/A": # Low temp available, no condition
forecast_tonight_str = f"Expected low {tonight_low_str}."
elif tonight_cond: # Condition available, no low temp
forecast_tonight_str = f"{tonight_cond}."
# --- 8. Date Formatting ---
# Get current date for the report header
current_date = datetime.date.today()
date_str = current_date.strftime("%B %d, %Y") # e.g., "July 26, 2024"
# --- 9. Construct Final Output ---
# Start building the output lines
output_lines = [
f"Weather for {resolved_location_name} on {date_str}:",
"Current Conditions:",
]
# Add temperature line, including "feels like" if different
if (
temp_str != "N/A"
and apparent_temp_str != "N/A"
and temp_str != apparent_temp_str
):
output_lines.append(
f" Temperature: {temp_str} (feels like {apparent_temp_str})"
)
elif (
temp_str != "N/A"
): # Temp available, but same as apparent or apparent is N/A
output_lines.append(f" Temperature: {temp_str}")
else: # Temp is N/A
output_lines.append(" Temperature: N/A")
# Add remaining current conditions
output_lines.extend(
[
f" Condition: {condition}",
f" Wind: {wind_description}",
f" Humidity: {humidity_str}",
f" Pressure: {pressure_str}",
"Forecast:",
f" Today: {forecast_today_str}",
f" Tonight: {forecast_tonight_str}",
]
)
# Join lines into a single string for return
return "\n".join(output_lines)
# --- 10. Exception Handling ---
except requests.exceptions.HTTPError as e:
# Handle specific HTTP errors gracefully
status = e.response.status_code
reason = e.response.reason
if status == 404:
# Location likely valid, but no weather data available from API
return f"Weather data not found for {resolved_location_name}."
elif status == 403:
# Permission issue, likely User-Agent related
return f"Access forbidden to weather API (HTTP 403). Check User-Agent config '{self.user_agent}'. ({reason})"
elif status == 429:
# Rate limited
return (
f"Weather API rate limit exceeded (HTTP 429). Try later. ({reason})"
)
else:
# Other HTTP errors
return f"Error fetching weather: HTTP {status} - {reason}"
except requests.exceptions.Timeout:
return f"Error fetching weather: Request timed out."
except requests.exceptions.RequestException as e:
# General network errors (DNS, connection refused, etc.)
return f"Network error fetching weather: {e}"
except json.JSONDecodeError:
# API returned something that wasn't valid JSON
return f"Error decoding weather data (invalid JSON)."
except (KeyError, IndexError, TypeError) as e:
# Errors likely caused by unexpected API response structure changes
print(
f"Data processing error: {type(e).__name__} - {e}"
) # Log for debugging
return f"Error processing weather data structure. API might have changed."
except Exception as e:
# Catch any other unexpected errors
print(
f"Unexpected tool error: {type(e).__name__} - {e}"
) # Log for debugging
return f"An unexpected error occurred while getting the weather: {type(e).__name__}"