cwadayi commited on
Commit
7b28d75
·
verified ·
1 Parent(s): 0598092

Upload 2 files

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