cwadayi commited on
Commit
0598092
·
verified ·
1 Parent(s): 9f18d9b
Files changed (1) hide show
  1. app.py +0 -397
app.py DELETED
@@ -1,397 +0,0 @@
1
-
2
- # app.py (trend charts + downloadable PNGs)
3
- import os
4
- import json
5
- import tempfile
6
- from datetime import datetime, timedelta
7
- from typing import List, Dict, Any, Tuple
8
-
9
- import gradio as gr
10
- import pandas as pd
11
- import requests
12
- import folium
13
- import matplotlib
14
- matplotlib.use("Agg")
15
- import matplotlib.pyplot as plt
16
-
17
- BASE_URL = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001"
18
-
19
- TILE_CHOICES = {
20
- "OpenStreetMap": "OpenStreetMap",
21
- "CartoDB Positron": "CartoDB positron",
22
- "Stamen Terrain": "Stamen Terrain",
23
- "Esri World Imagery (衛星)": "Esri.WorldImagery"
24
- }
25
-
26
- def validate_iso(dt: str) -> str:
27
- if not dt:
28
- return ""
29
- try:
30
- datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S")
31
- return dt
32
- except ValueError:
33
- raise gr.Error("時間格式需為 yyyy-MM-ddThh:mm:ss")
34
-
35
- def build_params(limit: int|None, offset: int|None, fmt: str, sort: str|None, timeFrom: str|None, timeTo: str|None):
36
- params = []
37
- api_key = os.getenv("CWA_API_KEY")
38
- if not api_key:
39
- raise gr.Error("缺少授權碼:請到 Space 的 Settings → Repository secrets 新增 CWA_API_KEY。")
40
- params.append(("Authorization", api_key))
41
- if limit is not None: params.append(("limit", str(limit)))
42
- if offset is not None: params.append(("offset", str(offset)))
43
- if fmt: params.append(("format", fmt))
44
- if sort: params.append(("sort", sort))
45
- if timeFrom: params.append(("timeFrom", timeFrom))
46
- if timeTo: params.append(("timeTo", timeTo))
47
- return params
48
-
49
- def http_get(url: str, params: List[Tuple[str,str]]) -> Dict[str, Any]:
50
- sess = requests.Session()
51
- resp = sess.get(url, params=params, timeout=(5, 20))
52
- resp.raise_for_status()
53
- if "application/json" in resp.headers.get("Content-Type","").lower() or resp.text.strip().startswith("{"):
54
- return resp.json()
55
- else:
56
- return {"raw": resp.text}
57
-
58
- def extract_records(payload: Dict[str, Any]):
59
- recs = payload.get("records")
60
- if isinstance(recs, dict):
61
- for k, v in recs.items():
62
- if isinstance(v, list):
63
- return v
64
- result = payload.get("result")
65
- if isinstance(result, dict) and isinstance(result.get("records"), list):
66
- return result["records"]
67
- for key in ("Earthquake","earthquakes","data","items"):
68
- v = payload.get(key)
69
- if isinstance(v, list):
70
- return v
71
- return []
72
-
73
- def flatten_row(row: Dict[str, Any]) -> Dict[str, Any]:
74
- out = {}
75
- for key in ("EarthquakeNo","ReportImageURI","Web","ReportColor","ReportContent"):
76
- if key in row:
77
- out[key] = row.get(key)
78
- eqi = row.get("EarthquakeInfo")
79
- if isinstance(eqi, dict):
80
- out["OriginTime"] = eqi.get("OriginTime")
81
- out["Depth_km"] = eqi.get("FocalDepth")
82
- mag = eqi.get("EarthquakeMagnitude") or {}
83
- if isinstance(mag, dict):
84
- out["Magnitude"] = mag.get("MagnitudeValue")
85
- out["MagnitudeType"] = mag.get("MagnitudeType")
86
- epic = eqi.get("Epicenter") or {}
87
- if isinstance(epic, dict):
88
- out["Epicenter"] = epic.get("Location")
89
- out["EpicenterLon"] = epic.get("EpicenterLongitude")
90
- out["EpicenterLat"] = epic.get("EpicenterLatitude")
91
- for k in ("OriginTime","originTime","Time"):
92
- if k in row and "OriginTime" not in out:
93
- out["OriginTime"] = row.get(k)
94
- for k in ("Depth","depth","FocalDepth"):
95
- if k in row and "Depth_km" not in out:
96
- out["Depth_km"] = row.get(k)
97
- for k in ("Magnitude","mag"):
98
- if k in row and "Magnitude" not in out:
99
- out["Magnitude"] = row.get(k)
100
- maxint = row.get("Intensity") or row.get("ShakingArea")
101
- if isinstance(maxint, dict):
102
- out["MaxIntensity"] = maxint.get("MaxIntensity")
103
- return out
104
-
105
- def df_with_types(df: pd.DataFrame) -> pd.DataFrame:
106
- if "OriginTime" in df.columns:
107
- try:
108
- df["OriginTime"] = pd.to_datetime(df["OriginTime"], format="%Y-%m-%dT%H:%M:%S", errors="coerce")
109
- except Exception:
110
- pass
111
- if "Magnitude" in df.columns:
112
- df["Magnitude"] = pd.to_numeric(df["Magnitude"], errors="coerce")
113
- if "Depth_km" in df.columns:
114
- df["Depth_km"] = pd.to_numeric(df["Depth_km"], errors="coerce")
115
- return df
116
-
117
- def add_tw_bbox(m: folium.Map):
118
- bounds = [(21.0, 119.0), (26.0, 123.0)]
119
- folium.Rectangle(bounds=bounds, color="#444", fill=False, weight=2, dash_array="5").add_to(m)
120
-
121
- def add_legend(m: folium.Map):
122
- html = '''
123
- <div style="position: fixed; bottom: 10px; right: 10px; z-index:9999; background: rgba(255,255,255,0.9); padding: 8px 10px; border:1px solid #999; border-radius:6px; font-size:12px;">
124
- <div style="font-weight:600; margin-bottom:4px;">圖例</div>
125
- <div><span style="display:inline-block;width:12px;height:12px;background:#abd9e9;margin-right:6px;border:1px solid #999;"></span> M4.0–4.9</div>
126
- <div><span style="display:inline-block;width:12px;height:12px;background:#fdae61;margin-right:6px;border:1px solid #999;"></span> M5.0–5.9</div>
127
- <div><span style="display:inline-block;width:12px;height:12px;background:#d7191c;margin-right:6px;border:1px solid #999;"></span> M≥6.0</div>
128
- <div style="margin-top:4px;">圓徑 ≈ 規模 × 2.5</div>
129
- </div>
130
- '''
131
- folium.Marker(location=[0,0], icon=folium.DivIcon(html=html)).add_to(m)
132
-
133
- def make_map(df: pd.DataFrame, tile_choice: str) -> str:
134
- if not {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
135
- return "<p>沒有可用的經緯度資料。</p>"
136
- valid = df.dropna(subset=["EpicenterLat","EpicenterLon"]).copy()
137
- if valid.empty:
138
- return "<p>沒有可用的經緯度資料。</p>"
139
- try:
140
- valid["EpicenterLat"] = pd.to_numeric(valid["EpicenterLat"], errors="coerce")
141
- valid["EpicenterLon"] = pd.to_numeric(valid["EpicenterLon"], errors="coerce")
142
- except Exception:
143
- pass
144
- valid = valid.dropna(subset=["EpicenterLat","EpicenterLon"])
145
- if valid.empty:
146
- return "<p>沒有可用的經緯度資料。</p>"
147
-
148
- tiles = TILE_CHOICES.get(tile_choice, "OpenStreetMap")
149
- if tiles == "Esri.WorldImagery":
150
- m = folium.Map(location=[valid["EpicenterLat"].mean(), valid["EpicenterLon"].mean()], zoom_start=6, tiles=None)
151
- folium.TileLayer(
152
- tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
153
- attr="Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community",
154
- name="Esri World Imagery"
155
- ).add_to(m)
156
- else:
157
- m = folium.Map(location=[valid["EpicenterLat"].mean(), valid["EpicenterLon"].mean()], zoom_start=6, tiles=tiles)
158
-
159
- add_tw_bbox(m)
160
-
161
- for _, r in valid.iterrows():
162
- lat = float(r["EpicenterLat"]); lon = float(r["EpicenterLon"])
163
- mag = r.get("Magnitude", None)
164
- radius = 4.0
165
- try:
166
- if pd.notna(mag):
167
- radius = max(4.0, min(20.0, float(mag) * 2.5))
168
- except Exception:
169
- pass
170
- color = "#2c7bb6"
171
- try:
172
- if mag is not None and float(mag) >= 6.0:
173
- color = "#d7191c"
174
- elif mag is not None and float(mag) >= 5.0:
175
- color = "#fdae61"
176
- elif mag is not None and float(mag) >= 4.0:
177
- color = "#abd9e9"
178
- except Exception:
179
- pass
180
- popup = folium.Popup(html=f"<b>時間</b>: {r.get('OriginTime','')}<br>"
181
- f"<b>震央</b>: {r.get('Epicenter','')}<br>"
182
- f"<b>規模</b>: {mag}<br>"
183
- f"<b>深度</b>: {r.get('Depth_km','')} km", max_width=320)
184
- folium.CircleMarker(location=[lat, lon], radius=radius, color=color, fill=True, fill_opacity=0.7, popup=popup).add_to(m)
185
- add_legend(m)
186
- return m._repr_html_()
187
-
188
- def make_trend_charts(df: pd.DataFrame, tmpdir: str) -> Tuple[str, str]:
189
- mag_time_png = os.path.join(tmpdir, "mag_time.png")
190
- daily_count_png = os.path.join(tmpdir, "daily_count.png")
191
-
192
- # Magnitude vs Time
193
- if "OriginTime" in df.columns and "Magnitude" in df.columns and not df.empty:
194
- s = df.dropna(subset=["OriginTime","Magnitude"]).copy()
195
- if not s.empty:
196
- plt.figure()
197
- plt.plot(s["OriginTime"], s["Magnitude"], marker="o", linestyle="-")
198
- plt.xlabel("Time")
199
- plt.ylabel("Magnitude")
200
- plt.title("Magnitude vs Time")
201
- plt.xticks(rotation=45)
202
- plt.tight_layout()
203
- plt.savefig(mag_time_png, dpi=150)
204
- plt.close()
205
- else:
206
- open(mag_time_png, "wb").write(b"")
207
- else:
208
- open(mag_time_png, "wb").write(b"")
209
-
210
- # Daily counts
211
- if "OriginTime" in df.columns and not df.empty:
212
- s = df.dropna(subset=["OriginTime"]).copy()
213
- if not s.empty:
214
- s["date"] = s["OriginTime"].dt.date
215
- cnt = s.groupby("date").size().reset_index(name="count")
216
- plt.figure()
217
- plt.plot(cnt["date"], cnt["count"], marker="o", linestyle="-")
218
- plt.xlabel("Date")
219
- plt.ylabel("Counts")
220
- plt.title("Daily Earthquake Counts")
221
- plt.xticks(rotation=45)
222
- plt.tight_layout()
223
- plt.savefig(daily_count_png, dpi=150)
224
- plt.close()
225
- else:
226
- open(daily_count_png, "wb").write(b"")
227
- else:
228
- open(daily_count_png, "wb").write(b"")
229
-
230
- return mag_time_png, daily_count_png
231
-
232
- def to_geojson(df: pd.DataFrame, path: str) -> str:
233
- if not {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
234
- open(path, "w", encoding="utf-8").write(json.dumps({"type":"FeatureCollection","features":[]}))
235
- return path
236
- features = []
237
- for _, r in df.iterrows():
238
- try:
239
- lat = float(r.get("EpicenterLat"))
240
- lon = float(r.get("EpicenterLon"))
241
- except (TypeError, ValueError):
242
- continue
243
- props = {
244
- "OriginTime": str(r.get("OriginTime","")),
245
- "Epicenter": r.get("Epicenter",""),
246
- "Magnitude": r.get("Magnitude",""),
247
- "Depth_km": r.get("Depth_km","")
248
- }
249
- features.append({
250
- "type": "Feature",
251
- "geometry": {"type": "Point", "coordinates": [lon, lat]},
252
- "properties": props
253
- })
254
- fc = {"type":"FeatureCollection", "features": features}
255
- with open(path, "w", encoding="utf-8") as f:
256
- json.dump(fc, f, ensure_ascii=False, indent=2)
257
- return path
258
-
259
- def to_kml(df: pd.DataFrame, path: str) -> str:
260
- def esc(s):
261
- return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
262
- kml = [
263
- '<?xml version="1.0" encoding="UTF-8"?>',
264
- '<kml xmlns="http://www.opengis.net/kml/2.2">',
265
- "<Document>"
266
- ]
267
- if {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
268
- for _, r in df.iterrows():
269
- try:
270
- lat = float(r.get("EpicenterLat"))
271
- lon = float(r.get("EpicenterLon"))
272
- except (TypeError, ValueError):
273
- continue
274
- name = f"M{r.get('Magnitude','')} {r.get('Epicenter','')}"
275
- desc = f"時間: {r.get('OriginTime','')}\n深度: {r.get('Depth_km','')} km"
276
- kml.extend([
277
- "<Placemark>",
278
- f"<name>{esc(name)}</name>",
279
- f"<description>{esc(desc)}</description>",
280
- "<Point>",
281
- f"<coordinates>{lon},{lat},0</coordinates>",
282
- "</Point>",
283
- "</Placemark>"
284
- ])
285
- kml.append("</Document></kml>")
286
- with open(path, "w", encoding="utf-8") as f:
287
- f.write("\n".join(kml))
288
- return path
289
-
290
- def fetch(time_from, time_to, limit, offset, fmt, sort, tile_choice):
291
- time_from = validate_iso(time_from) if time_from else None
292
- time_to = validate_iso(time_to) if time_to else None
293
- params = build_params(limit=limit, offset=offset, fmt=fmt, sort=sort, timeFrom=time_from, timeTo=time_to)
294
- payload = http_get(BASE_URL, params)
295
- records = extract_records(payload)
296
- flat = [flatten_row(r) for r in records]
297
- df = pd.DataFrame(flat)
298
- df = df_with_types(df)
299
- if "OriginTime" in df.columns:
300
- ascending = True if sort == "OriginTime" else False
301
- df = df.sort_values("OriginTime", ascending=ascending)
302
- tmpdir = tempfile.mkdtemp(prefix="cwa_")
303
- csv_path = os.path.join(tmpdir, "cwa_quake.csv")
304
- json_path = os.path.join(tmpdir, "raw.json")
305
- geojson_path = os.path.join(tmpdir, "cwa_quake.geojson")
306
- kml_path = os.path.join(tmpdir, "cwa_quake.kml")
307
- mag_time_png, daily_count_png = make_trend_charts(df, tmpdir)
308
- df.to_csv(csv_path, index=False, encoding="utf-8")
309
- with open(json_path, "w", encoding="utf-8") as f:
310
- json.dump(payload, f, ensure_ascii=False, indent=2)
311
- to_geojson(df, geojson_path)
312
- to_kml(df, kml_path)
313
- total = len(df)
314
- earliest = latest = ""
315
- if total and "OriginTime" in df.columns:
316
- earliest_row = df.iloc[0]; latest_row = df.iloc[-1]
317
- def fmt_row(r):
318
- ot = r.get("OriginTime","")
319
- if isinstance(ot, pd.Timestamp):
320
- ot = ot.strftime("%Y-%m-%dT%H:%M:%S")
321
- return f"{ot} | {r.get('Epicenter','')} | M{r.get('Magnitude','')} | 深{r.get('Depth_km','')}km"
322
- earliest = "最早: " + fmt_row(earliest_row)
323
- latest = "最新: " + fmt_row(latest_row)
324
- summary = f"取得筆數: {total}\n{earliest}\n{latest}"
325
- html_map = make_map(df, tile_choice)
326
- if "OriginTime" in df.columns:
327
- df["OriginTime"] = df["OriginTime"].astype(str)
328
- return df, summary, csv_path, json_path, html_map, geojson_path, kml_path, mag_time_png, daily_count_png
329
-
330
- def quick_range(hours: int):
331
- now = datetime.now()
332
- tf = (now - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
333
- tt = now.strftime("%Y-%m-%dT%H:%M:%S")
334
- return tf, tt
335
-
336
- with gr.Blocks(title="CWA 顯著有感地震報告 (E-A0015-001)") as demo:
337
- gr.Markdown("# CWA 顯著有感地震報告 (E-A0015-001)")
338
- gr.Markdown("**此 Space 只使用環境變數 `CWA_API_KEY` 作為授權。** 預設查詢最近 3 天。")
339
-
340
- tf_default = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M:%S")
341
- tt_default = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
342
-
343
- time_from = gr.Textbox(label="timeFrom yyyy-MM-ddThh:mm:ss", value=tf_default)
344
- time_to = gr.Textbox(label="timeTo yyyy-MM-ddThh:mm:ss", value=tt_default)
345
- with gr.Row():
346
- btn6 = gr.Button("最近 6 小時")
347
- btn12 = gr.Button("最近 12 小時")
348
- btn24 = gr.Button("最近 24 小時")
349
- btn3d = gr.Button("最近 3 天")
350
- sort = gr.Dropdown(choices=[None, "OriginTime"], value=None, label="sort(預設降冪;選 OriginTime 會升冪)")
351
- limit = gr.Number(label="limit(筆數上限)", precision=0, value=60)
352
- offset = gr.Number(label="offset(起始偏移)", precision=0, value=0)
353
- fmt = gr.Radio(choices=["JSON","XML"], value="JSON", label="回傳格式")
354
- tile_choice = gr.Dropdown(choices=list(TILE_CHOICES.keys()), value="OpenStreetMap", label="地圖底圖")
355
-
356
- auto_on = gr.Checkbox(label="每小時自動刷新(固定使用目前 timeFrom/timeTo)", value=False)
357
- timer = gr.Timer(3600.0)
358
-
359
- run_btn = gr.Button("查詢", variant="primary")
360
-
361
- out_df = gr.Dataframe(label="查詢結果(扁平化)", interactive=False, wrap=True, datatype="str")
362
- out_summary = gr.Textbox(label="摘要", interactive=False)
363
- out_csv = gr.File(label="下載 CSV")
364
- out_json = gr.File(label="下載原始 JSON")
365
- out_map = gr.HTML(label="震央地圖")
366
- out_geojson = gr.File(label="下載 GeoJSON")
367
- out_kml = gr.File(label="下載 KML")
368
- out_mag_time = gr.Image(label="Magnitude vs Time(規模-時間趨勢)")
369
- out_daily_cnt = gr.Image(label="Daily Earthquake Counts(每日件數)")
370
- dl_mag_time = gr.File(label="下載 趨勢圖:Magnitude vs Time (PNG)")
371
- dl_daily_cnt = gr.File(label="下載 趨勢圖:每日件數 (PNG)")
372
-
373
- def on_click(time_from, time_to, limit, offset, fmt, sort, tile_choice):
374
- df, summary, csv_path, json_path, html_map, geojson_path, kml_path, mag_time_png, daily_count_png = fetch(
375
- time_from, time_to, int(limit) if limit is not None else None, int(offset) if offset is not None else None,
376
- fmt, sort, tile_choice
377
- )
378
- return df, summary, csv_path, json_path, html_map, geojson_path, kml_path, mag_time_png, daily_count_png, mag_time_png, daily_count_png
379
-
380
- def on_tick(auto_on, time_from, time_to, limit, offset, fmt, sort, tile_choice):
381
- if not auto_on:
382
- return [gr.skip()] * 11
383
- return on_click(time_from, time_to, limit, offset, fmt, sort, tile_choice)
384
-
385
- run_btn.click(on_click, inputs=[time_from, time_to, limit, offset, fmt, sort, tile_choice],
386
- outputs=[out_df, out_summary, out_csv, out_json, out_map, out_geojson, out_kml, out_mag_time, out_daily_cnt, dl_mag_time, dl_daily_cnt])
387
-
388
- btn6.click(lambda: quick_range(6), inputs=[], outputs=[time_from, time_to])
389
- btn12.click(lambda: quick_range(12), inputs=[], outputs=[time_from, time_to])
390
- btn24.click(lambda: quick_range(24), inputs=[], outputs=[time_from, time_to])
391
- btn3d.click(lambda: ((datetime.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M:%S"), datetime.now().strftime("%Y-%m-%dT%H:%M:%S")), inputs=[], outputs=[time_from, time_to])
392
-
393
- timer.tick(on_tick, inputs=[auto_on, time_from, time_to, limit, offset, fmt, sort, tile_choice],
394
- outputs=[out_df, out_summary, out_csv, out_json, out_map, out_geojson, out_kml, out_mag_time, out_daily_cnt, dl_mag_time, dl_daily_cnt])
395
-
396
- if __name__ == "__main__":
397
- demo.launch()