Spaces:
Sleeping
Sleeping
fist push the app
Browse files- README.md +61 -9
- app.py +3 -65
- models/mock_model/model.onnx +0 -0
- models/mock_model/model.onnx.data +0 -0
- requirements-train.txt +2 -0
- requirements.txt +7 -0
- scripts/fetch_historical_weather.py +23 -0
- scripts/smoke_test_gradio_app.py +39 -0
- scripts/train_export_onnx.py +97 -0
- weatherpred/__init__.py +1 -0
- weatherpred/agent.py +203 -0
- weatherpred/config.py +35 -0
- weatherpred/dashboard.py +246 -0
- weatherpred/data.py +108 -0
- weatherpred/modeling.py +128 -0
- weatherpred/prompts.py +54 -0
- weatherpred/tools.py +200 -0
- weatherpred/zerogpu.py +15 -0
README.md
CHANGED
|
@@ -1,15 +1,67 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 💬
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.5.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
hf_oauth: true
|
| 11 |
-
hf_oauth_scopes:
|
| 12 |
-
- inference-api
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Tokyo Weather Forecast Lab
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: gradio
|
|
|
|
| 4 |
app_file: app.py
|
| 5 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# WeatherPred
|
| 9 |
+
|
| 10 |
+
A Gradio app for comparing Tokyo five-day weather forecasts from Open-Meteo with a local ONNX model forecast.
|
| 11 |
+
|
| 12 |
+
## Repository Layout
|
| 13 |
+
|
| 14 |
+
```text
|
| 15 |
+
app.py # HuggingFace Spaces entrypoint
|
| 16 |
+
weatherpred/ # Application package
|
| 17 |
+
config.py # Paths, constants, Tokyo coordinates
|
| 18 |
+
data.py # Open-Meteo fetch and CSV/JSON cache
|
| 19 |
+
modeling.py # ONNX inference and forecast comparison
|
| 20 |
+
agent.py # Gemini-backed agent orchestration and fallback logic
|
| 21 |
+
prompts.py # LLM routing and response prompts
|
| 22 |
+
tools.py # Deterministic forecast/trend/seasonality/model tools
|
| 23 |
+
dashboard.py # Gradio UI
|
| 24 |
+
zerogpu.py # HuggingFace ZeroGPU compatibility
|
| 25 |
+
models/mock_model/ # ONNX model artifacts
|
| 26 |
+
scripts/ # Data/model utility scripts
|
| 27 |
+
requirements.txt # Runtime dependencies
|
| 28 |
+
requirements-train.txt # Optional model training dependencies
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Local Run
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
python scripts/fetch_historical_weather.py
|
| 36 |
+
python app.py
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## HuggingFace ZeroGPU
|
| 40 |
+
|
| 41 |
+
Free HuggingFace Gradio Spaces may run on ZeroGPU only. A tiny hidden compatibility probe is decorated with `@spaces.GPU(duration=10)` so the Space satisfies the ZeroGPU startup check, while the real weather dashboard refresh remains CPU-based. The local fallback keeps the app runnable outside HuggingFace.
|
| 42 |
+
|
| 43 |
+
## Agent Capabilities
|
| 44 |
+
|
| 45 |
+
The chat agent uses Gemini when `GEMINI_API_KEY` is available, with a deterministic fallback parser when it is not. It can route user requests to typed Python tools for:
|
| 46 |
+
|
| 47 |
+
- next-N-day forecasts, up to 16 days
|
| 48 |
+
- historical trends over the cached weather history
|
| 49 |
+
- seasonal/monthly cyclic patterns
|
| 50 |
+
- model-vs-Open-Meteo comparison
|
| 51 |
+
- model and data-source explanation
|
| 52 |
+
|
| 53 |
+
The LLM does not invent forecasts directly; it selects tools and explains their computed results.
|
| 54 |
+
|
| 55 |
+
## Optional Gemini Briefing
|
| 56 |
+
|
| 57 |
+
Set `GEMINI_API_KEY` as a HuggingFace Space secret. If it is absent, the app uses deterministic fallback summaries and routing.
|
| 58 |
+
|
| 59 |
+
## Optional Model Export
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
pip install -r requirements-train.txt
|
| 63 |
+
python scripts/fetch_historical_weather.py
|
| 64 |
+
python scripts/train_export_onnx.py
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
The training helper exports the ONNX model to `models/mock_model/model.onnx`.
|
app.py
CHANGED
|
@@ -1,69 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
from huggingface_hub import InferenceClient
|
| 3 |
|
| 4 |
-
|
| 5 |
-
def respond(
|
| 6 |
-
message,
|
| 7 |
-
history: list[dict[str, str]],
|
| 8 |
-
system_message,
|
| 9 |
-
max_tokens,
|
| 10 |
-
temperature,
|
| 11 |
-
top_p,
|
| 12 |
-
hf_token: gr.OAuthToken,
|
| 13 |
-
):
|
| 14 |
-
"""
|
| 15 |
-
For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
|
| 16 |
-
"""
|
| 17 |
-
client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
|
| 18 |
-
|
| 19 |
-
messages = [{"role": "system", "content": system_message}]
|
| 20 |
-
|
| 21 |
-
messages.extend(history)
|
| 22 |
-
|
| 23 |
-
messages.append({"role": "user", "content": message})
|
| 24 |
-
|
| 25 |
-
response = ""
|
| 26 |
-
|
| 27 |
-
for message in client.chat_completion(
|
| 28 |
-
messages,
|
| 29 |
-
max_tokens=max_tokens,
|
| 30 |
-
stream=True,
|
| 31 |
-
temperature=temperature,
|
| 32 |
-
top_p=top_p,
|
| 33 |
-
):
|
| 34 |
-
choices = message.choices
|
| 35 |
-
token = ""
|
| 36 |
-
if len(choices) and choices[0].delta.content:
|
| 37 |
-
token = choices[0].delta.content
|
| 38 |
-
|
| 39 |
-
response += token
|
| 40 |
-
yield response
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
"""
|
| 44 |
-
For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
|
| 45 |
-
"""
|
| 46 |
-
chatbot = gr.ChatInterface(
|
| 47 |
-
respond,
|
| 48 |
-
additional_inputs=[
|
| 49 |
-
gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
|
| 50 |
-
gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
|
| 51 |
-
gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
|
| 52 |
-
gr.Slider(
|
| 53 |
-
minimum=0.1,
|
| 54 |
-
maximum=1.0,
|
| 55 |
-
value=0.95,
|
| 56 |
-
step=0.05,
|
| 57 |
-
label="Top-p (nucleus sampling)",
|
| 58 |
-
),
|
| 59 |
-
],
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
with gr.Blocks() as demo:
|
| 63 |
-
with gr.Sidebar():
|
| 64 |
-
gr.LoginButton()
|
| 65 |
-
chatbot.render()
|
| 66 |
|
| 67 |
|
| 68 |
if __name__ == "__main__":
|
| 69 |
-
demo.launch()
|
|
|
|
| 1 |
+
"""HuggingFace Spaces entrypoint for the Gradio app."""
|
|
|
|
| 2 |
|
| 3 |
+
from weatherpred.dashboard import demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
if __name__ == "__main__":
|
| 7 |
+
demo.launch(ssr_mode=False)
|
models/mock_model/model.onnx
ADDED
|
Binary file (567 Bytes). View file
|
|
|
models/mock_model/model.onnx.data
ADDED
|
File without changes
|
requirements-train.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
torch
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
google-genai
|
| 3 |
+
numpy
|
| 4 |
+
onnxruntime
|
| 5 |
+
pandas
|
| 6 |
+
plotly
|
| 7 |
+
requests
|
scripts/fetch_historical_weather.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Fetch and cache 30 years of Tokyo daily weather data."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 10 |
+
|
| 11 |
+
from weatherpred.config import HISTORY_CSV_PATH, HISTORY_JSON_PATH
|
| 12 |
+
from weatherpred.data import fetch_historical_weather, save_history
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
history = fetch_historical_weather()
|
| 17 |
+
save_history(history)
|
| 18 |
+
print(f"Wrote {len(history):,} rows to {HISTORY_CSV_PATH}")
|
| 19 |
+
print(f"Wrote JSON copy to {HISTORY_JSON_PATH}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
main()
|
scripts/smoke_test_gradio_app.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Offline smoke tests for the Gradio weather app core."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from weatherpred.config import FORECAST_DAYS
|
| 14 |
+
from weatherpred.modeling import seasonal_reference
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main() -> None:
|
| 18 |
+
dates = pd.date_range("2020-01-01", periods=400, freq="D")
|
| 19 |
+
history = pd.DataFrame(
|
| 20 |
+
{
|
| 21 |
+
"date": dates,
|
| 22 |
+
"high_c": [20.0 + (index % 10) for index in range(len(dates))],
|
| 23 |
+
"low_c": [12.0 + (index % 7) for index in range(len(dates))],
|
| 24 |
+
"rain_mm": [float(index % 3) for index in range(len(dates))],
|
| 25 |
+
"wind_kmh": [10.0 + (index % 5) for index in range(len(dates))],
|
| 26 |
+
"confidence": 1.0,
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
forecast_dates = list(pd.date_range("2021-02-04", periods=FORECAST_DAYS, freq="D"))
|
| 30 |
+
reference = seasonal_reference(history, forecast_dates)
|
| 31 |
+
|
| 32 |
+
assert len(reference) == FORECAST_DAYS
|
| 33 |
+
assert {"seasonal_low_gap", "rain_mm", "wind_kmh", "high_std"}.issubset(reference.columns)
|
| 34 |
+
assert reference["rain_mm"].notna().all()
|
| 35 |
+
print("Gradio app core smoke test passed.")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
scripts/train_export_onnx.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train a tiny demo weather model and export it to ONNX.
|
| 3 |
+
|
| 4 |
+
This is intentionally simple: it learns from daily max temperature only and
|
| 5 |
+
predicts the next five max-temperature values from the previous seven days.
|
| 6 |
+
Use it as a scaffold for replacing the mock model with a stronger architecture.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.optim as optim
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
LOOKBACK_DAYS = 7
|
| 24 |
+
FORECAST_DAYS = 5
|
| 25 |
+
HISTORY_CSV_PATH = Path("data/tokyo_weather_history_30y.csv")
|
| 26 |
+
ONNX_PATH = Path("models/mock_model/model.onnx")
|
| 27 |
+
PT_PATH = Path("models/mock_model/model.pt")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class MovingAverageForecastModel(nn.Module):
|
| 31 |
+
def __init__(self, lookback: int, forecast: int) -> None:
|
| 32 |
+
super().__init__()
|
| 33 |
+
self.fc = nn.Linear(lookback, forecast)
|
| 34 |
+
|
| 35 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 36 |
+
return self.fc(x)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_sequences(values: np.ndarray) -> tuple[torch.Tensor, torch.Tensor]:
|
| 40 |
+
x_values = []
|
| 41 |
+
y_values = []
|
| 42 |
+
for index in range(len(values) - LOOKBACK_DAYS - FORECAST_DAYS + 1):
|
| 43 |
+
x_values.append(values[index : index + LOOKBACK_DAYS])
|
| 44 |
+
y_values.append(values[index + LOOKBACK_DAYS : index + LOOKBACK_DAYS + FORECAST_DAYS])
|
| 45 |
+
return (
|
| 46 |
+
torch.tensor(np.array(x_values), dtype=torch.float32),
|
| 47 |
+
torch.tensor(np.array(y_values), dtype=torch.float32),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_training_values() -> np.ndarray:
|
| 52 |
+
if not HISTORY_CSV_PATH.exists():
|
| 53 |
+
raise FileNotFoundError(
|
| 54 |
+
f"{HISTORY_CSV_PATH} does not exist. Run scripts/fetch_historical_weather.py first."
|
| 55 |
+
)
|
| 56 |
+
history = pd.read_csv(HISTORY_CSV_PATH)
|
| 57 |
+
return history["high_c"].dropna().to_numpy(dtype=np.float32)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> None:
|
| 61 |
+
values = load_training_values()
|
| 62 |
+
x_train, y_train = create_sequences(values)
|
| 63 |
+
|
| 64 |
+
model = MovingAverageForecastModel(LOOKBACK_DAYS, FORECAST_DAYS)
|
| 65 |
+
optimizer = optim.Adam(model.parameters(), lr=0.01)
|
| 66 |
+
criterion = nn.MSELoss()
|
| 67 |
+
|
| 68 |
+
with torch.no_grad():
|
| 69 |
+
model.fc.weight.fill_(1.0 / LOOKBACK_DAYS)
|
| 70 |
+
model.fc.bias.fill_(0.0)
|
| 71 |
+
|
| 72 |
+
model.train()
|
| 73 |
+
for epoch in range(120):
|
| 74 |
+
optimizer.zero_grad()
|
| 75 |
+
predictions = model(x_train)
|
| 76 |
+
loss = criterion(predictions, y_train)
|
| 77 |
+
loss.backward()
|
| 78 |
+
optimizer.step()
|
| 79 |
+
|
| 80 |
+
ONNX_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
torch.save(model.state_dict(), PT_PATH)
|
| 82 |
+
dummy_input = torch.zeros(1, LOOKBACK_DAYS, dtype=torch.float32)
|
| 83 |
+
torch.onnx.export(
|
| 84 |
+
model,
|
| 85 |
+
dummy_input,
|
| 86 |
+
ONNX_PATH,
|
| 87 |
+
input_names=["input"],
|
| 88 |
+
output_names=["forecast"],
|
| 89 |
+
dynamic_axes={"input": {0: "batch"}, "forecast": {0: "batch"}},
|
| 90 |
+
opset_version=17,
|
| 91 |
+
)
|
| 92 |
+
print(f"Saved PyTorch weights to {PT_PATH}")
|
| 93 |
+
print(f"Exported ONNX model to {ONNX_PATH}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
main()
|
weatherpred/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tokyo weather forecast demo package."""
|
weatherpred/agent.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-backed agent and deterministic weather briefing helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
from weatherpred.prompts import AGENT_RESPONSE_PROMPT, AGENT_SYSTEM_PROMPT
|
| 14 |
+
from weatherpred.tools import clamp_horizon, normalize_variable, run_tool
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class AgentRun:
|
| 19 |
+
response: str
|
| 20 |
+
action: dict[str, Any]
|
| 21 |
+
tool_result: dict[str, Any]
|
| 22 |
+
comparison: pd.DataFrame | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def has_gemini_key() -> bool:
|
| 26 |
+
return bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def make_rule_based_brief(comparison: pd.DataFrame, summary: dict[str, Any]) -> str:
|
| 30 |
+
warmest = comparison.sort_values("model_high_c", ascending=False).iloc[0]
|
| 31 |
+
rainiest = comparison.sort_values("api_rain_mm", ascending=False).iloc[0]
|
| 32 |
+
confidence = round(summary["avg_model_confidence"] * 100)
|
| 33 |
+
return (
|
| 34 |
+
f"The ONNX model expects the warmest day around {warmest['date']} "
|
| 35 |
+
f"at {warmest['model_high_c']:.1f}C, while Open-Meteo peaks at "
|
| 36 |
+
f"{summary['max_api_high_c']:.1f}C. API rainfall is highest on "
|
| 37 |
+
f"{rainiest['date']} at {rainiest['api_rain_mm']:.1f} mm. "
|
| 38 |
+
f"The model confidence proxy averages {confidence}%, based on historical "
|
| 39 |
+
"seasonal variability rather than a calibrated meteorological probability."
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def make_gemini_brief(comparison: pd.DataFrame, summary: dict[str, Any]) -> str:
|
| 44 |
+
if not has_gemini_key():
|
| 45 |
+
return make_rule_based_brief(comparison, summary)
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
from google import genai
|
| 49 |
+
except ImportError:
|
| 50 |
+
return make_rule_based_brief(comparison, summary)
|
| 51 |
+
|
| 52 |
+
prompt = (
|
| 53 |
+
"Write a concise weather analyst briefing for Tokyo. Compare an ONNX "
|
| 54 |
+
"model forecast against Open-Meteo API forecast. Mention uncertainty and "
|
| 55 |
+
"avoid claiming this model is production-grade.\n\n"
|
| 56 |
+
f"Summary: {json.dumps(summary)}\n"
|
| 57 |
+
f"Forecast rows: {comparison.to_json(orient='records')}"
|
| 58 |
+
)
|
| 59 |
+
try:
|
| 60 |
+
client = genai.Client()
|
| 61 |
+
response = client.models.generate_content(
|
| 62 |
+
model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
|
| 63 |
+
contents=prompt,
|
| 64 |
+
)
|
| 65 |
+
return response.text or make_rule_based_brief(comparison, summary)
|
| 66 |
+
except Exception:
|
| 67 |
+
return make_rule_based_brief(comparison, summary)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def extract_json_object(text: str) -> dict[str, Any] | None:
|
| 71 |
+
try:
|
| 72 |
+
return json.loads(text)
|
| 73 |
+
except json.JSONDecodeError:
|
| 74 |
+
pass
|
| 75 |
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
| 76 |
+
if not match:
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
return json.loads(match.group(0))
|
| 80 |
+
except json.JSONDecodeError:
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def fallback_action(user_message: str) -> dict[str, Any]:
|
| 85 |
+
text = user_message.lower()
|
| 86 |
+
horizon_match = re.search(r"(?:next|for)\s+(\d+)\s*(?:day|days)?", text)
|
| 87 |
+
horizon = clamp_horizon(horizon_match.group(1) if horizon_match else None)
|
| 88 |
+
|
| 89 |
+
if any(word in text for word in ["rain", "precip", "precipitation", "shower"]):
|
| 90 |
+
variable = "rain"
|
| 91 |
+
elif "wind" in text:
|
| 92 |
+
variable = "wind"
|
| 93 |
+
elif any(word in text for word in ["temperature", "temp", "hot", "cold", "high", "low"]):
|
| 94 |
+
variable = "temperature"
|
| 95 |
+
else:
|
| 96 |
+
variable = "all"
|
| 97 |
+
|
| 98 |
+
if any(word in text for word in ["season", "seasonal", "cycle", "cyclic", "monthly", "summer", "winter"]):
|
| 99 |
+
tool = "analyze_seasonality"
|
| 100 |
+
elif any(word in text for word in ["trend", "past", "historical", "history", "30 years", "long-term"]):
|
| 101 |
+
tool = "analyze_historical_trend"
|
| 102 |
+
elif any(word in text for word in ["model", "onnx", "confidence", "method", "how", "source", "data"]):
|
| 103 |
+
tool = "explain_model"
|
| 104 |
+
elif any(word in text for word in ["compare", "difference", "versus", "vs"]):
|
| 105 |
+
tool = "compare_model_vs_open_meteo"
|
| 106 |
+
elif horizon_match or any(word in text for word in ["predict", "forecast", "next"]):
|
| 107 |
+
tool = "forecast_next_days"
|
| 108 |
+
else:
|
| 109 |
+
tool = "show_dashboard_view"
|
| 110 |
+
|
| 111 |
+
return {"tool": tool, "variable": variable, "horizon_days": horizon, "reason": "fallback parser"}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def infer_action(user_message: str) -> dict[str, Any]:
|
| 115 |
+
if not has_gemini_key():
|
| 116 |
+
return fallback_action(user_message)
|
| 117 |
+
try:
|
| 118 |
+
from google import genai
|
| 119 |
+
|
| 120 |
+
client = genai.Client()
|
| 121 |
+
prompt = f"{AGENT_SYSTEM_PROMPT}\n\nUser request: {user_message}"
|
| 122 |
+
response = client.models.generate_content(
|
| 123 |
+
model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
|
| 124 |
+
contents=prompt,
|
| 125 |
+
)
|
| 126 |
+
action = extract_json_object(response.text or "")
|
| 127 |
+
if action and "tool" in action:
|
| 128 |
+
action["variable"] = normalize_variable(action.get("variable"))
|
| 129 |
+
action["horizon_days"] = clamp_horizon(action.get("horizon_days"))
|
| 130 |
+
return action
|
| 131 |
+
except Exception:
|
| 132 |
+
pass
|
| 133 |
+
return fallback_action(user_message)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def deterministic_agent_response(action: dict[str, Any], tool_result: dict[str, Any]) -> str:
|
| 137 |
+
tool = tool_result.get("tool", action.get("tool"))
|
| 138 |
+
summary = tool_result.get("summary", {})
|
| 139 |
+
variable = tool_result.get("variable", action.get("variable", "all"))
|
| 140 |
+
|
| 141 |
+
if tool == "forecast_next_days":
|
| 142 |
+
return (
|
| 143 |
+
f"Showing the next {tool_result['horizon_days']} days for {variable}. "
|
| 144 |
+
"Open-Meteo is shown beside the local model. Note: ONNX directly predicts five-day max temperature; "
|
| 145 |
+
"longer horizons, rain, wind, and low temperature use historical seasonal statistics."
|
| 146 |
+
)
|
| 147 |
+
if tool == "compare_model_vs_open_meteo":
|
| 148 |
+
return f"Model vs Open-Meteo comparison for {variable}: {json.dumps(summary.get('average_absolute_differences', {}))}."
|
| 149 |
+
if tool == "analyze_historical_trend":
|
| 150 |
+
return (
|
| 151 |
+
f"Historical {variable} trend from {summary['history_start']} to {summary['history_end']}: "
|
| 152 |
+
f"the recent five-year mean is {summary['last_5_year_mean']} {summary['unit']}, "
|
| 153 |
+
f"versus {summary['first_5_year_mean']} {summary['unit']} in the first five years. "
|
| 154 |
+
f"Change: {summary['change_last_vs_first']} {summary['unit']}."
|
| 155 |
+
)
|
| 156 |
+
if tool == "analyze_seasonality":
|
| 157 |
+
return (
|
| 158 |
+
f"Seasonality for {variable}: peak month is {summary['peak_month']} "
|
| 159 |
+
f"at {summary['peak_value']} {summary['unit']}; lowest month is {summary['low_month']} "
|
| 160 |
+
f"at {summary['low_value']} {summary['unit']}. Seasonal amplitude is "
|
| 161 |
+
f"{summary['seasonal_amplitude']} {summary['unit']}."
|
| 162 |
+
)
|
| 163 |
+
if tool == "explain_model":
|
| 164 |
+
return " ".join(str(value) for value in summary.values())
|
| 165 |
+
if tool == "show_dashboard_view":
|
| 166 |
+
return f"{variable.title()} is available in {summary['view']}."
|
| 167 |
+
return "I handled the request with the weather dashboard tools."
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def summarize_agent_response(user_message: str, action: dict[str, Any], tool_result: dict[str, Any]) -> str:
|
| 171 |
+
if not has_gemini_key():
|
| 172 |
+
return deterministic_agent_response(action, tool_result)
|
| 173 |
+
try:
|
| 174 |
+
from google import genai
|
| 175 |
+
|
| 176 |
+
safe_result = {key: value for key, value in tool_result.items() if key != "comparison"}
|
| 177 |
+
prompt = AGENT_RESPONSE_PROMPT.format(
|
| 178 |
+
user_message=user_message,
|
| 179 |
+
tool_result=json.dumps(safe_result, default=str),
|
| 180 |
+
)
|
| 181 |
+
client = genai.Client()
|
| 182 |
+
response = client.models.generate_content(
|
| 183 |
+
model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
|
| 184 |
+
contents=prompt,
|
| 185 |
+
)
|
| 186 |
+
return response.text or deterministic_agent_response(action, tool_result)
|
| 187 |
+
except Exception:
|
| 188 |
+
return deterministic_agent_response(action, tool_result)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def run_weather_agent(user_message: str) -> AgentRun:
|
| 192 |
+
action = infer_action(user_message)
|
| 193 |
+
tool_result = run_tool(action)
|
| 194 |
+
response = summarize_agent_response(user_message, action, tool_result)
|
| 195 |
+
comparison = tool_result.get("comparison")
|
| 196 |
+
if not isinstance(comparison, pd.DataFrame):
|
| 197 |
+
comparison = None
|
| 198 |
+
return AgentRun(
|
| 199 |
+
response=response,
|
| 200 |
+
action=action,
|
| 201 |
+
tool_result=tool_result,
|
| 202 |
+
comparison=comparison,
|
| 203 |
+
)
|
weatherpred/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Project configuration constants."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 8 |
+
DATA_DIR = ROOT_DIR / "data"
|
| 9 |
+
MODELS_DIR = ROOT_DIR / "models"
|
| 10 |
+
MODEL_PATH = MODELS_DIR / "mock_model" / "model.onnx"
|
| 11 |
+
|
| 12 |
+
TOKYO_LATITUDE = 35.6762
|
| 13 |
+
TOKYO_LONGITUDE = 139.6503
|
| 14 |
+
TOKYO_TIMEZONE = "Asia/Tokyo"
|
| 15 |
+
FORECAST_DAYS = 5
|
| 16 |
+
LOOKBACK_DAYS = 7
|
| 17 |
+
HISTORY_YEARS = 30
|
| 18 |
+
|
| 19 |
+
HISTORY_CSV_PATH = DATA_DIR / "tokyo_weather_history_30y.csv"
|
| 20 |
+
HISTORY_JSON_PATH = DATA_DIR / "tokyo_weather_history_30y.json"
|
| 21 |
+
LATEST_JSON_PATH = DATA_DIR / "tokyo_weather_latest.json"
|
| 22 |
+
|
| 23 |
+
DAILY_COLUMNS = [
|
| 24 |
+
"temperature_2m_max",
|
| 25 |
+
"temperature_2m_min",
|
| 26 |
+
"precipitation_sum",
|
| 27 |
+
"wind_speed_10m_max",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
API_DISPLAY_COLUMNS = {
|
| 31 |
+
"high_c": "api_high_c",
|
| 32 |
+
"low_c": "api_low_c",
|
| 33 |
+
"rain_mm": "api_rain_mm",
|
| 34 |
+
"wind_kmh": "api_wind_kmh",
|
| 35 |
+
}
|
weatherpred/dashboard.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio dashboard assembly."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
|
| 9 |
+
from weatherpred.agent import make_gemini_brief, run_weather_agent
|
| 10 |
+
from weatherpred.modeling import build_weather_bundle
|
| 11 |
+
from weatherpred.zerogpu import spaces
|
| 12 |
+
|
| 13 |
+
def make_line_plot(
|
| 14 |
+
comparison: pd.DataFrame,
|
| 15 |
+
title: str,
|
| 16 |
+
yaxis_title: str,
|
| 17 |
+
series: list[tuple[str, str, str]],
|
| 18 |
+
) -> go.Figure:
|
| 19 |
+
fig = go.Figure()
|
| 20 |
+
for column, name, color in series:
|
| 21 |
+
fig.add_trace(
|
| 22 |
+
go.Scatter(
|
| 23 |
+
x=comparison["date"],
|
| 24 |
+
y=comparison[column],
|
| 25 |
+
mode="lines+markers",
|
| 26 |
+
name=name,
|
| 27 |
+
line={"color": color, "width": 3},
|
| 28 |
+
)
|
| 29 |
+
)
|
| 30 |
+
fig.update_layout(
|
| 31 |
+
title=title,
|
| 32 |
+
xaxis_title="Date",
|
| 33 |
+
yaxis_title=yaxis_title,
|
| 34 |
+
hovermode="x unified",
|
| 35 |
+
template="plotly_white",
|
| 36 |
+
legend={"orientation": "h", "y": -0.25},
|
| 37 |
+
margin={"l": 40, "r": 20, "t": 60, "b": 70},
|
| 38 |
+
)
|
| 39 |
+
return fig
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def make_temperature_plot(comparison: pd.DataFrame) -> go.Figure:
|
| 43 |
+
return make_line_plot(
|
| 44 |
+
comparison,
|
| 45 |
+
"Five-Day Temperature Forecast",
|
| 46 |
+
"Temperature (C)",
|
| 47 |
+
[
|
| 48 |
+
("api_high_c", "Open-Meteo high", "#0f8b8d"),
|
| 49 |
+
("model_high_c", "ONNX model high", "#e05d3d"),
|
| 50 |
+
("api_low_c", "Open-Meteo low", "#74b6b7"),
|
| 51 |
+
("model_low_c", "ONNX model low", "#f0a08c"),
|
| 52 |
+
],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def make_rain_plot(comparison: pd.DataFrame) -> go.Figure:
|
| 57 |
+
return make_line_plot(
|
| 58 |
+
comparison,
|
| 59 |
+
"Five-Day Rain Forecast",
|
| 60 |
+
"Rain (mm)",
|
| 61 |
+
[
|
| 62 |
+
("api_rain_mm", "Open-Meteo rain", "#0f8b8d"),
|
| 63 |
+
("model_rain_mm", "Model rain proxy", "#e05d3d"),
|
| 64 |
+
],
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def make_wind_plot(comparison: pd.DataFrame) -> go.Figure:
|
| 69 |
+
return make_line_plot(
|
| 70 |
+
comparison,
|
| 71 |
+
"Five-Day Wind Forecast",
|
| 72 |
+
"Wind (km/h)",
|
| 73 |
+
[
|
| 74 |
+
("api_wind_kmh", "Open-Meteo wind", "#0f8b8d"),
|
| 75 |
+
("model_wind_kmh", "Historical model wind", "#e05d3d"),
|
| 76 |
+
],
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@spaces.GPU(duration=10)
|
| 81 |
+
def zero_gpu_probe() -> str:
|
| 82 |
+
return "ZeroGPU compatibility probe is available."
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def make_metrics(summary: dict) -> str:
|
| 86 |
+
return (
|
| 87 |
+
f"Historical rows: {summary['history_rows']:,}\n"
|
| 88 |
+
f"History window: {summary['history_start']} to {summary['history_end']}\n"
|
| 89 |
+
f"Average model confidence proxy: {summary['avg_model_confidence']:.0%}\n"
|
| 90 |
+
f"Open-Meteo max high: {summary['max_api_high_c']:.1f}C\n"
|
| 91 |
+
f"ONNX model max high: {summary['max_model_high_c']:.1f}C"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def render_all_charts(comparison: pd.DataFrame) -> tuple:
|
| 96 |
+
return (
|
| 97 |
+
gr.update(value=make_temperature_plot(comparison), visible=True),
|
| 98 |
+
gr.update(value=make_rain_plot(comparison), visible=True),
|
| 99 |
+
gr.update(value=make_wind_plot(comparison), visible=True),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def refresh_dashboard(force_refresh_history: bool) -> tuple:
|
| 104 |
+
bundle = build_weather_bundle(force_refresh_history=force_refresh_history)
|
| 105 |
+
comparison = bundle.comparison.copy()
|
| 106 |
+
summary = bundle.summary
|
| 107 |
+
brief = make_gemini_brief(comparison, summary)
|
| 108 |
+
return (
|
| 109 |
+
comparison,
|
| 110 |
+
"all",
|
| 111 |
+
make_temperature_plot(comparison),
|
| 112 |
+
make_rain_plot(comparison),
|
| 113 |
+
make_wind_plot(comparison),
|
| 114 |
+
make_metrics(summary),
|
| 115 |
+
brief,
|
| 116 |
+
[{"role": "assistant", "content": "Dashboard refreshed. Showing temperature, rain, and wind forecasts."}],
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def parse_chart_selection(message: str) -> tuple[str, str]:
|
| 121 |
+
text = message.lower()
|
| 122 |
+
wants_temperature = any(word in text for word in ["temperature", "temp", "hot", "cold", "high", "low"])
|
| 123 |
+
wants_rain = any(word in text for word in ["rain", "precipitation", "precip", "shower"])
|
| 124 |
+
wants_wind = "wind" in text
|
| 125 |
+
wants_all = any(word in text for word in ["all", "everything", "reset", "both"])
|
| 126 |
+
|
| 127 |
+
if wants_all or sum([wants_temperature, wants_rain, wants_wind]) > 1:
|
| 128 |
+
return "all", "Showing all three charts in the fixed 2x2 layout."
|
| 129 |
+
if wants_temperature:
|
| 130 |
+
return "temperature", "Temperature is shown in the top-left chart."
|
| 131 |
+
if wants_rain:
|
| 132 |
+
return "rain", "Rain is shown in the top-right chart."
|
| 133 |
+
if wants_wind:
|
| 134 |
+
return "wind", "Wind is shown in the bottom-left chart, including the historical model wind prediction."
|
| 135 |
+
return "all", "I can point you to temperature, rain, wind, or all charts in the 2x2 dashboard."
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def chat_with_dashboard(message: str, history: list, comparison: pd.DataFrame) -> tuple:
|
| 139 |
+
if comparison is None or getattr(comparison, "empty", True):
|
| 140 |
+
comparison = build_weather_bundle(force_refresh_history=False).comparison.copy()
|
| 141 |
+
|
| 142 |
+
if not message.strip():
|
| 143 |
+
return (history or [], "all", comparison, *render_all_charts(comparison), "")
|
| 144 |
+
|
| 145 |
+
agent_run = run_weather_agent(message)
|
| 146 |
+
next_comparison = agent_run.comparison if agent_run.comparison is not None else comparison
|
| 147 |
+
charts = render_all_charts(next_comparison)
|
| 148 |
+
history = history or []
|
| 149 |
+
history.append({"role": "user", "content": message})
|
| 150 |
+
history.append({"role": "assistant", "content": agent_run.response})
|
| 151 |
+
selection = agent_run.action.get("variable", "all")
|
| 152 |
+
return (history, selection, next_comparison, *charts, agent_run.response)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
with gr.Blocks(title="Tokyo Weather Forecast Lab") as demo:
|
| 156 |
+
gr.Markdown(
|
| 157 |
+
"""
|
| 158 |
+
# Tokyo Weather Forecast Lab
|
| 159 |
+
|
| 160 |
+
Compare Open-Meteo's five-day forecast with a local ONNX model using
|
| 161 |
+
cached 30-year Tokyo weather history. Use the chat panel to show
|
| 162 |
+
temperature, rain, wind, or all forecast charts in the fixed 2x2 layout.
|
| 163 |
+
"""
|
| 164 |
+
)
|
| 165 |
+
forecast_state = gr.State(pd.DataFrame())
|
| 166 |
+
chart_selection = gr.State("all")
|
| 167 |
+
|
| 168 |
+
with gr.Row():
|
| 169 |
+
refresh = gr.Button("Refresh forecast", variant="primary")
|
| 170 |
+
force_history = gr.Checkbox(
|
| 171 |
+
label="Refresh 30-year historical cache",
|
| 172 |
+
value=False,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
with gr.Row(visible=False):
|
| 176 |
+
gpu_probe_button = gr.Button("ZeroGPU probe")
|
| 177 |
+
gpu_probe_output = gr.Textbox(label="ZeroGPU probe")
|
| 178 |
+
|
| 179 |
+
with gr.Row():
|
| 180 |
+
with gr.Column(scale=1):
|
| 181 |
+
temperature_plot = gr.Plot(label="Temperature")
|
| 182 |
+
with gr.Column(scale=1):
|
| 183 |
+
rain_plot = gr.Plot(label="Rain")
|
| 184 |
+
|
| 185 |
+
with gr.Row():
|
| 186 |
+
with gr.Column(scale=1):
|
| 187 |
+
wind_plot = gr.Plot(label="Wind")
|
| 188 |
+
with gr.Column(scale=1):
|
| 189 |
+
gr.Markdown("## Forecast Chat")
|
| 190 |
+
chatbot = gr.Chatbot(label="Dashboard assistant", height=360)
|
| 191 |
+
chat_input = gr.Textbox(
|
| 192 |
+
label="Ask the dashboard",
|
| 193 |
+
placeholder="Try: show rain, show wind, show temperature, show all charts",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
with gr.Row():
|
| 197 |
+
metrics_box = gr.Textbox(label="Data and model metrics", lines=6)
|
| 198 |
+
brief_box = gr.Textbox(label="AI analyst brief", lines=6)
|
| 199 |
+
|
| 200 |
+
gpu_probe_button.click(
|
| 201 |
+
zero_gpu_probe,
|
| 202 |
+
inputs=None,
|
| 203 |
+
outputs=gpu_probe_output,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
refresh.click(
|
| 207 |
+
refresh_dashboard,
|
| 208 |
+
inputs=[force_history],
|
| 209 |
+
outputs=[
|
| 210 |
+
forecast_state,
|
| 211 |
+
chart_selection,
|
| 212 |
+
temperature_plot,
|
| 213 |
+
rain_plot,
|
| 214 |
+
wind_plot,
|
| 215 |
+
metrics_box,
|
| 216 |
+
brief_box,
|
| 217 |
+
chatbot,
|
| 218 |
+
],
|
| 219 |
+
)
|
| 220 |
+
demo.load(
|
| 221 |
+
refresh_dashboard,
|
| 222 |
+
inputs=[force_history],
|
| 223 |
+
outputs=[
|
| 224 |
+
forecast_state,
|
| 225 |
+
chart_selection,
|
| 226 |
+
temperature_plot,
|
| 227 |
+
rain_plot,
|
| 228 |
+
wind_plot,
|
| 229 |
+
metrics_box,
|
| 230 |
+
brief_box,
|
| 231 |
+
chatbot,
|
| 232 |
+
],
|
| 233 |
+
)
|
| 234 |
+
chat_input.submit(
|
| 235 |
+
chat_with_dashboard,
|
| 236 |
+
inputs=[chat_input, chatbot, forecast_state],
|
| 237 |
+
outputs=[
|
| 238 |
+
chatbot,
|
| 239 |
+
chart_selection,
|
| 240 |
+
forecast_state,
|
| 241 |
+
temperature_plot,
|
| 242 |
+
rain_plot,
|
| 243 |
+
wind_plot,
|
| 244 |
+
brief_box,
|
| 245 |
+
],
|
| 246 |
+
).then(lambda: "", outputs=chat_input)
|
weatherpred/data.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Open-Meteo data fetching and local CSV/JSON cache helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from datetime import date, datetime, timedelta, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from weatherpred.config import (
|
| 14 |
+
DAILY_COLUMNS,
|
| 15 |
+
DATA_DIR,
|
| 16 |
+
FORECAST_DAYS,
|
| 17 |
+
HISTORY_CSV_PATH,
|
| 18 |
+
HISTORY_JSON_PATH,
|
| 19 |
+
HISTORY_YEARS,
|
| 20 |
+
LATEST_JSON_PATH,
|
| 21 |
+
TOKYO_LATITUDE,
|
| 22 |
+
TOKYO_LONGITUDE,
|
| 23 |
+
TOKYO_TIMEZONE,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def fetch_json(url: str, params: dict[str, str | float | int]) -> dict[str, Any]:
|
| 28 |
+
response = requests.get(url, params=params, timeout=40)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
return response.json()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def normalize_daily_weather(raw: dict[str, Any], observed: bool) -> pd.DataFrame:
|
| 34 |
+
daily = raw["daily"]
|
| 35 |
+
frame = pd.DataFrame(
|
| 36 |
+
{
|
| 37 |
+
"date": pd.to_datetime(daily["time"]),
|
| 38 |
+
"high_c": daily["temperature_2m_max"],
|
| 39 |
+
"low_c": daily["temperature_2m_min"],
|
| 40 |
+
"rain_mm": daily["precipitation_sum"],
|
| 41 |
+
"wind_kmh": daily["wind_speed_10m_max"],
|
| 42 |
+
}
|
| 43 |
+
)
|
| 44 |
+
frame["confidence"] = 1.0 if observed else np.nan
|
| 45 |
+
return frame.dropna(subset=["high_c", "low_c", "rain_mm", "wind_kmh"])
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def fetch_historical_weather(
|
| 49 |
+
years: int = HISTORY_YEARS,
|
| 50 |
+
latitude: float = TOKYO_LATITUDE,
|
| 51 |
+
longitude: float = TOKYO_LONGITUDE,
|
| 52 |
+
) -> pd.DataFrame:
|
| 53 |
+
end = date.today() - timedelta(days=1)
|
| 54 |
+
start = end.replace(year=end.year - years)
|
| 55 |
+
params = {
|
| 56 |
+
"latitude": latitude,
|
| 57 |
+
"longitude": longitude,
|
| 58 |
+
"timezone": TOKYO_TIMEZONE,
|
| 59 |
+
"start_date": start.isoformat(),
|
| 60 |
+
"end_date": end.isoformat(),
|
| 61 |
+
"daily": ",".join(DAILY_COLUMNS),
|
| 62 |
+
}
|
| 63 |
+
raw = fetch_json("https://archive-api.open-meteo.com/v1/archive", params)
|
| 64 |
+
return normalize_daily_weather(raw, observed=True)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def fetch_api_forecast(
|
| 68 |
+
latitude: float = TOKYO_LATITUDE,
|
| 69 |
+
longitude: float = TOKYO_LONGITUDE,
|
| 70 |
+
days: int = FORECAST_DAYS,
|
| 71 |
+
) -> pd.DataFrame:
|
| 72 |
+
params = {
|
| 73 |
+
"latitude": latitude,
|
| 74 |
+
"longitude": longitude,
|
| 75 |
+
"timezone": TOKYO_TIMEZONE,
|
| 76 |
+
"forecast_days": days,
|
| 77 |
+
"current": "temperature_2m,precipitation,wind_speed_10m",
|
| 78 |
+
"daily": ",".join(DAILY_COLUMNS),
|
| 79 |
+
}
|
| 80 |
+
raw = fetch_json("https://api.open-meteo.com/v1/forecast", params)
|
| 81 |
+
latest = {
|
| 82 |
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
| 83 |
+
"current": raw.get("current", {}),
|
| 84 |
+
"daily": raw.get("daily", {}),
|
| 85 |
+
}
|
| 86 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
LATEST_JSON_PATH.write_text(json.dumps(latest, indent=2) + "\n", encoding="utf-8")
|
| 88 |
+
return normalize_daily_weather(raw, observed=False)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def save_history(history: pd.DataFrame) -> None:
|
| 92 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
history.to_csv(HISTORY_CSV_PATH, index=False)
|
| 94 |
+
HISTORY_JSON_PATH.write_text(
|
| 95 |
+
history.assign(date=history["date"].dt.strftime("%Y-%m-%d")).to_json(
|
| 96 |
+
orient="records", indent=2
|
| 97 |
+
)
|
| 98 |
+
+ "\n",
|
| 99 |
+
encoding="utf-8",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def load_or_fetch_history(force_refresh: bool = False) -> pd.DataFrame:
|
| 104 |
+
if HISTORY_CSV_PATH.exists() and not force_refresh:
|
| 105 |
+
return pd.read_csv(HISTORY_CSV_PATH, parse_dates=["date"])
|
| 106 |
+
history = fetch_historical_weather()
|
| 107 |
+
save_history(history)
|
| 108 |
+
return history
|
weatherpred/modeling.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ONNX weather inference and forecast comparison logic."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from weatherpred.config import (
|
| 13 |
+
API_DISPLAY_COLUMNS,
|
| 14 |
+
FORECAST_DAYS,
|
| 15 |
+
LOOKBACK_DAYS,
|
| 16 |
+
MODEL_PATH,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class WeatherBundle:
|
| 22 |
+
history: pd.DataFrame
|
| 23 |
+
api_forecast: pd.DataFrame
|
| 24 |
+
model_forecast: pd.DataFrame
|
| 25 |
+
comparison: pd.DataFrame
|
| 26 |
+
summary: dict[str, Any]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_onnx_temperature_model(history: pd.DataFrame) -> np.ndarray:
|
| 30 |
+
recent_highs = (
|
| 31 |
+
history.sort_values("date")["high_c"].tail(LOOKBACK_DAYS).to_numpy(dtype=np.float32)
|
| 32 |
+
)
|
| 33 |
+
if len(recent_highs) < LOOKBACK_DAYS:
|
| 34 |
+
raise ValueError(f"Need at least {LOOKBACK_DAYS} historical days for ONNX inference.")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
import onnxruntime as ort
|
| 38 |
+
except ImportError as exc:
|
| 39 |
+
raise RuntimeError(
|
| 40 |
+
"onnxruntime is required to run models/mock_model/model.onnx. "
|
| 41 |
+
"Install requirements.txt or deploy on HuggingFace Spaces."
|
| 42 |
+
) from exc
|
| 43 |
+
|
| 44 |
+
session = ort.InferenceSession(str(MODEL_PATH), providers=["CPUExecutionProvider"])
|
| 45 |
+
input_name = session.get_inputs()[0].name
|
| 46 |
+
output_name = session.get_outputs()[0].name
|
| 47 |
+
prediction = session.run([output_name], {input_name: recent_highs.reshape(1, -1)})[0]
|
| 48 |
+
return np.asarray(prediction, dtype=float).reshape(-1)[:FORECAST_DAYS]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def seasonal_reference(history: pd.DataFrame, forecast_dates: list[pd.Timestamp]) -> pd.DataFrame:
|
| 52 |
+
frame = history.copy()
|
| 53 |
+
frame["day_of_year"] = frame["date"].dt.dayofyear
|
| 54 |
+
rows = []
|
| 55 |
+
for forecast_date in forecast_dates:
|
| 56 |
+
day_of_year = forecast_date.dayofyear
|
| 57 |
+
window = frame[
|
| 58 |
+
(frame["day_of_year"] >= day_of_year - 14)
|
| 59 |
+
& (frame["day_of_year"] <= day_of_year + 14)
|
| 60 |
+
]
|
| 61 |
+
if window.empty:
|
| 62 |
+
window = frame.tail(365)
|
| 63 |
+
rows.append(
|
| 64 |
+
{
|
| 65 |
+
"date": forecast_date,
|
| 66 |
+
"seasonal_high_c": float(window["high_c"].median()),
|
| 67 |
+
"seasonal_low_gap": float((window["high_c"] - window["low_c"]).median()),
|
| 68 |
+
"rain_mm": float(window["rain_mm"].median()),
|
| 69 |
+
"wind_kmh": float(window["wind_kmh"].median()),
|
| 70 |
+
"high_std": float(window["high_c"].std(ddof=0) or 3.0),
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
return pd.DataFrame(rows)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_model_forecast(history: pd.DataFrame, api_forecast: pd.DataFrame) -> pd.DataFrame:
|
| 77 |
+
forecast_dates = list(api_forecast["date"])
|
| 78 |
+
seasonal = seasonal_reference(history, forecast_dates)
|
| 79 |
+
predicted_highs = run_onnx_temperature_model(history)
|
| 80 |
+
horizon = len(forecast_dates)
|
| 81 |
+
if horizon <= len(predicted_highs):
|
| 82 |
+
model_highs = predicted_highs[:horizon]
|
| 83 |
+
else:
|
| 84 |
+
seasonal_extension = seasonal["seasonal_high_c"].to_numpy(dtype=float)[len(predicted_highs) : horizon]
|
| 85 |
+
model_highs = np.concatenate([predicted_highs, seasonal_extension])
|
| 86 |
+
|
| 87 |
+
result = pd.DataFrame({"date": forecast_dates, "model_high_c": model_highs})
|
| 88 |
+
result = result.merge(seasonal, on="date", how="left")
|
| 89 |
+
result["model_low_c"] = result["model_high_c"] - result["seasonal_low_gap"]
|
| 90 |
+
result["model_rain_mm"] = result["rain_mm"].clip(lower=0)
|
| 91 |
+
result["model_wind_kmh"] = result["wind_kmh"].clip(lower=0)
|
| 92 |
+
result["model_confidence"] = (1 - (result["high_std"] / 12)).clip(lower=0.45, upper=0.88)
|
| 93 |
+
return result[
|
| 94 |
+
[
|
| 95 |
+
"date",
|
| 96 |
+
"model_high_c",
|
| 97 |
+
"model_low_c",
|
| 98 |
+
"model_rain_mm",
|
| 99 |
+
"model_wind_kmh",
|
| 100 |
+
"model_confidence",
|
| 101 |
+
]
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def build_weather_bundle(force_refresh_history: bool = False, horizon_days: int = FORECAST_DAYS) -> WeatherBundle:
|
| 106 |
+
from weatherpred.data import fetch_api_forecast, load_or_fetch_history
|
| 107 |
+
|
| 108 |
+
history = load_or_fetch_history(force_refresh=force_refresh_history)
|
| 109 |
+
api_forecast = fetch_api_forecast(days=horizon_days)
|
| 110 |
+
model_forecast = build_model_forecast(history, api_forecast)
|
| 111 |
+
|
| 112 |
+
comparison = api_forecast.rename(columns=API_DISPLAY_COLUMNS).merge(
|
| 113 |
+
model_forecast, on="date", how="inner"
|
| 114 |
+
)
|
| 115 |
+
comparison["date"] = comparison["date"].dt.strftime("%Y-%m-%d")
|
| 116 |
+
comparison["high_delta_c"] = comparison["model_high_c"] - comparison["api_high_c"]
|
| 117 |
+
comparison["rain_delta_mm"] = comparison["model_rain_mm"] - comparison["api_rain_mm"]
|
| 118 |
+
|
| 119 |
+
summary = {
|
| 120 |
+
"history_rows": int(len(history)),
|
| 121 |
+
"history_start": history["date"].min().strftime("%Y-%m-%d"),
|
| 122 |
+
"history_end": history["date"].max().strftime("%Y-%m-%d"),
|
| 123 |
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
| 124 |
+
"avg_model_confidence": float(model_forecast["model_confidence"].mean()),
|
| 125 |
+
"max_api_high_c": float(api_forecast["high_c"].max()),
|
| 126 |
+
"max_model_high_c": float(model_forecast["model_high_c"].max()),
|
| 127 |
+
}
|
| 128 |
+
return WeatherBundle(history, api_forecast, model_forecast, comparison, summary)
|
weatherpred/prompts.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompts for the weather dashboard agent."""
|
| 2 |
+
|
| 3 |
+
AGENT_SYSTEM_PROMPT = """
|
| 4 |
+
You are the routing layer for a Tokyo weather forecasting dashboard.
|
| 5 |
+
Your job is to convert the user request into exactly one JSON action.
|
| 6 |
+
Return JSON only. Do not include markdown.
|
| 7 |
+
|
| 8 |
+
Allowed variables:
|
| 9 |
+
- temperature
|
| 10 |
+
- rain
|
| 11 |
+
- wind
|
| 12 |
+
- all
|
| 13 |
+
|
| 14 |
+
Allowed tools:
|
| 15 |
+
- forecast_next_days: predict/show the next N days. Args: variable, horizon_days.
|
| 16 |
+
- analyze_historical_trend: summarize long-term trend over historical data. Args: variable.
|
| 17 |
+
- analyze_seasonality: summarize seasonal or cyclic behavior. Args: variable.
|
| 18 |
+
- compare_model_vs_open_meteo: compare model output and Open-Meteo forecast. Args: variable, horizon_days.
|
| 19 |
+
- explain_model: explain data sources, model limits, and forecasting method. Args: none.
|
| 20 |
+
- show_dashboard_view: direct user to an existing dashboard view. Args: variable.
|
| 21 |
+
|
| 22 |
+
Rules:
|
| 23 |
+
- If the user asks for next N days, choose forecast_next_days and extract N.
|
| 24 |
+
- If N is absent for a forecast or comparison, use 5.
|
| 25 |
+
- Clamp horizon_days to 1-16.
|
| 26 |
+
- If the user asks about the past, historical trend, long-term movement, 30 years, or change over time, choose analyze_historical_trend.
|
| 27 |
+
- If the user asks about seasonal, yearly, monthly, cyclic, summer, winter, or periodic behavior, choose analyze_seasonality.
|
| 28 |
+
- If the user asks how the model works, what model is used, confidence, limitations, ONNX, or data source, choose explain_model.
|
| 29 |
+
- If the user asks to show a fixed chart without analytical wording, choose show_dashboard_view.
|
| 30 |
+
|
| 31 |
+
JSON schema:
|
| 32 |
+
{
|
| 33 |
+
"tool": "forecast_next_days | analyze_historical_trend | analyze_seasonality | compare_model_vs_open_meteo | explain_model | show_dashboard_view",
|
| 34 |
+
"variable": "temperature | rain | wind | all",
|
| 35 |
+
"horizon_days": 5,
|
| 36 |
+
"reason": "brief internal reason"
|
| 37 |
+
}
|
| 38 |
+
""".strip()
|
| 39 |
+
|
| 40 |
+
AGENT_RESPONSE_PROMPT = """
|
| 41 |
+
You are a concise weather analyst for a Tokyo forecasting dashboard.
|
| 42 |
+
Use the tool result below to answer the user.
|
| 43 |
+
Be clear about model limits:
|
| 44 |
+
- ONNX model directly predicts only max temperature for the first five days.
|
| 45 |
+
- Min temperature, rain, wind, and horizons beyond five days use historical seasonal statistics.
|
| 46 |
+
- Open-Meteo is the external weather API baseline.
|
| 47 |
+
Avoid investment-like certainty and avoid pretending the model is production-grade.
|
| 48 |
+
|
| 49 |
+
User request:
|
| 50 |
+
{user_message}
|
| 51 |
+
|
| 52 |
+
Tool result JSON:
|
| 53 |
+
{tool_result}
|
| 54 |
+
""".strip()
|
weatherpred/tools.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic tools called by the weather dashboard agent."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from weatherpred.config import FORECAST_DAYS
|
| 11 |
+
from weatherpred.modeling import build_weather_bundle
|
| 12 |
+
|
| 13 |
+
MAX_FORECAST_DAYS = 16
|
| 14 |
+
VARIABLE_COLUMNS = {
|
| 15 |
+
"temperature": {
|
| 16 |
+
"api": ["api_high_c", "api_low_c"],
|
| 17 |
+
"model": ["model_high_c", "model_low_c"],
|
| 18 |
+
"history": "high_c",
|
| 19 |
+
"unit": "C",
|
| 20 |
+
},
|
| 21 |
+
"rain": {
|
| 22 |
+
"api": ["api_rain_mm"],
|
| 23 |
+
"model": ["model_rain_mm"],
|
| 24 |
+
"history": "rain_mm",
|
| 25 |
+
"unit": "mm",
|
| 26 |
+
},
|
| 27 |
+
"wind": {
|
| 28 |
+
"api": ["api_wind_kmh"],
|
| 29 |
+
"model": ["model_wind_kmh"],
|
| 30 |
+
"history": "wind_kmh",
|
| 31 |
+
"unit": "km/h",
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalize_variable(variable: str | None) -> str:
|
| 37 |
+
text = (variable or "all").lower()
|
| 38 |
+
if text in {"temp", "temperature", "hot", "cold"}:
|
| 39 |
+
return "temperature"
|
| 40 |
+
if text in {"rain", "precip", "precipitation", "shower"}:
|
| 41 |
+
return "rain"
|
| 42 |
+
if text in {"wind", "windspeed", "wind_speed"}:
|
| 43 |
+
return "wind"
|
| 44 |
+
return "all"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def clamp_horizon(horizon_days: int | str | None) -> int:
|
| 48 |
+
try:
|
| 49 |
+
value = int(horizon_days or FORECAST_DAYS)
|
| 50 |
+
except (TypeError, ValueError):
|
| 51 |
+
value = FORECAST_DAYS
|
| 52 |
+
return max(1, min(MAX_FORECAST_DAYS, value))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def compact_forecast_rows(comparison: pd.DataFrame, variable: str) -> list[dict[str, Any]]:
|
| 56 |
+
variable = normalize_variable(variable)
|
| 57 |
+
columns = ["date"]
|
| 58 |
+
selected = ["temperature", "rain", "wind"] if variable == "all" else [variable]
|
| 59 |
+
for item in selected:
|
| 60 |
+
columns.extend(VARIABLE_COLUMNS[item]["api"])
|
| 61 |
+
columns.extend(VARIABLE_COLUMNS[item]["model"])
|
| 62 |
+
return comparison[columns].round(2).to_dict(orient="records")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def forecast_next_days(variable: str = "all", horizon_days: int = FORECAST_DAYS) -> dict[str, Any]:
|
| 66 |
+
variable = normalize_variable(variable)
|
| 67 |
+
horizon = clamp_horizon(horizon_days)
|
| 68 |
+
bundle = build_weather_bundle(horizon_days=horizon)
|
| 69 |
+
return {
|
| 70 |
+
"tool": "forecast_next_days",
|
| 71 |
+
"variable": variable,
|
| 72 |
+
"horizon_days": horizon,
|
| 73 |
+
"comparison": bundle.comparison,
|
| 74 |
+
"summary": {
|
| 75 |
+
**bundle.summary,
|
| 76 |
+
"rows": compact_forecast_rows(bundle.comparison, variable),
|
| 77 |
+
"model_note": "ONNX predicts max temperature for the first five days; other outputs use historical seasonal statistics.",
|
| 78 |
+
},
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def analyze_historical_trend(variable: str = "temperature") -> dict[str, Any]:
|
| 83 |
+
from weatherpred.data import load_or_fetch_history
|
| 84 |
+
|
| 85 |
+
variable = normalize_variable(variable)
|
| 86 |
+
if variable == "all":
|
| 87 |
+
variable = "temperature"
|
| 88 |
+
info = VARIABLE_COLUMNS[variable]
|
| 89 |
+
history = load_or_fetch_history(force_refresh=False).sort_values("date")
|
| 90 |
+
yearly = history.set_index("date")[info["history"]].resample("YE").mean().dropna()
|
| 91 |
+
x = np.arange(len(yearly), dtype=float)
|
| 92 |
+
slope = float(np.polyfit(x, yearly.to_numpy(dtype=float), 1)[0]) if len(yearly) > 1 else 0.0
|
| 93 |
+
first_mean = float(yearly.head(5).mean())
|
| 94 |
+
last_mean = float(yearly.tail(5).mean())
|
| 95 |
+
return {
|
| 96 |
+
"tool": "analyze_historical_trend",
|
| 97 |
+
"variable": variable,
|
| 98 |
+
"summary": {
|
| 99 |
+
"history_start": history["date"].min().strftime("%Y-%m-%d"),
|
| 100 |
+
"history_end": history["date"].max().strftime("%Y-%m-%d"),
|
| 101 |
+
"unit": info["unit"],
|
| 102 |
+
"first_5_year_mean": round(first_mean, 2),
|
| 103 |
+
"last_5_year_mean": round(last_mean, 2),
|
| 104 |
+
"change_last_vs_first": round(last_mean - first_mean, 2),
|
| 105 |
+
"linear_slope_per_year": round(slope, 3),
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def analyze_seasonality(variable: str = "temperature") -> dict[str, Any]:
|
| 111 |
+
from weatherpred.data import load_or_fetch_history
|
| 112 |
+
|
| 113 |
+
variable = normalize_variable(variable)
|
| 114 |
+
if variable == "all":
|
| 115 |
+
variable = "temperature"
|
| 116 |
+
info = VARIABLE_COLUMNS[variable]
|
| 117 |
+
history = load_or_fetch_history(force_refresh=False).copy()
|
| 118 |
+
history["month"] = history["date"].dt.month
|
| 119 |
+
monthly = history.groupby("month")[info["history"]].mean()
|
| 120 |
+
peak_month = int(monthly.idxmax())
|
| 121 |
+
low_month = int(monthly.idxmin())
|
| 122 |
+
return {
|
| 123 |
+
"tool": "analyze_seasonality",
|
| 124 |
+
"variable": variable,
|
| 125 |
+
"summary": {
|
| 126 |
+
"unit": info["unit"],
|
| 127 |
+
"peak_month": peak_month,
|
| 128 |
+
"peak_value": round(float(monthly.loc[peak_month]), 2),
|
| 129 |
+
"low_month": low_month,
|
| 130 |
+
"low_value": round(float(monthly.loc[low_month]), 2),
|
| 131 |
+
"seasonal_amplitude": round(float(monthly.max() - monthly.min()), 2),
|
| 132 |
+
"monthly_means": {str(month): round(float(value), 2) for month, value in monthly.items()},
|
| 133 |
+
},
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def compare_model_vs_open_meteo(variable: str = "all", horizon_days: int = FORECAST_DAYS) -> dict[str, Any]:
|
| 138 |
+
forecast = forecast_next_days(variable=variable, horizon_days=horizon_days)
|
| 139 |
+
comparison = forecast["comparison"]
|
| 140 |
+
variable = normalize_variable(variable)
|
| 141 |
+
selected = ["temperature", "rain", "wind"] if variable == "all" else [variable]
|
| 142 |
+
deltas: dict[str, Any] = {}
|
| 143 |
+
for item in selected:
|
| 144 |
+
if item == "temperature":
|
| 145 |
+
deltas["temperature_high_mae_c"] = round(float((comparison["model_high_c"] - comparison["api_high_c"]).abs().mean()), 2)
|
| 146 |
+
deltas["temperature_low_mae_c"] = round(float((comparison["model_low_c"] - comparison["api_low_c"]).abs().mean()), 2)
|
| 147 |
+
elif item == "rain":
|
| 148 |
+
deltas["rain_mae_mm"] = round(float((comparison["model_rain_mm"] - comparison["api_rain_mm"]).abs().mean()), 2)
|
| 149 |
+
elif item == "wind":
|
| 150 |
+
deltas["wind_mae_kmh"] = round(float((comparison["model_wind_kmh"] - comparison["api_wind_kmh"]).abs().mean()), 2)
|
| 151 |
+
forecast["tool"] = "compare_model_vs_open_meteo"
|
| 152 |
+
forecast["summary"]["average_absolute_differences"] = deltas
|
| 153 |
+
return forecast
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def explain_model() -> dict[str, Any]:
|
| 157 |
+
return {
|
| 158 |
+
"tool": "explain_model",
|
| 159 |
+
"variable": "all",
|
| 160 |
+
"summary": {
|
| 161 |
+
"data_source": "Open-Meteo archive API for history and Open-Meteo forecast API as an external baseline.",
|
| 162 |
+
"history_window": "Up to 30 years of daily Tokyo max/min temperature, precipitation, and max wind speed stored in local CSV/JSON cache.",
|
| 163 |
+
"model": "A small ONNX demo model loaded from models/mock_model/model.onnx.",
|
| 164 |
+
"direct_prediction": "The ONNX model directly predicts five days of max temperature from the previous seven daily max temperatures.",
|
| 165 |
+
"derived_outputs": "Min temperature, rain, wind, confidence, and horizons beyond five days are derived from historical seasonal statistics.",
|
| 166 |
+
"limitation": "This is an interview/demo forecasting scaffold, not a production meteorological model.",
|
| 167 |
+
},
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def show_dashboard_view(variable: str = "all") -> dict[str, Any]:
|
| 172 |
+
variable = normalize_variable(variable)
|
| 173 |
+
locations = {
|
| 174 |
+
"temperature": "top-left chart",
|
| 175 |
+
"rain": "top-right chart",
|
| 176 |
+
"wind": "bottom-left chart",
|
| 177 |
+
"all": "the three chart cells in the 2x2 layout",
|
| 178 |
+
}
|
| 179 |
+
return {
|
| 180 |
+
"tool": "show_dashboard_view",
|
| 181 |
+
"variable": variable,
|
| 182 |
+
"summary": {"view": locations[variable]},
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def run_tool(action: dict[str, Any]) -> dict[str, Any]:
|
| 187 |
+
tool = action.get("tool", "show_dashboard_view")
|
| 188 |
+
variable = normalize_variable(action.get("variable"))
|
| 189 |
+
horizon = clamp_horizon(action.get("horizon_days"))
|
| 190 |
+
if tool == "forecast_next_days":
|
| 191 |
+
return forecast_next_days(variable, horizon)
|
| 192 |
+
if tool == "analyze_historical_trend":
|
| 193 |
+
return analyze_historical_trend(variable)
|
| 194 |
+
if tool == "analyze_seasonality":
|
| 195 |
+
return analyze_seasonality(variable)
|
| 196 |
+
if tool == "compare_model_vs_open_meteo":
|
| 197 |
+
return compare_model_vs_open_meteo(variable, horizon)
|
| 198 |
+
if tool == "explain_model":
|
| 199 |
+
return explain_model()
|
| 200 |
+
return show_dashboard_view(variable)
|
weatherpred/zerogpu.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility wrapper for HuggingFace ZeroGPU's spaces package."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
import spaces
|
| 7 |
+
except ImportError:
|
| 8 |
+
class _SpacesFallback:
|
| 9 |
+
@staticmethod
|
| 10 |
+
def GPU(*_args, **_kwargs):
|
| 11 |
+
def decorator(function):
|
| 12 |
+
return function
|
| 13 |
+
return decorator
|
| 14 |
+
|
| 15 |
+
spaces = _SpacesFallback()
|