M13 綜合專案:天文台天氣報告
- 從開放資料 API 取得 JSON,網絡不通時改用離線副本;
- 用 YAML 設定檔控制報告,並用 JSON Schema 檢查設定;
- 把天文台的原始資料整理成 DataFrame,計算酷熱天氣日數和熱夜數目,並與天文台公布的數字對照;
- 寫出報告(JSON、CSV、圖),確認「重新啟動並全部執行」之後結果不變。
1專案:一份天氣報告 The project
M0 的第一幅圖說過資料的旅程:檔案 → Python 物件 → 在 JupyterLab 裏整理 → 寫出新的檔案。這個專案把整段旅程走一次,用的是真實的資料:香港天文台 2025 年每日的最高和最低氣溫,以及未來九天的天氣預報。
flowchart TD
C["config.yaml<br/>設定(M9、M10)"] --> F["取得資料<br/>API 或離線副本(M8)"]
F --> R["原始 JSON<br/>存檔(M7)"]
R --> T["整理成表格<br/>pandas(M11)"]
T --> A["統計、畫圖<br/>(M5、M11)"]
A --> O["報告<br/>JSON、CSV、圖(M8、M10)"]
O --> V["重新啟動並全部執行<br/>(M2)"]
classDef s fill:#ffffff,stroke:#d3d1cb,color:#37352f
classDef e fill:#f1f1ef,stroke:#d3d1cb,color:#37352f
class C,O e
class F,R,T,A,V s
完成的報告要回答幾個問題:2025 年每月的平均最高、最低氣溫是多少?有多少個酷熱天氣日和熱夜?最熱和最冷是哪一天?未來九天的預報如何?
2開放資料 API Open data APIs
API(Application Programming Interface)在這裏的意思是:一個專門給程式讀的網址。瀏覽器打開網頁,得到給人看的 HTML;程式打開 API 的網址,得到 JSON。天文台的開放資料 API 不用登記、不用密鑰:
- 九天天氣預報:
https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=fnd&lang=tc - 某年每日最高氣溫:
https://data.weather.gov.hk/weatherAPI/opendata/opendata.php?dataType=CLMMAXT&station=HKO&year=2025&rformat=json - 某年每日最低氣溫:同上,把
CLMMAXT換成CLMMINT
? 之後的 名稱=值,用 & 分隔,就是告訴 API 你要甚麼:station=HKO 是天文台總部,year=2025 是年份。
用標準函式庫的 urllib.request 就能取得,不用另外安裝套件:
import json
import urllib.request
with urllib.request.urlopen(url, timeout=10) as resp:
text = resp.read().decode("utf-8")
data = json.loads(text)
timeout=10:等十秒還沒有回應就放棄,不要讓 notebook 一直卡住。
把 station 寫錯,天文台回來的是一句英文說明,不是 JSON,json.loads 會報 Expecting value: line 1 column 1 (char 0)(M8)。看到這個錯誤,先把 text 印出來看看收到了甚麼,而不是懷疑自己的 JSON 程式。
天文台的開放資料經 DATA.GOV.HK 提供,可以免費使用和轉載,包括商業用途,但必須清楚註明資料來源。本課的報告和離線副本都寫明:資料來源:香港天文台(經 DATA.GOV.HK)。你的報告也要寫。
網絡不通怎麼辦? 課堂上的網絡不一定可靠,API 也可能改版。所以專案先試 API,失敗就改用課程提供的離線副本(2026 年 10 月 4 日下載的同一批資料)。兩條路得到的資料格式完全相同,後面的程式不用改。離線副本只有 2025 年;年份改了又連不上網,程式會清楚地說「離線副本只有 2025 年的資料」,而不是報一個看不懂的錯誤。
3先寫設定 Configuration first
報告用哪個氣象站、哪一年、甚麼溫度算「酷熱」,不要寫死在程式裏,而是放在一份設定檔:
# config.yaml:天氣報告的設定
station: "HKO" # 天文台總部。加引號:氣象站代碼不是用來計算的(M9)
year: 2025
very_hot_day: 33.0 # 日最高氣溫 ≥ 33.0 °C:酷熱天氣日(天文台的定義)
hot_night: 28.0 # 日最低氣溫 ≥ 28.0 °C:熱夜
output_dir: "report"
offline: false # true:不連網,直接用離線副本(課堂網絡不穩時用)
讀入之後,先用 JSON Schema(M10)檢查一次,再開始做事。改設定檔是最常出錯的地方(M12),一開始就擋下,比跑到一半才出錯好。
4讀進來、整理 Loading and cleaning
天文台的每日氣溫是這樣的 JSON:
{
"type": ["\ufeff日最高氣溫(攝氏度) - 天文台", "Daily Maximum Temperature (°C) at the Hong Kong Observatory"],
"fields": ["年/Year", "月/Month", "日/Day", "數值/Value", "數據完整性/data Completeness"],
"data": [["2025", "1", "1", "19.1", "C"], ["2025", "1", "2", "22.0", "C"], …]
}
這段真實的資料,用得上好幾個單元的知識:
- 標題開頭有一個 BOM(
\ufeff,M10)。這次它在字串裏面,不在檔案開頭;要比較標題時記得處理; - 每個值都是字串,連氣溫
"19.1"也是(M3)。要轉成數字才能計算; - 年、月、日分成三欄,要合成一個日期(M11 的
pd.to_datetime); - 最後一欄是數據完整性,
"C"表示完整。2025 年全部完整;但只取完整的資料,是處理別人資料時的好習慣。
df = pd.DataFrame(raw["data"], columns=["year", "month", "day", "max_temp", "complete"])
df = df[df["complete"] == "C"].copy() # .copy():明確要一張獨立的新表(M4)
df["max_temp"] = df["max_temp"].astype(float)
df["date"] = pd.to_datetime(df[["year", "month", "day"]].astype(int))
最高氣溫和最低氣溫是兩份 JSON,各整理成一張表後,按日期 merge(M11)成一張。
5分析 Analysis
very_hot = (df["max_temp"] >= cfg["very_hot_day"]).sum() # 酷熱天氣日數
hot_nights = (df["min_temp"] >= cfg["hot_night"]).sum() # 熱夜數目
hottest = df.loc[df["max_temp"].idxmax()] # 最熱的一天
idxmax() 找出最大值所在的那一行的索引,.loc 再把整行取出。按月統計則是 M11 的 groupby:

自己算出來的結果,最好找一個可靠的來源對照。天文台的《2025年天氣概況》公布:2025 年酷熱天氣日數 53 天、熱夜 54 天,全年最低氣溫是 1 月 27 日的 10.6 度。用上面的程式算出來,三個數字完全相同。對得上,才可以放心寫進報告;對不上,就要回頭找原因(定義不同?漏了資料?)。
6九天預報 The nine-day forecast
預報的 JSON 是巢狀的:每一天是一個 dict,氣溫又是一個 {"value": 31, "unit": "C"} 的 dict。M11 的 json_normalize 一行攤平:
fc = pd.json_normalize(forecast["weatherForecast"])
fc["date"] = pd.to_datetime(fc["forecastDate"], format="%Y%m%d") # "20261004" → 日期
fc[["date", "week", "forecastMintemp.value", "forecastMaxtemp.value", "forecastWeather"]]
巢狀的 forecastMaxtemp 變成了 forecastMaxtemp.value 和 forecastMaxtemp.unit 兩欄。日期是 "20261004" 這種沒有分隔符的字串,用 format="%Y%m%d" 告訴 pandas 怎樣讀。
7輸出與重現 Output and reproducibility
報告寫到 report 資料夾(M7):
| 檔案 | 內容 | 格式要點 |
|---|---|---|
summary.json |
全年摘要:酷熱天氣日數、熱夜、最熱最冷的一天 | ensure_ascii=False、indent=2(M8),寫出前用 schema 檢查(M10) |
monthly.csv |
每月平均氣溫和酷熱日數 | utf-8-sig,Excel 開得到(M10)。CSV 沒有地方寫註解,交給別人時在附帶的說明裏註明資料來源 |
monthly.png、forecast.png |
兩張圖 | 中文字型(M11) |
config-used.yaml |
這次報告用的設定 | safe_dump(allow_unicode=True)(M9) |
最後一步最重要:選 Kernel → Restart Kernel and Run All Cells(M2),確認由頭到尾一次跑通、結果不變。這份報告連同 pyproject.toml、uv.lock(M1)交給任何人,他們 uv sync 之後重新執行,就得到同樣的結果。再把它放進 Git(git-course),就有了完整的歷史。
8回顧:每個單元在哪裏 Recap
| 單元 | 在這個專案裏 |
|---|---|
| M0 資料的一生 | 整個專案就是那幅旅程圖 |
| M1 環境 | uv 專案、pyproject.toml 和 uv.lock,讓別人能重現 |
| M2 JupyterLab | 報告是一個 notebook;最後重新啟動並全部執行 |
| M3 值與型別 | 氣溫是字串,要轉成 float |
| M4 容器 | API 回來的 dict 和 list,沿着路徑取值 |
| M5 流程控制 | if not offline:設定要離線,就不試 API |
| M6 函式與模組 | load_daily()、fetch_json() 等函式 |
| M7 檔案與路徑 | report 資料夾、離線副本、encoding="utf-8" |
| M8 JSON | API 的回應、summary.json;try/except:試 API,失敗就用離線副本 |
| M9 YAML | config.yaml,氣象站代碼加引號 |
| M10 選擇格式 | 設定檔的 schema、monthly.csv 用 utf-8-sig |
| M11 表格資料 | json_normalize、merge、groupby、畫圖 |
| M12 設定檔的世界 | 先檢查設定,再開始做事 |
9動手:你的天氣報告 Hands-on: your weather report
- 下載
m13-weather-report.ipynb、config.yaml和m13-hko-snapshot.zip(離線副本,不用解壓),放進python-course資料夾; - 確認已經
uv add pandas matplotlib jsonschema; - 執行
uv run jupyter lab,打開 notebook,逐格執行,最後重新啟動並全部執行一次。
完成基本報告之後,試試改 config.yaml:換一個年份(2024?),或者把酷熱的門檻改成 35 °C(天文台稱為「極端酷熱」),重新執行,看報告怎樣變。改年份會令程式去 API 拿新的資料;離線副本只有 2025 年。
m13-script-eng.ipynb 和 report.py:把 notebook 的流程變成一個可以在終端機執行的腳本(uv run python report.py)。它用 Path(__file__).parent(M7)找到自己的設定檔和離線副本,可以每天由排程自動執行。它的圖存成 monthly-script.png,不會蓋掉 notebook 畫的 monthly.png。
這是本課最後一個單元。你已經走完了資料的整段旅程:從一個 JSON 檔開始,到一份可以重現、可以交給別人的報告。之後遇到新的格式、新的工具,問自己三個問題:它在檔案裏長甚麼樣?讀進 Python 變成甚麼?要寫出去給誰?