rickyt commited on
Commit
ce62384
Β·
1 Parent(s): e4a3592

add tomorrow

Browse files
Files changed (1) hide show
  1. app.py +258 -102
app.py CHANGED
@@ -1,135 +1,291 @@
1
  # app.py
2
- # Gradio app: Xweather hourly forecast table (next 12 hours)
3
- # Requirements: pip install gradio requests pandas python-dateutil
4
 
5
  import os
6
  import requests
7
  import pandas as pd
8
  import gradio as gr
9
  from zoneinfo import ZoneInfo
 
 
 
10
 
11
- BASE_URL = "https://data.api.xweather.com/forecasts/"
12
 
13
- CLIENT_ID = "BlZV8kShcnDxJ2ugQ3b65"
14
- CLIENT_SECRET = "JYvA8vAJJqEO6yP5QixQw59V3oUKqO9HHvj7ZI2R"
 
15
 
16
- def fetch_hourly_table(location: str):
17
- """
18
- location: either 'lat,lon' (e.g., '-6.29,106.82') or a place string (e.g., 'Jakarta, ID')
19
- returns: (pandas.DataFrame, info_markdown)
20
- """
21
- if not CLIENT_ID or not CLIENT_SECRET:
22
- return None, "❌ Missing API credentials. Please set XWEATHER_CLIENT_ID and XWEATHER_CLIENT_SECRET."
23
-
24
- location = (location or "").strip()
25
- if not location:
26
- return None, "❌ Enter a location (e.g., '-6.29,106.82' or 'Jakarta, ID')."
27
-
28
- # Build request
29
- # {id} may be 'city,cc' or 'lat,lon'. Keep comma unescaped so the API recognizes coordinates.
30
- url = BASE_URL + requests.utils.quote(location, safe=",")
31
- params = {
32
- "client_id": CLIENT_ID,
33
- "client_secret": CLIENT_SECRET,
34
- "filter": "1hr", # hourly periods
35
- "limit": 24 # next 12 hours
36
- }
37
 
38
- try:
39
- r = requests.get(url, params=params, timeout=20)
40
- except requests.RequestException as e:
41
- return None, f"❌ Network error: {e}"
42
-
43
- if r.status_code == 404:
44
- # Common case: no coverage for that location
45
- return None, f"⚠️ No forecast coverage for this location (404). Try a nearby city or adjust coordinates."
46
- if r.status_code != 200:
47
- # Show a short snippet of the response for debugging
48
- body = r.text[:400]
49
- return None, f"❌ API error {r.status_code}:\n```\n{body}\n```"
50
 
 
 
 
 
 
 
 
51
  try:
52
- data = r.json()
53
- except ValueError:
54
- return None, "❌ Failed to parse JSON response."
55
-
56
- # Xweather returns a top-level list of records for this endpoint
57
- # (loc/place/periods/profile per record)
58
- # Fallbacks included in case the format changes.
59
- records = []
60
- if isinstance(data, list):
61
- records = data
62
- elif isinstance(data, dict) and "response" in data:
63
- records = data["response"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  else:
65
- records = [data]
66
 
67
- if not records or "periods" not in records[0]:
68
- return None, "⚠️ Unexpected response format or no periods returned."
 
 
 
 
69
 
70
- rec = records[0]
71
- periods = (rec.get("periods") or [])[:24]
72
- if not periods:
73
- return None, "⚠️ No hourly periods returned."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- # Use location timezone if provided
76
- tz_str = (rec.get("profile") or {}).get("tz")
77
- tz = None
78
- if tz_str:
79
- try:
80
- tz = ZoneInfo(tz_str)
81
- except Exception:
82
- tz = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  rows = []
85
- for p in periods:
86
- # Prefer ISO time (already timezone-aware with offset)
87
  iso = p.get("dateTimeISO") or p.get("validTime")
88
- dt = pd.to_datetime(iso) if iso else None
89
- if tz and getattr(dt, "tzinfo", None) is not None:
90
- try:
91
- dt = dt.tz_convert(tz)
92
- except Exception:
93
- pass
94
-
95
  rows.append({
96
- "Time": dt.strftime("%Y-%m-%d %H:%M %Z") if isinstance(dt, pd.Timestamp) else iso,
97
- "Weather": p.get("weather") or p.get("weatherPrimary"),
98
- "Temp (Β°C)": p.get("tempC") if p.get("tempC") is not None else p.get("avgTempC"),
99
- "Feels (Β°C)": p.get("feelslikeC") if p.get("feelslikeC") is not None else p.get("avgFeelslikeC"),
100
- "Humidity (%)": p.get("humidity"),
101
- "POP (%)": p.get("pop"),
102
- "Precip (mm)": p.get("precipMM"),
103
- "Wind (kph)": p.get("windSpeedKPH"),
104
- "Wind Dir": p.get("windDir"),
105
- "Visibility (km)": p.get("visibilityKM"),
106
- "Cloud Cover (%)": p.get("sky"),
107
  })
108
-
109
  df = pd.DataFrame(rows)
 
 
110
 
111
- place = rec.get("place") or {}
112
- place_str = ", ".join([x for x in [place.get("name"), place.get("state"), place.get("country")] if x])
113
- loc_str = f"**Location**: {place_str or location}"
114
- tz_info = f" β€’ **Timezone**: {tz_str}" if tz_str else ""
115
- meta = f"{loc_str}{tz_info} β€’ Periods: {len(df)} (filter=1hr, limit=24)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
- return df, meta
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
 
119
  with gr.Blocks(fill_height=True) as demo:
120
- gr.Markdown("## Xweather β€” Hourly Forecast (Next 24 Hours)")
121
  with gr.Row():
122
- loc = gr.Textbox(
123
- label="Location",
124
- placeholder="lat,lon (e.g., -6.21,106.85) or place name (e.g., 'Jakarta, ID')",
125
- value="0.46876,116.16879"
126
- )
127
- btn = gr.Button("Get forecast", variant="primary")
128
- out_table = gr.Dataframe(label="Hourly forecast", interactive=False, wrap=True)
129
  out_info = gr.Markdown()
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- btn.click(fetch_hourly_table, inputs=loc, outputs=[out_table, out_info])
132
 
133
  if __name__ == "__main__":
134
- # For local runs; on Spaces, Gradio will call this file directly.
135
  demo.launch()
 
1
  # app.py
2
+ # Gradio app: Xweather vs Tomorrow.io hourly (next 24 hours) side-by-side
3
+ # Requirements: pip install gradio requests pandas python-dateutil python-dotenv
4
 
5
  import os
6
  import requests
7
  import pandas as pd
8
  import gradio as gr
9
  from zoneinfo import ZoneInfo
10
+ from dotenv import load_dotenv
11
+ import numpy as np
12
+ from html import escape
13
 
14
+ load_dotenv()
15
 
16
+ # -------------------
17
+ # Helper functions
18
+ # -------------------
19
 
20
+ def _wx_emoji(txt):
21
+ """Safely format weather text with emoji icons"""
22
+ if txt is None or (isinstance(txt, float) and np.isnan(txt)):
23
+ return "β€”"
24
+ txt = str(txt) # ensure always string
25
+ t = txt.lower()
26
+ if "thunder" in t: return "β›ˆοΈ " + txt
27
+ if "shower" in t or "rain" in t or "drizzle" in t: return "🌧️ " + txt
28
+ if "fog" in t or "mist" in t: return "🌫️ " + txt
29
+ if "cloud" in t or "overcast" in t: return "☁️ " + txt
30
+ if "clear" in t or "sun" in t: return "β˜€οΈ " + txt
31
+ return txt
 
 
 
 
 
 
 
 
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ def _fmt(x, nd=1):
35
+ if x is None or (isinstance(x, float) and np.isnan(x)): return "β€”"
36
+ if isinstance(x, (int, np.integer)): return f"{x:d}"
37
+ if isinstance(x, (float, np.floating)): return f"{x:.{nd}f}"
38
+ return escape(str(x))
39
+
40
+ def _heat(value, vmax, hue="blue"):
41
  try:
42
+ v = float(value or 0.0)
43
+ except:
44
+ v = 0.0
45
+ pct = max(0.0, min(1.0, v / max(vmax, 1e-9)))
46
+ if hue == "blue":
47
+ return f"background: rgba(0, 123, 255, {0.08*pct});"
48
+ if hue == "green":
49
+ return f"background: rgba(0, 200, 83, {0.08*pct});"
50
+ return ""
51
+
52
+ def build_pretty_table(df: pd.DataFrame) -> tuple[str, dict]:
53
+ if df is None or len(df) == 0:
54
+ return "<p>No data.</p>", {}
55
+
56
+ df = df.copy()
57
+ df["XW_mm"] = pd.to_numeric(df.get("XW_Precip_mm"), errors="coerce")
58
+ df["TMRW_mm"] = pd.to_numeric(df.get("TMRW_PrecipRate_mmph"), errors="coerce")
59
+ df["XW_PoP"] = pd.to_numeric(df.get("XW_RAIN_PROB_%"), errors="coerce")
60
+ df["TMRW_PoP"] = pd.to_numeric(df.get("TMRW_RAIN_PROB_%"), errors="coerce")
61
+
62
+ # Rain alert condition
63
+ rain_flag = (
64
+ ((df["XW_mm"] >= 1.0) | (df["TMRW_mm"] >= 1.0)) |
65
+ ((df["XW_PoP"] >= 70) | (df["TMRW_PoP"] >= 70))
66
+ )
67
+
68
+ # Next rain + peak rain
69
+ def _first_rain_row():
70
+ for _, row in df.iterrows():
71
+ if (row.get("XW_mm", 0) >= 1) or (row.get("TMRW_mm", 0) >= 1) or \
72
+ (row.get("XW_PoP", 0) >= 70) or (row.get("TMRW_PoP", 0) >= 70):
73
+ return row.get("Local Time")
74
+ return None
75
+ next_rain = _first_rain_row()
76
+
77
+ xw_vals = df["XW_mm"].fillna(0).to_numpy() if "XW_mm" in df else np.zeros(len(df))
78
+ tm_vals = df["TMRW_mm"].fillna(0).to_numpy() if "TMRW_mm" in df else np.zeros(len(df))
79
+ df["peak_proxy"] = np.maximum(xw_vals, tm_vals)
80
+
81
+ if df["peak_proxy"].max() > 0:
82
+ peak_idx = int(df["peak_proxy"].idxmax())
83
+ peak_when = df.iloc[peak_idx]["Local Time"]
84
+ peak_amt = float(df.iloc[peak_idx]["peak_proxy"])
85
  else:
86
+ peak_when, peak_amt = None, 0.0
87
 
88
+ summary = {
89
+ "next_rain": next_rain,
90
+ "peak_when": peak_when,
91
+ "peak_mmph": round(peak_amt, 1),
92
+ "has_rain": bool(next_rain),
93
+ }
94
 
95
+ # --- Grouped header ---
96
+ rows_html = []
97
+ rows_html.append(
98
+ "<thead>"
99
+ "<tr>"
100
+ "<th rowspan='2'>Local Time</th>"
101
+ "<th class='xw-col' colspan='3'>Xweather</th>"
102
+ "<th class='tm-col' colspan='3'>Tomorrow.io</th>"
103
+ "</tr>"
104
+ "<tr>"
105
+ "<th class='xw-col'>Weather</th>"
106
+ "<th class='xw-col'>Rain Prob. %</th>"
107
+ "<th class='xw-col'>Rain mm</th>"
108
+ "<th class='tm-col'>Weather</th>"
109
+ "<th class='tm-col'>Rain Prob. %</th>"
110
+ "<th class='tm-col'>Rain mm/h</th>"
111
+ "</tr>"
112
+ "</thead>"
113
+ )
114
 
115
+ vmax_pop = max(70, np.nanmax([df["XW_PoP"].max(), df["TMRW_PoP"].max()]))
116
+ vmax_mm = max(2.0, np.nanmax([df["XW_mm"].max(), df["TMRW_mm"].max()]))
117
+
118
+ body = []
119
+ for i, r in df.iterrows():
120
+ alert = rain_flag.iloc[i]
121
+ tr_style = "background: rgba(255,165,0,0.08);" if alert else ""
122
+ cells = [
123
+ f"<td>{escape(str(r.get('Local Time','')))}</td>",
124
+ f"<td class='xw-cell'>{_wx_emoji(r.get('XW_Weather'))}</td>",
125
+ f"<td class='xw-cell' style='{_heat(r.get('XW_PoP'), vmax_pop, 'green')}'>{_fmt(r.get('XW_PoP'))}</td>",
126
+ f"<td class='xw-cell' style='{_heat(r.get('XW_mm'), vmax_mm, 'blue')}'>{_fmt(r.get('XW_mm'))}</td>",
127
+ f"<td class='tm-cell'>{_wx_emoji(r.get('TMRW_Weather'))}</td>",
128
+ f"<td class='tm-cell' style='{_heat(r.get('TMRW_PoP'), vmax_pop, 'green')}'>{_fmt(r.get('TMRW_PoP'))}</td>",
129
+ f"<td class='tm-cell' style='{_heat(r.get('TMRW_mm'), vmax_mm, 'blue')}'>{_fmt(r.get('TMRW_mm'))}</td>",
130
+ ]
131
+ body.append(f"<tr style='{tr_style}'>" + "".join(cells) + "</tr>")
132
+
133
+ table_css = """
134
+ <style>
135
+ .wx-table { width: 100%; border-collapse: collapse; font-family: ui-sans-serif, system-ui, -apple-system; }
136
+ .wx-table th, .wx-table td { padding: 10px 8px; border-bottom: 1px solid #eee; font-size: 14px; vertical-align: middle; text-align: center;}
137
+ .wx-table thead th { position: sticky; top: 0; z-index: 1; }
138
+ .wx-table th.xw-col { background: #cfe2ff; text-align: center;} /* Xweather headers */
139
+ .wx-table th.tm-col { background: #d4edda; text-align: center;} /* Tomorrow.io headers */
140
+ .wx-table th:first-child { background: #fafafa; }
141
+ .xw-cell { background: #f8fbff; }
142
+ .tm-cell { background: #f9fff9; }
143
+ .wx-chips { display:flex; gap:8px; flex-wrap: wrap; margin: 10px 0 6px; }
144
+ .chip { padding:6px 10px; border-radius: 999px; background: #f5f5f5; border:1px solid #eee; font-size: 13px; }
145
+ .chip.good { background:#e6f4ea; border-color:#ccebd7; }
146
+ .chip.warn { background:#fff7e6; border-color:#ffe2b3; }
147
+ </style>
148
+ """
149
+
150
+ chips = []
151
+ chips.append(f"<span class='chip {'warn' if summary['has_rain'] else 'good'}'>"
152
+ f"{'Next rain: ' + escape(summary['next_rain']) if summary['has_rain'] else 'No rain signal in next 24h'}</span>")
153
+ if summary["peak_mmph"] > 0:
154
+ chips.append(f"<span class='chip'>Peak ~{summary['peak_mmph']} mm/h at {escape(summary['peak_when'] or '')}</span>")
155
+
156
+ header_html = "<div class='wx-chips'>" + "".join(chips) + "</div>"
157
 
158
+ table_html = f"""
159
+ {table_css}
160
+ {header_html}
161
+ <table class="wx-table">
162
+ {''.join(rows_html)}
163
+ <tbody>
164
+ {''.join(body)}
165
+ </tbody>
166
+ </table>
167
+ """
168
+ return table_html, summary
169
+
170
+ # -------------------
171
+ # API Configs
172
+ # -------------------
173
+ XW_BASE_URL = "https://data.api.xweather.com/forecasts/"
174
+ CLIENT_ID = os.getenv("XWEATHER_CLIENT_ID") or "BlZV8kShcnDxJ2ugQ3b65"
175
+ CLIENT_SECRET = os.getenv("XWEATHER_CLIENT_SECRET") or "JYvA8vAJJqEO6yP5QixQw59V3oUKqO9HHvj7ZI2R"
176
+
177
+ TMRW_BASE = "https://api.tomorrow.io/v4/weather/forecast"
178
+ TMRW_API_KEY = os.getenv("TOMORROW_API_KEY") or "teKj9Rkys1UzWxKBEs36pAR8paCXnPW6"
179
+
180
+ TMRW_WEATHER_CODE = {
181
+ 0: "Unknown", 1000: "Clear", 1100: "Mostly Clear", 1101: "Partly Cloudy", 1102: "Mostly Cloudy",
182
+ 1001: "Cloudy", 2000: "Fog", 2100: "Light Fog",
183
+ 4000: "Drizzle", 4001: "Rain", 4200: "Light Rain", 4201: "Heavy Rain",
184
+ 5000: "Snow", 5001: "Flurries", 5100: "Light Snow", 5101: "Heavy Snow",
185
+ 6000: "Freezing Drizzle", 6001: "Freezing Rain", 6200: "Light Freezing Rain", 6201: "Heavy Freezing Rain",
186
+ 7000: "Ice Pellets", 7101: "Heavy Ice Pellets", 7102: "Light Ice Pellets",
187
+ 8000: "Thunderstorm"
188
+ }
189
+
190
+ def _safe_to_timestamp(iso: str):
191
+ if not iso: return None
192
+ try: return pd.to_datetime(iso)
193
+ except Exception: return None
194
+
195
+ # ---------- Xweather ----------
196
+ def fetch_xweather_hourly(location: str, limit: int = 24):
197
+ url = XW_BASE_URL + requests.utils.quote(location.strip(), safe=",")
198
+ params = {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "filter": "1hr", "limit": limit}
199
+ r = requests.get(url, params=params, timeout=20)
200
+ r.raise_for_status()
201
+ data = r.json()
202
+ recs = data.get("response") if isinstance(data, dict) and "response" in data else data
203
+ rec = recs[0] if isinstance(recs, list) and recs else data
204
+ periods = rec.get("periods") or []
205
+ tz_str = (rec.get("profile") or {}).get("tz", "")
206
  rows = []
207
+ for p in periods[:limit]:
 
208
  iso = p.get("dateTimeISO") or p.get("validTime")
209
+ t_utc = _safe_to_timestamp(iso)
210
+ if isinstance(t_utc, pd.Timestamp) and t_utc.tzinfo:
211
+ t_utc = t_utc.tz_convert("UTC")
 
 
 
 
212
  rows.append({
213
+ "time_utc": t_utc,
214
+ "XW_Weather": p.get("weather") or p.get("weatherPrimary"),
215
+ "XW_Humidity_%": p.get("humidity"),
216
+ "XW_RAIN_PROB_%": p.get("pop"),
217
+ "XW_Precip_mm": p.get("precipMM"),
218
+ "XW_Visibility_km": p.get("visibilityKM"),
219
+ "XW_Cloud_%": p.get("sky"),
220
+ "XW_TZ": tz_str,
 
 
 
221
  })
 
222
  df = pd.DataFrame(rows)
223
+ df["time_hour_utc"] = pd.to_datetime(df["time_utc"]).dt.tz_convert("UTC").dt.floor("H")
224
+ return df
225
 
226
+ # ---------- Tomorrow.io ----------
227
+ def fetch_tomorrow_hourly(latlon: str, hours: int = 24):
228
+ params = {"location": latlon.strip(), "timesteps": "hourly", "units": "metric", "apikey": TMRW_API_KEY}
229
+ r = requests.get(TMRW_BASE, params=params, timeout=20)
230
+ r.raise_for_status()
231
+ data = r.json()
232
+ hourly = (data.get("timelines") or {}).get("hourly") or []
233
+ rows = []
234
+ for h in hourly[:hours]:
235
+ t_utc = _safe_to_timestamp(h.get("time"))
236
+ v = h.get("values") or {}
237
+ code = v.get("weatherCode")
238
+ rows.append({
239
+ "time_utc": t_utc.tz_convert("UTC") if isinstance(t_utc, pd.Timestamp) and t_utc.tzinfo else t_utc,
240
+ "TMRW_Weather": TMRW_WEATHER_CODE.get(code, str(code) if code is not None else None),
241
+ "TMRW_Humidity_%": v.get("humidity"),
242
+ "TMRW_RAIN_PROB_%": v.get("precipitationProbability"),
243
+ "TMRW_PrecipRate_mmph": v.get("rainIntensity"),
244
+ "TMRW_Visibility_km": v.get("visibility"),
245
+ "TMRW_Cloud_%": v.get("cloudCover"),
246
+ })
247
+ df = pd.DataFrame(rows)
248
+ df["time_hour_utc"] = pd.to_datetime(df["time_utc"]).dt.tz_convert("UTC").dt.floor("H")
249
+ return df
250
 
251
+ # ---------- Merge ----------
252
+ def build_side_by_side(location: str):
253
+ xw = fetch_xweather_hourly(location, limit=24)
254
+ tm = fetch_tomorrow_hourly(location, hours=24)
255
+ merged = pd.merge(xw, tm, on="time_hour_utc", how="outer", sort=True)
256
+ try:
257
+ local_tz = ZoneInfo("Asia/Singapore") # UTC+8
258
+ merged["Local Time"] = (
259
+ pd.to_datetime(merged["time_hour_utc"])
260
+ .dt.tz_convert(local_tz) # βœ… directly convert from UTC
261
+ .dt.strftime("%Y-%m-%d %H:%M %Z")
262
+ )
263
+ except Exception:
264
+ merged["Local Time"] = merged["time_hour_utc"].astype(str)
265
+ return merged, f"**Location:** {location} β€’ Rows: {len(merged)} β€’ Sources: Xweather + Tomorrow.io (UTC+8)"
266
 
267
+ # ---------- Gradio UI ----------
268
  with gr.Blocks(fill_height=True) as demo:
269
+ gr.Markdown("## Hourly Forecast β€” Side by Side (Xweather vs Tomorrow.io)")
270
  with gr.Row():
271
+ loc = gr.Textbox(label="Location (lat,lon)", placeholder="e.g., -6.21,106.85", value="0.46876,116.16879")
272
+ btn = gr.Button("Compare", variant="primary")
273
+
274
+ out_table = gr.HTML(label="Hourly comparison (next 24h)")
 
 
 
275
  out_info = gr.Markdown()
276
+ dl_btn = gr.DownloadButton(label="Download CSV", value=None)
277
+
278
+ def _run(loc_s):
279
+ try:
280
+ df, meta = build_side_by_side(loc_s)
281
+ html, summary = build_pretty_table(df)
282
+ csv_path = "forecast_compare.csv"
283
+ df.to_csv(csv_path, index=False)
284
+ return html, meta, csv_path
285
+ except Exception as ex:
286
+ return f"<pre>{escape(str(ex))}</pre>", "", None
287
 
288
+ btn.click(_run, inputs=[loc], outputs=[out_table, out_info, dl_btn])
289
 
290
  if __name__ == "__main__":
 
291
  demo.launch()