cwadayi commited on
Commit
92c9f5e
·
verified ·
1 Parent(s): 7d8300a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +29 -207
app.py CHANGED
@@ -8,29 +8,13 @@ import pandas as pd
8
  import matplotlib.pyplot as plt
9
  import gradio as gr
10
 
11
- # ---------- 可選依賴偵測 ----------
12
  try:
13
  import tabulate as _tabulate # noqa: F401
14
  HAS_TABULATE = True
15
  except Exception:
16
  HAS_TABULATE = False
17
 
18
- try:
19
- import numpy as np # noqa: F401
20
- import pygmt
21
- HAS_PYGMT = True
22
- except Exception:
23
- HAS_PYGMT = False
24
-
25
- # ---- 自動抓取 PyGMT 所需資料(若可用) ----
26
- if 'HAS_PYGMT' in globals() and HAS_PYGMT:
27
- try:
28
- pygmt.which("@gshhg", download=True) # 海岸線
29
- pygmt.which("@dcw", download=True) # 國界
30
- pygmt.which("@earth_relief_04m", download=True) # 地形備援
31
- except Exception:
32
- pass
33
-
34
  # -----------------------------
35
  # 台北時區 (UTC+8)
36
  # -----------------------------
@@ -40,6 +24,7 @@ def _fmt(dt: datetime) -> str:
40
  return dt.strftime("%Y-%m-%dT%H:%M:%S")
41
 
42
  def set_time_range(hours=None, days=None):
 
43
  now = datetime.now(TAIPEI_TZ)
44
  if hours is not None:
45
  t_from = now - timedelta(hours=hours)
@@ -64,10 +49,15 @@ def fetch_reports(time_from, time_to):
64
  return r.json()
65
 
66
  # -----------------------------
67
- # 解析 JSON
68
  # -----------------------------
69
  def _to_float(x):
70
- """將字串(含單位)抽出第一個數字成 float;失敗回 None。"""
 
 
 
 
 
71
  if x is None:
72
  return None
73
  if isinstance(x, (int, float)):
@@ -79,6 +69,11 @@ def _to_float(x):
79
  return float(m.group()) if m else None
80
 
81
  def parse_ea0015(obj):
 
 
 
 
 
82
  records = obj.get("records") or obj.get("Records") or {}
83
  quakes = records.get("earthquake") or records.get("Earthquake") or []
84
  if not isinstance(quakes, list):
@@ -88,6 +83,8 @@ def parse_ea0015(obj):
88
  for q in quakes:
89
  ei = q.get("EarthquakeInfo") or q.get("earthquakeInfo") or {}
90
  epic = ei.get("Epicenter") or ei.get("epicenter") or {}
 
 
91
  mago = (
92
  ei.get("Magnitude") or ei.get("magnitude")
93
  or ei.get("EarthquakeMagnitude") or ei.get("earthquakeMagnitude")
@@ -99,6 +96,7 @@ def parse_ea0015(obj):
99
  or q.get("OriginTime") or q.get("originTime")
100
  )
101
 
 
102
  lat_raw = (
103
  epic.get("EpicenterLat") or epic.get("epicenterLat")
104
  or epic.get("EpicenterLatitude") or epic.get("epicenterLatitude")
@@ -110,12 +108,14 @@ def parse_ea0015(obj):
110
  or epic.get("Lon") or epic.get("lon")
111
  )
112
 
 
113
  depth_raw = (
114
  ei.get("Depth") or ei.get("depth")
115
  or ei.get("FocalDepth") or ei.get("focalDepth")
116
  or ei.get("FocalDepthKm") or ei.get("focalDepthKm")
117
  )
118
 
 
119
  mag_raw = (
120
  mago.get("MagnitudeValue") or mago.get("magnitudeValue")
121
  or mago.get("Value") or mago.get("value")
@@ -143,7 +143,7 @@ def parse_ea0015(obj):
143
  return df
144
 
145
  # -----------------------------
146
- # 視覺化
147
  # -----------------------------
148
  def _save_fig_to_tmp(fig, suffix=".png", dpi=180):
149
  outpath = tempfile.NamedTemporaryFile(delete=False, suffix=suffix).name
@@ -163,199 +163,21 @@ def plot_trend_path(df):
163
  fig.autofmt_xdate()
164
  return _save_fig_to_tmp(fig)
165
 
166
- def _auto_region_from_df(d, pad=0.5):
167
- lon_min = float(pd.to_numeric(d["Lon"], errors="coerce").min())
168
- lon_max = float(pd.to_numeric(d["Lon"], errors="coerce").max())
169
- lat_min = float(pd.to_numeric(d["Lat"], errors="coerce").min())
170
- lat_max = float(pd.to_numeric(d["Lat"], errors="coerce").max())
171
- return [lon_min - pad, lon_max + pad, lat_min - pad, lat_max + pad]
172
-
173
  def plot_map_path(df):
174
- """PyGMT(含海岸線三段式備援)→ 失敗退回 Matplotlib。"""
 
 
 
 
175
  if df.empty:
176
  return None
 
177
  d = df.dropna(subset=["Lon", "Lat"]).copy()
178
  if d.empty:
179
  return None
180
 
 
181
  d["Magnitude"] = pd.to_numeric(d["Magnitude"], errors="coerce").fillna(0).clip(lower=0)
182
- d["Depth_km"] = pd.to_numeric(d["Depth_km"], errors="coerce").fillna(0)
183
-
184
- # --- PyGMT 版 ---
185
- if HAS_PYGMT:
186
- d["Size"] = 0.06 * (d["Magnitude"] + 1.5) # 圓半徑(cm)
187
- region = _auto_region_from_df(d, pad=0.5)
188
-
189
- fig = pygmt.Figure()
190
- drew_background = False
191
- # 1) GSHHG 海岸線
192
- try:
193
- fig.coast(
194
- region=region, projection="M12c",
195
- resolution="i",
196
- land="lightgray", water="lightblue",
197
- shorelines="0.8p,black", borders="1/0.6p,black",
198
- frame=["WSen", "xaf", "yaf"]
199
- )
200
- drew_background = True
201
- except Exception:
202
- pass
203
- # 2) DCW 台灣填色
204
- if not drew_background:
205
- try:
206
- fig.coast(region=region, projection="M12c",
207
- water="lightblue", frame=["WSen", "xaf", "yaf"])
208
- fig.coast(region=region, projection="M12c", dcw="TW+glightgray")
209
- drew_background = True
210
- except Exception:
211
- pass
212
- # 3) 地形格網 +(可用則)海岸線
213
- if not drew_background:
214
- fig.grdimage("@earth_relief_04m", region=region, projection="M12c", cmap="gray")
215
- try:
216
- fig.coast(region=region, projection="M12c",
217
- shorelines="0.8p,black", frame=["WSen", "xaf", "yaf"])
218
- except Exception:
219
- fig.basemap(region=region, projection="M12c", frame=["WSen", "xaf", "yaf"])
220
-
221
- # 畫震央
222
- fig.plot(
223
- data=d, x="Lon", y="Lat",
224
- style="c", size="Size",
225
- color="Depth_km", cmap="roma", pen="0.25p,black"
226
- )
227
- fig.colorbar(frame=["x+lDepth (km)"], cmap=True, position="JMR+w7c/0.4c+o0.6c/0c")
228
- fig.basemap(map_scale="jBL+w50k+o0.6c/0.6c+f+lkm")
229
-
230
- outpath = tempfile.NamedTemporaryFile(delete=False, suffix=".png").name
231
- fig.savefig(outpath, dpi=220)
232
- return outpath
233
-
234
- # --- Matplotlib 備援(無海岸線,只畫散點) ---
235
- region = _auto_region_from_df(d, pad=0.5)
236
- lon_min, lon_max, lat_min, lat_max = region
237
-
238
- fig, ax = plt.subplots(figsize=(6, 6))
239
- ax.set_xlim(lon_min, lon_max)
240
- ax.set_ylim(lat_min, lat_max)
241
- s = (d["Magnitude"] + 2) ** 3
242
- sc = ax.scatter(d["Lon"], d["Lat"], s=s, c=d["Depth_km"], alpha=0.85, edgecolor="black")
243
- cb = plt.colorbar(sc, ax=ax, fraction=0.046, pad=0.04)
244
- cb.set_label("Depth (km)")
245
- ax.set_xlabel("Longitude (°E)")
246
- ax.set_ylabel("Latitude (°N)")
247
- ax.set_title("Epicenters (auto region)")
248
- ax.grid(True, linestyle="--", alpha=0.3)
249
- return _save_fig_to_tmp(fig)
250
-
251
- # -----------------------------
252
- # 表格輸出
253
- # -----------------------------
254
- def _format_taipei(series):
255
- try:
256
- if series.dt.tz is None:
257
- s = series.dt.tz_localize(TAIPEI_TZ)
258
- else:
259
- s = series.dt.tz_convert(TAIPEI_TZ)
260
- return s.dt.strftime("%Y-%m-%d %H:%M:%S %Z")
261
- except Exception:
262
- return series.astype(str)
263
-
264
- def _to_simple_md_table(df: pd.DataFrame) -> str:
265
- cols = list(df.columns)
266
- header = "|" + "|".join(cols) + "|\n"
267
- sep = "|" + "|".join(["---"] * len(cols)) + "|\n"
268
- rows = []
269
- for _, r in df.iterrows():
270
- cells = []
271
- for c in cols:
272
- v = r.get(c, "")
273
- cells.append("" if pd.isna(v) else str(v))
274
- rows.append("|" + "|".join(cells) + "|")
275
- return header + sep + "\n".join(rows)
276
-
277
- def df_to_markdown(df, top_n=100):
278
- if df.empty:
279
- return "(查無資料)"
280
- cols = ["OriginTime", "Magnitude", "Depth_km", "Lat", "Lon", "Location", "ReportURL"]
281
- cols = [c for c in cols if c in df.columns]
282
- slim = df[cols].head(top_n).copy()
283
- if "OriginTime" in slim.columns:
284
- slim["OriginTime"] = _format_taipei(slim["OriginTime"])
285
- header = f"共 {len(df)} 筆,顯示前 {min(len(slim), top_n)} 筆\n\n"
286
- if HAS_TABULATE:
287
- table = slim.to_markdown(index=False)
288
- else:
289
- table = _to_simple_md_table(slim.reset_index(drop=True))
290
- return header + table
291
-
292
- # -----------------------------
293
- # 主流程
294
- # -----------------------------
295
- def query_and_render(time_from, time_to, sort_order):
296
- try:
297
- raw = fetch_reports(time_from, time_to)
298
- df = parse_ea0015(raw)
299
- if df.empty:
300
- return "(查無資料)", None, None, None
301
-
302
- if sort_order == "OriginTime (舊→新)":
303
- df = df.sort_values("OriginTime", ascending=True, na_position="last").reset_index(drop=True)
304
-
305
- md = df_to_markdown(df)
306
- trend_path = plot_trend_path(df)
307
- map_path = plot_map_path(df)
308
-
309
- csv_bytes = df.to_csv(index=False).encode("utf-8-sig")
310
- csv_path = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="CWA_E-A0015-001_").name
311
- with open(csv_path, "wb") as f:
312
- f.write(csv_bytes)
313
-
314
- return md, trend_path, map_path, csv_path
315
- except Exception as e:
316
- return f"錯誤:{e}", None, None, None
317
-
318
- # -----------------------------
319
- # 介面
320
- # -----------------------------
321
- default_from, default_to = set_time_range(days=3)
322
-
323
- with gr.Blocks(fill_height=True) as demo:
324
- gr.Markdown("## CWA 顯著有感地震報告 (E-A0015-001)\n預設查詢最近 3 天(台北時間)")
325
-
326
- with gr.Column():
327
- time_from = gr.Textbox(label="timeFrom yyyy-MM-ddTHH:mm:ss", value=default_from)
328
- time_to = gr.Textbox(label="timeTo yyyy-MM-ddTHH:mm:ss", value=default_to)
329
-
330
- with gr.Row():
331
- btn_12h = gr.Button("最近 12 小時")
332
- btn_24h = gr.Button("最近 24 小時")
333
- btn_3d = gr.Button("最近 3 天")
334
- btn_5d = gr.Button("最近 5 天")
335
-
336
- sort_dd = gr.Dropdown(
337
- choices=["OriginTime (新→舊)", "OriginTime (舊→新)"],
338
- value="OriginTime (新→舊)",
339
- label="排序",
340
- )
341
-
342
- run_btn = gr.Button("查詢", variant="primary")
343
-
344
- table_out = gr.Markdown("(尚未查詢)")
345
- trend_out = gr.Image(label="趨勢圖", type="filepath")
346
- map_out = gr.Image(label="台灣範圍圖(PyGMT)", type="filepath")
347
- dl_btn = gr.DownloadButton(label="下載 CSV") # 回傳路徑即可
348
-
349
- btn_12h.click(lambda: set_time_range(hours=12), outputs=[time_from, time_to])
350
- btn_24h.click(lambda: set_time_range(hours=24), outputs=[time_from, time_to])
351
- btn_3d.click(lambda: set_time_range(days=3), outputs=[time_from, time_to])
352
- btn_5d.click(lambda: set_time_range(days=5), outputs=[time_from, time_to])
353
-
354
- run_btn.click(
355
- query_and_render,
356
- inputs=[time_from, time_to, sort_dd],
357
- outputs=[table_out, trend_out, map_out, dl_btn],
358
- )
359
 
360
- if __name__ == "__main__":
361
- demo.launch()
 
8
  import matplotlib.pyplot as plt
9
  import gradio as gr
10
 
11
+ # ---------- 可選依賴偵測(沒裝也能跑) ----------
12
  try:
13
  import tabulate as _tabulate # noqa: F401
14
  HAS_TABULATE = True
15
  except Exception:
16
  HAS_TABULATE = False
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # -----------------------------
19
  # 台北時區 (UTC+8)
20
  # -----------------------------
 
24
  return dt.strftime("%Y-%m-%dT%H:%M:%S")
25
 
26
  def set_time_range(hours=None, days=None):
27
+ """依台北時間回傳 (timeFrom, timeTo) 字串"""
28
  now = datetime.now(TAIPEI_TZ)
29
  if hours is not None:
30
  t_from = now - timedelta(hours=hours)
 
49
  return r.json()
50
 
51
  # -----------------------------
52
+ # JSON 解析(讀 EarthquakeInfo)+ 強化數字解析
53
  # -----------------------------
54
  def _to_float(x):
55
+ """
56
+ 將各種數字表達轉成 float:
57
+ - 純數字:23.5
58
+ - 含單位/文字:'23.5°N'、'121.6 E'、'25.3 公里' -> 擷取第一個浮點數
59
+ - 其他不可解析 -> None
60
+ """
61
  if x is None:
62
  return None
63
  if isinstance(x, (int, float)):
 
69
  return float(m.group()) if m else None
70
 
71
  def parse_ea0015(obj):
72
+ """
73
+ 解析 CWA E-A0015-001
74
+ 主要欄位在 records.earthquake[].EarthquakeInfo.*
75
+ 取出:OriginTime, Lat, Lon, Depth_km, Magnitude, Location, ReportURL
76
+ """
77
  records = obj.get("records") or obj.get("Records") or {}
78
  quakes = records.get("earthquake") or records.get("Earthquake") or []
79
  if not isinstance(quakes, list):
 
83
  for q in quakes:
84
  ei = q.get("EarthquakeInfo") or q.get("earthquakeInfo") or {}
85
  epic = ei.get("Epicenter") or ei.get("epicenter") or {}
86
+
87
+ # Magnitude 可能在 Magnitude 或 EarthquakeMagnitude
88
  mago = (
89
  ei.get("Magnitude") or ei.get("magnitude")
90
  or ei.get("EarthquakeMagnitude") or ei.get("earthquakeMagnitude")
 
96
  or q.get("OriginTime") or q.get("originTime")
97
  )
98
 
99
+ # 經緯度多種鍵名
100
  lat_raw = (
101
  epic.get("EpicenterLat") or epic.get("epicenterLat")
102
  or epic.get("EpicenterLatitude") or epic.get("epicenterLatitude")
 
108
  or epic.get("Lon") or epic.get("lon")
109
  )
110
 
111
+ # 深度:Depth / FocalDepth / FocalDepthKm / depth / focalDepth...
112
  depth_raw = (
113
  ei.get("Depth") or ei.get("depth")
114
  or ei.get("FocalDepth") or ei.get("focalDepth")
115
  or ei.get("FocalDepthKm") or ei.get("focalDepthKm")
116
  )
117
 
118
+ # 規模:MagnitudeValue / value / Magnitude / magnitude
119
  mag_raw = (
120
  mago.get("MagnitudeValue") or mago.get("magnitudeValue")
121
  or mago.get("Value") or mago.get("value")
 
143
  return df
144
 
145
  # -----------------------------
146
+ # 視覺化(回傳檔案路徑;相容舊/新 Gradio)
147
  # -----------------------------
148
  def _save_fig_to_tmp(fig, suffix=".png", dpi=180):
149
  outpath = tempfile.NamedTemporaryFile(delete=False, suffix=suffix).name
 
163
  fig.autofmt_xdate()
164
  return _save_fig_to_tmp(fig)
165
 
 
 
 
 
 
 
 
166
  def plot_map_path(df):
167
+ """
168
+ 最簡版地圖:純 Matplotlib 散點圖
169
+ - 自動依據資料決定範圍(加一點 padding)
170
+ - 點大小 = 規模函數;點顏色 = 深度(km)
171
+ """
172
  if df.empty:
173
  return None
174
+
175
  d = df.dropna(subset=["Lon", "Lat"]).copy()
176
  if d.empty:
177
  return None
178
 
179
+ # 數值化
180
  d["Magnitude"] = pd.to_numeric(d["Magnitude"], errors="coerce").fillna(0).clip(lower=0)
181
+ d["Depth_km"] = pd.to_numeric(d["Depth_km"], errors="coerce")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ # 自動