Weather 1.0.0
get_weather.py
9.1 KB · raw
"""What the weather is doing somewhere, and what it will do next.
This is AWORG's first installed Capability, and it is a small one on purpose.
It exists to be the proof that the thing works: a folder that AWORG does not
need, sitting in capabilities/ in the owner's home, loaded and called exactly
like a built-in tool. Delete the folder and the Resident loses the weather and
nothing else. That is the whole shape of a capability in one example.
It is also written the way an installed capability has to be written, and the
two differences from a built-in are both here:
from aworg.tools.base import ... absolute, because a folder in the
home is not inside the package and
has no `..` to reach up through
httpx taken from what AWORG already
depends on. A capability runs in
AWORG's process, with AWORG's
interpreter and AWORG's environment,
so it may use what is there -- and
anything that is not there, it has to
bring with it.
Open-Meteo needs no API key and asks for nothing about the caller, which is
why it is the one used: a shipped example that wanted a credential would be
an example nobody could run.
"""
from __future__ import annotations
import httpx
from aworg.tools.base import ToolContext, ToolError, ToolResult
NAME = "get_weather"
DESCRIPTION = (
"Look up the current weather and the forecast for a place, by name. "
"Give a town, city, postcode or landmark -- 'Dayton, Ohio', 'Kyoto', "
"'45402'. Returns conditions now and a day-by-day forecast."
)
INPUT_SCHEMA = {
"type": "object",
"properties": {
"place": {
"type": "string",
"description": (
"Where to look up. A town or city, optionally with a region "
"or country to tell two of the same name apart."
),
},
"units": {
"type": "string",
"description": (
"'metric' for Celsius and km/h, 'imperial' for Fahrenheit "
"and mph. Defaults to metric."
),
},
"days": {
"type": "integer",
"description": (
"How many days of forecast to include, 0 to 7. Defaults to 3. "
"0 gives current conditions only."
),
},
},
"required": ["place"],
}
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
TIMEOUT = 20
MAX_DAYS = 7
#: WMO weather interpretation codes, which is what the forecast comes back
#: as. Written out rather than passed through as a number, because "code 61"
#: is not weather and a model asked to translate it will sometimes invent a
#: translation.
CONDITIONS = {
0: "clear", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
45: "fog", 48: "freezing fog",
51: "light drizzle", 53: "drizzle", 55: "heavy drizzle",
56: "freezing drizzle", 57: "heavy freezing drizzle",
61: "light rain", 63: "rain", 65: "heavy rain",
66: "freezing rain", 67: "heavy freezing rain",
71: "light snow", 73: "snow", 75: "heavy snow", 77: "snow grains",
80: "light showers", 81: "showers", 82: "violent showers",
85: "light snow showers", 86: "snow showers",
95: "thunderstorms", 96: "thunderstorms with hail",
99: "thunderstorms with heavy hail",
}
async def run(
context: ToolContext,
place: str,
units: str = "metric",
days: int = 3,
) -> ToolResult:
place = (place or "").strip()
if not place:
raise ToolError("get_weather needs a place to look up.")
imperial = str(units).strip().lower() in ("imperial", "us", "f", "fahrenheit")
degrees = "F" if imperial else "C"
speed = "mph" if imperial else "km/h"
days = max(0, min(int(days), MAX_DAYS))
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
found = await _geocode(client, place)
weather = await _forecast(client, found, imperial, days)
current = weather.get("current") or {}
where = _describe_place(found)
lines = [f"{where} (local time {(current.get('time') or '?').replace('T', ' ')})"]
lines.append(
"Now: {temp}°{d}{feels}, {sky}, wind {wind} {s}, humidity {humidity}%".format(
temp=_number(current.get("temperature_2m")),
d=degrees,
feels=(
f" (feels like {_number(current.get('apparent_temperature'))}°{degrees})"
if current.get("apparent_temperature") is not None else ""
),
sky=CONDITIONS.get(current.get("weather_code"), "unknown conditions"),
wind=_number(current.get("wind_speed_10m")),
s=speed,
humidity=_number(current.get("relative_humidity_2m")),
)
)
daily = weather.get("daily") or {}
dates = daily.get("time") or []
if dates:
lines.append("")
for index, date in enumerate(dates):
chance = _at(daily.get("precipitation_probability_max"), index)
lines.append(
"{date} {sky:<22} {low} to {high}°{d}{rain}".format(
date=date,
sky=CONDITIONS.get(
_at(daily.get("weather_code"), index), "unknown"
),
low=_number(_at(daily.get("temperature_2m_min"), index)),
high=_number(_at(daily.get("temperature_2m_max"), index)),
d=degrees,
rain=f" {chance}% chance of precipitation"
if chance not in (None, "") else "",
)
)
return ToolResult(
text="\n".join(lines),
# The whole answer, for an owner who opens the Activity. The tool
# showed the model a readable summary; this is what it was made from.
payload={"place": found, "weather": weather},
summary="{temp}°{d}, {sky}".format(
temp=_number(current.get("temperature_2m")),
d=degrees,
sky=CONDITIONS.get(current.get("weather_code"), "?"),
),
)
async def _geocode(client: httpx.AsyncClient, place: str) -> dict:
"""Turn a place name into a point on the earth.
Failures here are told in the words of the thing that failed. "No place
called 'Daytn, Ohio'" is something a model can act on by spelling it
again; a traceback is not.
"""
try:
response = await client.get(
GEOCODE_URL,
params={"name": place, "count": 1, "language": "en", "format": "json"},
)
response.raise_for_status()
results = (response.json() or {}).get("results") or []
except httpx.HTTPError as exc:
raise ToolError(f"Could not reach the place lookup service: {exc}") from exc
except ValueError as exc:
raise ToolError(f"The place lookup service answered with nonsense: {exc}") from exc
if not results:
raise ToolError(
f"No place called {place!r} was found. Try adding a region or "
"country, or check the spelling."
)
return results[0]
async def _forecast(
client: httpx.AsyncClient, found: dict, imperial: bool, days: int
) -> dict:
params = {
"latitude": found.get("latitude"),
"longitude": found.get("longitude"),
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,"
"precipitation,weather_code,wind_speed_10m",
"timezone": "auto",
}
if days:
params["daily"] = ("weather_code,temperature_2m_max,temperature_2m_min,"
"precipitation_probability_max")
params["forecast_days"] = days
if imperial:
params["temperature_unit"] = "fahrenheit"
params["wind_speed_unit"] = "mph"
params["precipitation_unit"] = "inch"
try:
response = await client.get(FORECAST_URL, params=params)
response.raise_for_status()
return response.json() or {}
except httpx.HTTPError as exc:
raise ToolError(f"Could not reach the weather service: {exc}") from exc
except ValueError as exc:
raise ToolError(f"The weather service answered with nonsense: {exc}") from exc
def _describe_place(found: dict) -> str:
"""Where it actually looked, which is not always where it was asked.
A name matched loosely is the commonest way a weather answer is wrong,
and the only defence is saying plainly which of the four Springfields
this is.
"""
parts = [found.get("name")]
for key in ("admin1", "country"):
value = found.get(key)
if value and value not in parts:
parts.append(value)
return ", ".join(part for part in parts if part)
def _at(values, index):
if isinstance(values, list) and index < len(values):
return values[index]
return None
def _number(value) -> str:
if value is None:
return "?"
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)