yyyc commited on
Commit
5194558
·
1 Parent(s): 873ef21

fist push the app

Browse files
README.md CHANGED
@@ -1,15 +1,67 @@
1
  ---
2
- title: WeatherPred
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
- An example chatbot using [Gradio](https://gradio.app), [`huggingface_hub`](https://huggingface.co/docs/huggingface_hub/v0.22.2/en/index), and the [Hugging Face Inference API](https://huggingface.co/docs/api-inference/index).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- import gradio as gr
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()