顯示具有 智慧養殖 標籤的文章。 顯示所有文章
顯示具有 智慧養殖 標籤的文章。 顯示所有文章

2026年9月4日 星期五

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

當我們使用手機看影片、上傳照片、網路購物或遠端控制家電時,背後都離不開「雲端網路」。 雲端網路不只是把資料放到遠端伺服器,更是透過網路把人、設備、資料、服務與人工智慧連結起來, 讓系統能夠即時互動、彈性擴充與集中管理。

一、什麼是雲端網路?

雲端網路是指透過網際網路,使用遠端資料中心提供的運算、儲存、資料庫、應用程式及網路服務。 使用者不必自己購買大量伺服器或網路設備,只要透過電腦、手機、感測器或機器人連上網路, 就能使用雲端上的資源。

一句話理解:
雲端網路就是把「運算、儲存、網路與智慧服務」放到雲端,讓各地的使用者與設備可以隨時連線使用。

二、雲端網路的核心概念

  1. 虛擬化(Virtualization)
    將一台實體伺服器、硬碟或網路設備,切分成多個可獨立使用的虛擬資源,例如虛擬機器、虛擬硬碟與虛擬網路。
  2. 軟體定義網路(SDN, Software Defined Networking)
    將網路設備的管理與決策集中到軟體控制器。管理者可以用軟體調整資料傳輸路徑、頻寬與安全規則, 讓網路管理更有彈性。
  3. 網路功能虛擬化(NFV, Network Function Virtualization)
    原本要使用專用硬體才能完成的路由器、防火牆、負載平衡器等功能,改以軟體方式在一般伺服器上執行, 可降低成本,也更容易擴充。
  4. 基礎設施即代碼(IaC, Infrastructure as Code)
    用程式或設定檔建立伺服器、資料庫、網路與防火牆規則。這樣可以重複部署、保留版本紀錄, 並減少人工設定造成的錯誤。
  5. 彈性擴充與負載平衡
    當使用者增加時,雲端可以自動增加伺服器資源;當流量下降時,再減少資源以節省成本。 負載平衡則把大量使用者的請求分配到多台伺服器。
  6. 資安、身分驗證與資料保護
    雲端系統會利用帳號權限、防火牆、加密、VPN、網路分區與備份機制, 保護資料與設備,避免未授權使用者存取。

三、雲端網路在生活與產業上的應用

領域 應用例子 雲端網路的功能
智慧家庭 遠端控制冷氣、攝影機、門鎖與掃地機器人 讓家電與手機透過雲端交換資料與控制指令。
串流影音 YouTube、Netflix、線上直播與雲端遊戲 利用雲端伺服器、CDN 與負載平衡,提供大量使用者穩定服務。
線上學習 Google Classroom、Moodle、視訊課程 集中提供教材、作業、測驗、影片與學習紀錄。
電子商務 網路商店、行動支付、物流追蹤 整合商品、訂單、庫存、金流與配送資訊。
智慧製造 機台監控、品質檢測、預測維護 收集設備資料,透過雲端 AI 分析異常與預估故障。
智慧農漁業 水質、土壤、氣象、影像與設備控制 讓感測器即時上傳資料,並由手機或電腦遠端監控。

四、三個智慧生活與地方創生應用案例

案例一:水井村智慧養殖

在養殖現場,可利用溶氧、水溫、pH 值等感測器蒐集魚塭資料,再透過 HUB8735、 Wi-Fi、4G/5G 或 LoRa 網路傳送到雲端平台。養殖戶只要打開手機,就能即時查看水質狀況。

當溶氧過低或水溫異常時,雲端平台可以自動發出告警通知,並進一步連動打氣機或抽水設備。 若累積足夠資料,還能運用 AI 分析水質變化,協助養殖戶及早處理問題。

核心價值:讓養殖管理從「靠經驗巡池」走向「即時監測、遠端管理與智慧決策」。

案例二:水井三寶智慧互動導覽平台

水井村可將烏龜、白馬與姻緣花等地方文化故事,建置成雲端智慧導覽平台。 遊客掃描 QR Code、使用手機網頁或操作互動螢幕後,即可取得故事、照片、語音、影片與地圖資訊。

平台也可結合 AI 導覽 Agent。當遊客詢問「姻緣樹有多久歷史?」、 「下一個景點怎麼走?」或「水井三寶代表什麼意義?」時, AI Agent 可根據地方知識庫即時回答,成為不受時間限制的數位導覽員。

核心價值:讓地方文化能被數位保存、持續更新,並以互動方式傳遞給更多遊客與下一代。

案例三:Q-Robot AI 結合 AI Agent

Q-Robot 可結合麥克風、喇叭、螢幕、按鈕、燈光與感測器,成為能與人互動的智慧機器人。 當孩童、長者或遊客向 Q-Robot 說話或按下按鈕時,Q-Robot 可透過雲端網路連接 AI Agent 與知識庫, 理解問題後,再以語音、文字、燈光或動作回應。

例如,Q-Robot 可以扮演下列角色:

  • 地方文化小導遊:介紹水井三寶與村庄故事。
  • 兒童學習夥伴:帶領孩子完成程式設計、環保、食農與 AI 體驗任務。
  • 長者陪伴助手:以較慢的語速聊天、播放懷舊故事,提醒社區活動。
  • 活動任務引導員:依照參與者的回答與活動進度,安排下一個互動關卡。
核心價值:讓機器人不只是播放指令,而是能連接地方知識、理解問題並提供個人化互動的 AI 夥伴。

五、三個案例的比較

案例 主要對象 雲端網路的角色 AI 的角色
水井村智慧養殖 養殖戶 傳送感測資料、遠端控制設備 分析水質與異常預警
水井三寶智慧互動導覽 遊客、社區居民 提供故事、地圖、影音與使用紀錄 回答地方文化與導覽問題
Q-Robot AI Agent 兒童、長者、學習者 連接機器人、知識庫與後台服務 理解提問、規劃任務、產生個人化回應

六、結語

雲端網路的價值,不只是把資料存放在遠端,而是把現場設備、使用者、資料平台與 AI 智慧服務連接起來。 從水井村智慧養殖的水質監測,到水井三寶的文化導覽,再到 Q-Robot 的 AI 互動陪伴, 都說明了雲端網路可以成為地方創生、智慧教育與永續生活的重要基礎。

善用雲端網路,讓地方的故事、產業與學習,連結成更智慧、更永續的未來。

2026年8月31日 星期一

Python一下:用NumPy讀懂智慧養殖感測資料的數學基礎

《Python一下:從風土資料到智慧生活》第 31 篇

用NumPy讀懂智慧養殖感測資料的數學基礎

把一串水溫、pH與溶氧讀值,轉成可計算、可比較、也知道限制的資料判讀。

CH14 數學相關NumPy智慧養殖
學習目標
完成本篇後,你能建立與檢查NumPy陣列、使用索引與布林遮罩、理解向量化與broadcasting、沿正確axis計算統計量、處理NaN、計算移動平均與變化率、建立異常候選遮罩,並對相關係數與標準化結果作負責任解讀。

一、為什麼智慧養殖需要NumPy?

Python串列可以保存數值,但NumPy的 ndarray 能以一致資料型別組成一維或多維陣列,並進行向量化運算。它是pandas、SciPy、影像處理與許多機器學習工具的數值基礎。

資料問題NumPy工具仍需人工確認
每小時水溫的平均與波動mean、std、min、max探頭是否校正、取樣是否完整
設備離線造成缺值NaN與nan系列函式缺值原因與補傳紀錄
短期趨勢太雜亂移動平均、diff平滑是否掩蓋突發事件
某筆數值特別不同IQR、z-score候選設備錯誤還是真實場域變化
本篇資料為教學示範:數值與門檻不代表特定魚種、蝦種、池型或季節的正式養殖標準。任何巡查或控制策略,都應由場域養殖者與專業人員共同確認。

二、安裝與確認NumPy

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "NumPy"],
    check=True,
)

請在虛擬環境安裝,並記錄課程驗證版本。程式匯入時慣例使用 import numpy as np。

三、第一個水溫陣列

import numpy as np


temperature = np.array(
    [27.2, 27.5, 28.0, 28.4, 29.1, 29.4],
    dtype=np.float64,
)

print("資料:", temperature)
print("形狀:", temperature.shape)
print("維度:", temperature.ndim)
print("筆數:", temperature.size)
print("型別:", temperature.dtype)

shape=(6,)代表包含6個元素的一維陣列。感測資料通常使用浮點型別;若需要缺值NaN,也必須使用可表示NaN的型別。

四、索引與切片:查看特定時段

print("第一筆:", temperature[0])
print("最後一筆:", temperature[-1])
print("第2到第4筆:", temperature[1:4])

morning = temperature[:3].copy()
morning[0] = 99.0

print("複製後修改:", morning)
print("原始資料不變:", temperature)

基本切片常得到原陣列的view,修改時可能影響原資料。當你需要獨立資料時明確使用 .copy()。

五、向量化:一次換算全部感測值

temperature_f = temperature * 9 / 5 + 32
change_from_first = temperature - temperature[0]

print("華氏:", np.round(temperature_f, 1))
print("相對第一筆變化:", change_from_first)

不必逐筆寫for迴圈,算術運算會套用到每個元素。這種向量化表達通常更簡潔,也能利用NumPy底層實作。

六、Broadcasting:每個感測器套用不同校正值

raw = np.array([
    [27.2, 7.3, 5.8],
    [27.5, 7.4, 5.6],
    [28.0, 7.2, 5.4],
    [28.4, 7.1, 5.2],
])

# 三欄依序是水溫、pH、溶氧
offset = np.array([0.2, -0.1, 0.0])
calibrated = raw + offset

print(calibrated)

形狀為 (4,3) 的資料可和形狀 (3,) 的校正值broadcasting:每一列都套用三個欄位校正。若形狀無法相容,NumPy會報錯;不能靠猜測哪個方向被套用。

七、理解axis:沿時間還是沿指標?

column_mean = calibrated.mean(axis=0)
row_mean = calibrated.mean(axis=1)

print("各指標跨時間平均:", column_mean)
print("每個時間點跨欄平均:", row_mean)

axis=0壓縮列,得到每一欄跨時間的平均;axis=1壓縮欄。但水溫、pH、溶氧單位不同,直接算跨欄平均通常沒有合理物理意義。程式算得出來,不代表應該這樣算。

八、基本統計:中心與離散程度

summary = {
    "count": temperature.size,
    "mean": float(np.mean(temperature)),
    "median": float(np.median(temperature)),
    "minimum": float(np.min(temperature)),
    "maximum": float(np.max(temperature)),
    "std_population": float(np.std(temperature, ddof=0)),
    "std_sample": float(np.std(temperature, ddof=1)),
}

for name, value in summary.items():
    print(name, value)

ddof=0計算這批資料本身的母體標準差;ddof=1常用於由樣本估計母體變異。選哪一個取決於問題,不是固定答案。

九、NaN:未知不等於0

temperature_with_missing = np.array(
    [27.2, 27.5, np.nan, 28.4, 29.1, 29.4],
    dtype=float,
)

missing_mask = np.isnan(temperature_with_missing)
valid_count = np.count_nonzero(~missing_mask)

print("缺值位置:", missing_mask)
print("有效筆數:", valid_count)
print("忽略NaN平均:", np.nanmean(temperature_with_missing))
print("忽略NaN標準差:", np.nanstd(
    temperature_with_missing, ddof=1
))

np.mean()遇到NaN通常會得到NaN;np.nanmean()忽略NaN,但不能假裝缺值不存在。報告平均時要同時呈現有效筆數、缺值率與時間範圍。

十、資料值與品質標記分開

values = np.array([27.2, 27.5, 99.0, np.nan, 28.4])
quality = np.array([
    "valid", "valid", "invalid", "unknown", "valid"
])

usable_mask = (quality == "valid") & np.isfinite(values)
usable_values = values[usable_mask]

print("可用遮罩:", usable_mask)
print("可納入一般統計:", usable_values)
print("平均:", usable_values.mean())

invalid保留原始數值供追查,但不納入一般統計;unknown通常搭配NaN。只靠NaN無法表達「為何不可用」,品質標記仍要保留。

十一、布林遮罩:找出教材門檻外讀值

low, high = 26.0, 30.0
valid = np.isfinite(temperature_with_missing)
outside = valid & (
    (temperature_with_missing < low)
    | (temperature_with_missing > high)
)

print("門檻外位置:", np.flatnonzero(outside))
print("門檻外數值:", temperature_with_missing[outside])

26~30°C只是程式示範。門檻必須附上物種、成長階段、場域、季節、資料來源、核定者與版本日期,不能把網路上找到的一個數字直接寫進控制系統。

十二、差分:觀察相鄰時間的變化

change = np.diff(temperature)
rapid_change = np.abs(change) >= 0.6

print("相鄰變化:", change)
print("快速變化區段:", np.flatnonzero(rapid_change))

np.diff()的結果比原陣列少一筆,第0個差分代表原始第0筆到第1筆。若取樣間隔不固定,還要除以實際時間差,不能直接比較。

十三、移動平均:平滑短期波動

from numpy.lib.stride_tricks import sliding_window_view


def moving_average(values, window):
    values = np.asarray(values, dtype=float)
    if window < 1 or window > values.size:
        raise ValueError("window超出資料範圍")
    windows = sliding_window_view(values, window_shape=window)
    return np.nanmean(windows, axis=-1)


smoothed = moving_average(temperature_with_missing, 3)
print(smoothed)

3筆移動平均輸出會少2筆,時間應對齊視窗尾端或中心並清楚說明。平滑可以看趨勢,也可能遮住短暫但重要的急變;原始資料不可被覆蓋。

十四、IQR:找統計上的異常候選

candidate_data = np.array(
    [27.1, 27.2, 27.3, 27.4, 27.5, 31.8],
    dtype=float,
)

q1, q3 = np.quantile(candidate_data, [0.25, 0.75])
iqr = q3 - q1
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr
candidate_mask = (
    (candidate_data < lower) | (candidate_data > upper)
)

print("IQR範圍:", lower, upper)
print("異常候選:", candidate_data[candidate_mask])

IQR規則只是探索方法。資料有日夜週期、季節變化、餵食或換水事件時,整批混算可能把正常情境標成異常。

十五、z-score:先檢查樣本量與標準差

def z_scores(values):
    values = np.asarray(values, dtype=float)
    valid = values[np.isfinite(values)]
    if valid.size < 3:
        raise ValueError("有效資料不足")
    mean = valid.mean()
    std = valid.std(ddof=1)
    if np.isclose(std, 0.0):
        raise ValueError("標準差接近0,無法計算z-score")
    return (values - mean) / std


scores = z_scores(candidate_data)
print(np.round(scores, 2))

z-score適合近似穩定分布的探索,不應機械式把 |z|>3當成通用場域告警。樣本小、分布偏斜或有時間趨勢時尤其要小心。

十六、標準化:不同量尺可比較,但單位意義消失

sensor_matrix = np.array([
    [27.2, 7.3, 5.8],
    [27.5, 7.4, 5.6],
    [28.0, 7.2, 5.4],
    [28.4, 7.1, 5.2],
])

mean = sensor_matrix.mean(axis=0, keepdims=True)
std = sensor_matrix.std(axis=0, ddof=1, keepdims=True)
if np.any(np.isclose(std, 0.0)):
    raise ValueError("至少一欄沒有變化,無法標準化")

standardized = (sensor_matrix - mean) / std
print(np.round(standardized, 2))

keepdims=True保留形狀為 (1,3),可安全broadcast回原矩陣。標準化結果沒有°C、pH或mg/L的原單位,不能拿來直接對照養殖門檻。

十七、相關係數:相關不代表因果

temperature_pair = np.array(
    [27.2, 27.5, 28.0, 28.4, 29.1, np.nan]
)
dissolved_oxygen = np.array(
    [5.8, 5.6, 5.4, 5.2, 4.9, 5.0]
)

pair_mask = (
    np.isfinite(temperature_pair)
    & np.isfinite(dissolved_oxygen)
)
if np.count_nonzero(pair_mask) < 3:
    raise ValueError("成對有效資料不足")

correlation = np.corrcoef(
    temperature_pair[pair_mask],
    dissolved_oxygen[pair_mask],
)[0, 1]
print("Pearson相關係數:", correlation)

高度正相關或負相關都不證明因果。共同時間趨勢、日夜循環、曝氣、餵食與感測器位置都可能影響結果;要回到實驗設計及場域紀錄。

十八、綜合函式:產生可追溯摘要

def sensor_summary(values, quality):
    values = np.asarray(values, dtype=float)
    quality = np.asarray(quality)
    if values.shape != quality.shape:
        raise ValueError("values與quality形狀不同")

    usable = (quality == "valid") & np.isfinite(values)
    selected = values[usable]
    if selected.size == 0:
        return {
            "total": int(values.size),
            "valid": 0,
            "missing_or_invalid": int(values.size),
            "mean": None,
            "minimum": None,
            "maximum": None,
        }

    return {
        "total": int(values.size),
        "valid": int(selected.size),
        "missing_or_invalid": int(values.size - selected.size),
        "mean": float(selected.mean()),
        "minimum": float(selected.min()),
        "maximum": float(selected.max()),
    }


print(sensor_summary(values, quality))

轉成Python的int與float,較方便輸出JSON。摘要仍應附感測器代碼、單位、起訖時間、取樣間隔、品質規則版本及產生時間。

十九、不要犯的五個錯誤

錯誤後果改善
把缺值補成0平均與告警失真保留NaN及quality
混合不同單位直接平均得到沒有物理意義的數字依欄位與單位分別統計
忘記axis方向跨時間與跨指標算反每步印shape並用小資料驗證
平滑後覆蓋原始值失去稽核與急變資訊原始值與衍生值分開保存
把統計候選當成場域異常錯誤巡查或控制結合校正、事件及專業判讀

二十、課堂挑戰

挑戰A|基礎:建立24筆每小時水溫資料,計算平均、中位數、最大值、最小值、有效筆數與缺值率。
挑戰B|進階:比較3筆與5筆移動平均,標示輸出對應的時間點,說明哪一種保留急變較多。
挑戰C|USR場域:與養殖者共同挑選一次真實事件,將感測值、品質標記、設備校正、天候與人工巡查紀錄排在同一時間軸,解釋統計方法在哪裡可能誤判。

二十一、用AI協助檢查數學,而不是代替場域判斷

請擔任NumPy與智慧養殖資料分析助教。
資料包含水溫、pH、溶氧、時間與quality標記。
請先確認每個陣列的shape、dtype、axis、單位、缺值與取樣間隔,
再檢查平均、標準差、移動平均、IQR與相關係數程式。
不得把NaN自動補成0,也不得把統計異常直接稱為養殖異常。
請為每個結果列出:數學意義、資料前提、場域限制、
最小測試資料,以及需要養殖者確認的問題。

二十二、延伸閱讀

Python一下:用SQLAlchemy與PyMySQL重構智慧養殖資料存取層

《Python一下:從風土資料到智慧生活》第 28 篇

用SQLAlchemy與PyMySQL重構智慧養殖資料存取層

讓資料表成為Python物件,讓交易有清楚邊界,也讓SQLite與MySQL共用大部分程式。

CH13 關聯式資料庫SQLAlchemy 2.xORM
學習目標
完成本篇後,你能說明Engine、Model、Session與Repository的責任;使用SQLAlchemy 2.x宣告關聯模型;以ORM新增、查詢及更新資料;正確控制交易與連線池;並以SQLite進行快速測試、以PyMySQL連接MySQL。

一、為何要增加SQLAlchemy這一層?

直接使用PyMySQL完全可行,但當SQL散落在API、排程、分析腳本與管理頁面中,欄名修改、交易處理及測試會逐漸困難。SQLAlchemy提供一致的資料存取介面;ORM則把資料表的列映射成Python物件。

元件主要責任水井村例子
Engine管理資料庫方言、連線與連線池MySQL正式環境、SQLite課堂測試
Model描述資料表、欄位與關聯Site、Sensor、Reading
Session追蹤物件及管理一個工作單元新增一次巡查紀錄並提交
Repository集中常用資料存取規則查最新水溫、建立場域
ORM不是不用學SQL:SQLAlchemy最後仍會產生SQL。若不了解JOIN、索引、NULL與交易,就可能寫出「看起來很Python、實際查詢很慢」的程式。

二、安裝SQLAlchemy與PyMySQL

import subprocess
import sys


subprocess.run(
    [
        sys.executable, "-m", "pip", "install",
        "SQLAlchemy>=2.0,<3.0", "PyMySQL>=1.1,<2.0",
    ],
    check=True,
)

實際專案應使用虛擬環境並鎖定經測試的版本。套件升級前先跑測試,不在正式環境臨時更新。

三、建立資料庫網址,但不把密碼寫進程式

import os
from sqlalchemy import URL


def mysql_url_from_environment():
    password = os.getenv("SHUIJING_DB_PASSWORD", "")
    if not password:
        raise RuntimeError("尚未設定SHUIJING_DB_PASSWORD")

    return URL.create(
        drivername="mysql+pymysql",
        username=os.getenv("SHUIJING_DB_USER", "shuijing_app"),
        password=password,
        host=os.getenv("SHUIJING_DB_HOST", "127.0.0.1"),
        port=int(os.getenv("SHUIJING_DB_PORT", "3306")),
        database=os.getenv("SHUIJING_DB_NAME", "shuijing_demo"),
        query={"charset": "utf8mb4"},
    )

URL.create()會處理密碼中的特殊字元。不要print完整URL,因為它可能包含憑證;Colab請使用Secrets,GitHub則使用平台的Secret設定。

四、建立Engine與連線池

from sqlalchemy import create_engine


engine = create_engine(
    mysql_url_from_environment(),
    pool_pre_ping=True,
    pool_recycle=1800,
    pool_size=5,
    max_overflow=5,
    pool_timeout=10,
    echo=False,
)

print(engine.url.render_as_string(hide_password=True))

pool_pre_ping可在借出連線前檢查是否仍有效;連線池大小必須配合MySQL上限、網站程序數與實際流量計算,不可每個程序都任意設很大。

五、先宣告共同Model基底

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass

本篇採SQLAlchemy 2.x的型別化宣告方式。每個Model都繼承Base,後續可由metadata取得所有資料表描述。

六、建立Site、Sensor與Reading模型

from __future__ import annotations

from datetime import datetime
from typing import Optional

from sqlalchemy import (
    BigInteger, Boolean, DateTime, Float, ForeignKey,
    Index, Integer, String, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship


ID_TYPE = BigInteger().with_variant(Integer, "sqlite")


class Site(Base):
    __tablename__ = "sites"

    site_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    site_code: Mapped[str] = mapped_column(
        String(30), unique=True, nullable=False
    )
    display_name: Mapped[str] = mapped_column(String(100))
    active: Mapped[bool] = mapped_column(Boolean, default=True)
    sensors: Mapped[list[Sensor]] = relationship(
        back_populates="site"
    )


class Sensor(Base):
    __tablename__ = "sensors"

    sensor_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    sensor_code: Mapped[str] = mapped_column(
        String(50), unique=True, nullable=False
    )
    site_id: Mapped[int] = mapped_column(
        ForeignKey("sites.site_id", ondelete="RESTRICT")
    )
    kind: Mapped[str] = mapped_column(String(30))
    unit: Mapped[str] = mapped_column(String(20))
    site: Mapped[Site] = relationship(back_populates="sensors")
    readings: Mapped[list[Reading]] = relationship(
        back_populates="sensor"
    )


class Reading(Base):
    __tablename__ = "readings"
    __table_args__ = (
        UniqueConstraint(
            "sensor_id", "observed_at",
            name="uq_sensor_time",
        ),
        Index("idx_readings_time", "observed_at"),
    )

    reading_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    sensor_id: Mapped[int] = mapped_column(
        ForeignKey("sensors.sensor_id", ondelete="RESTRICT")
    )
    observed_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False)
    )
    value: Mapped[Optional[float]] = mapped_column(Float)
    quality: Mapped[str] = mapped_column(String(10))
    note: Mapped[str] = mapped_column(String(255), default="")
    sensor: Mapped[Sensor] = relationship(
        back_populates="readings"
    )

ID_TYPE讓MySQL使用BIGINT,SQLite測試則使用可正確連結ROWID自動編號的INTEGER。Python型別提示協助編輯器及靜態檢查,但不會自動完成所有資料驗證;quality允許值、時間規則及數值合理性仍應在應用層和資料庫層共同約束。

七、建立資料表:教材可以,正式系統要遷移

Base.metadata.create_all(engine)

create_all()適合初學、測試及全新資料庫,但不會安全地替既有資料表改欄位。正式系統應使用Alembic建立可審查、可追蹤、可回復的結構遷移版本。

八、用Session新增一座場域

from sqlalchemy.orm import Session


with Session(engine) as session:
    with session.begin():
        site = Site(
            site_code="SITE-01",
            display_name="示範池A",
        )
        session.add(site)

print("場域新增完成")

session.begin()區塊正常結束時提交,發生例外時回滾。離開Session後不要任意依賴尚未載入的關聯資料。

九、一次建立場域、感測器與讀值

from datetime import datetime


with Session(engine) as session:
    with session.begin():
        site = Site(
            site_code="SITE-02",
            display_name="示範池B",
        )
        sensor = Sensor(
            sensor_code="TEMP-02",
            kind="temperature",
            unit="°C",
        )
        sensor.readings.append(
            Reading(
                observed_at=datetime(2026, 8, 31, 9, 0),
                value=29.1,
                quality="valid",
            )
        )
        site.sensors.append(sensor)
        session.add(site)

ORM會依關聯順序寫入三張表。教材時間為示例;正式專案應統一以UTC或明確規則寫入,且不可把無時區時間與有時區時間混用。

十、用select()查詢,而不是舊式Query

from sqlalchemy import select


statement = (
    select(Site)
    .where(Site.active.is_(True))
    .order_by(Site.site_code)
)

with Session(engine) as session:
    sites = session.scalars(statement).all()
    for site in sites:
        print(site.site_code, site.display_name)

select(Site)回傳Site物件;如果選擇個別欄位,結果形態會不同。先確認自己需要物件、欄位列,還是單一純量。

十一、JOIN查詢最新有效水溫

statement = (
    select(
        Site.site_code,
        Site.display_name,
        Sensor.sensor_code,
        Reading.observed_at,
        Reading.value,
        Sensor.unit,
    )
    .join(Site.sensors)
    .join(Sensor.readings)
    .where(
        Site.site_code == "SITE-02",
        Sensor.kind == "temperature",
        Reading.quality == "valid",
    )
    .order_by(
        Reading.observed_at.desc(),
        Reading.reading_id.desc(),
    )
    .limit(1)
)

with Session(engine) as session:
    latest = session.execute(statement).mappings().first()
    print(dict(latest) if latest else "尚無有效資料")

SQLAlchemy會把比較值轉成參數,而不是直接拼進SQL。若需要動態欄位或排序,仍必須使用程式內允許清單。

十二、彙總各場域的有效資料

from sqlalchemy import and_, func


statement = (
    select(
        Site.site_code,
        func.count(Reading.reading_id).label("valid_count"),
        func.round(func.avg(Reading.value), 2).label("average"),
    )
    .outerjoin(
        Sensor,
        and_(
            Sensor.site_id == Site.site_id,
            Sensor.kind == "temperature",
        ),
    )
    .outerjoin(
        Reading,
        and_(
            Reading.sensor_id == Sensor.sensor_id,
            Reading.quality == "valid",
        ),
    )
    .group_by(Site.site_id, Site.site_code)
    .order_by(Site.site_code)
)

with Session(engine) as session:
    for row in session.execute(statement).mappings():
        print(dict(row))

把有效資料條件放在LEFT JOIN的ON子句,才能保留沒有有效資料的場域。若放到WHERE,查詢可能悄悄變成只剩有資料的場域。

十三、避免N+1查詢

逐一讀取每座場域的sensors,可能額外送出很多SQL。需要一起使用關聯時,可明確預載:

from sqlalchemy.orm import selectinload


statement = (
    select(Site)
    .options(selectinload(Site.sensors))
    .order_by(Site.site_code)
)

with Session(engine) as session:
    sites = session.scalars(statement).all()
    for site in sites:
        print(site.site_code, len(site.sensors))

selectinload通常以第二個IN查詢載入集合,避免每座場域各查一次。不要為了省查詢而預載所有歷史讀值,資料量可能非常大。

十四、建立輸入驗證函式

from datetime import datetime


def build_reading(observed_at, value, quality, note=""):
    if quality not in {"valid", "unknown", "invalid"}:
        raise ValueError("不支援的資料品質")
    if not isinstance(observed_at, datetime):
        raise TypeError("observed_at必須是datetime")
    if quality == "unknown":
        value = None
    elif value is None:
        raise ValueError("valid或invalid必須保留原始值")
    else:
        value = float(value)

    return Reading(
        observed_at=observed_at,
        value=value,
        quality=quality,
        note=str(note)[:255],
    )
資料品質先於圖表:unknown表示沒有可信數值,invalid表示保留原始值但不宜納入一般統計。不要把兩者都轉成0,否則平均值與告警會被扭曲。

十五、Repository:集中常用存取規則

class SiteRepository:
    def __init__(self, session):
        self.session = session

    def get_by_code(self, site_code):
        statement = select(Site).where(
            Site.site_code == site_code
        )
        return self.session.scalar(statement)

    def add(self, site_code, display_name):
        if self.get_by_code(site_code) is not None:
            raise ValueError("場域代碼已存在")
        site = Site(
            site_code=site_code,
            display_name=display_name,
        )
        self.session.add(site)
        return site


with Session(engine) as session:
    with session.begin():
        repository = SiteRepository(session)
        repository.add("SITE-03", "示範池C")

Repository不應偷偷commit;由外層服務決定交易邊界,才能把多個Repository操作包在同一交易中。

十六、服務層:把場域規則與資料存取分開

def register_sensor(session, site_code, sensor_code, kind, unit):
    repository = SiteRepository(session)
    site = repository.get_by_code(site_code)
    if site is None:
        raise ValueError("場域不存在")

    duplicate = session.scalar(
        select(Sensor).where(Sensor.sensor_code == sensor_code)
    )
    if duplicate is not None:
        raise ValueError("感測器代碼已存在")

    sensor = Sensor(
        sensor_code=sensor_code,
        kind=kind,
        unit=unit,
        site=site,
    )
    session.add(sensor)
    return sensor


with Session(engine) as session:
    with session.begin():
        register_sensor(
            session, "SITE-03", "TEMP-03",
            "temperature", "°C",
        )

十七、用SQLite快速測試相同Model

from sqlalchemy import create_engine
from sqlalchemy.orm import Session


test_engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(test_engine)

with Session(test_engine) as session:
    with session.begin():
        repository = SiteRepository(session)
        repository.add("TEST-01", "測試場域")

with Session(test_engine) as session:
    saved = session.scalar(
        select(Site).where(Site.site_code == "TEST-01")
    )
    assert saved is not None
    assert saved.display_name == "測試場域"

print("測試通過")

SQLite測試快速,但不能完全代表MySQL:型別、排序規則、鎖定、時區、函式與並行行為可能不同。因此還需要少量MySQL整合測試。

十八、Session常見錯誤

錯誤改善方式
全站共用一個Session每個請求或工作單元建立並關閉Session
Repository自行commit由服務層統一決定提交或回滾
離開Session後才讀延遲關聯在Session內預載或轉為輸出資料
直接回傳ORM物件給外部API選擇必要欄位,避免洩漏內部資料
把echo=True留在正式環境使用受控Log,避免敏感參數外洩

十九、USR資料治理:Model不等於公開資料

Model可能包含場域內部欄位,但學生、養殖戶、公開儀表板與研究資料集需要不同視圖。應在服務層或API輸出層做角色授權與欄位選擇,使用匿名場域代碼,避免輸出姓名、電話、精確座標、設備Token與連線資訊。

def public_site_summary(site, latest_value=None):
    return {
        "site_code": site.site_code,
        "display_name": site.display_name,
        "latest_temperature": latest_value,
    }


# 不輸出內部主鍵、帳號、精確位置或設備憑證

二十、課堂挑戰

挑戰A|基礎:為Reading增加received_at欄位,說明observed_at與received_at的差別。
挑戰B|進階:建立ReadingRepository,完成「批次新增」及「查每支感測器最新有效讀值」,但不要在Repository內commit。
挑戰C|USR場域:設計公開版、養殖戶版及維運版三種輸出資料,逐欄說明誰能看、為何需要,以及保存多久。

二十一、用AI協助審查ORM,而不是盲目產碼

請擔任SQLAlchemy 2.x程式碼審查助教。
系統使用MySQL、PyMySQL,模型為Site、Sensor、Reading。
請檢查:關聯方向、外鍵、唯一鍵、NULL、索引、時區、
Session生命週期、交易邊界、N+1查詢、連線池與敏感資料輸出。
不要改用舊式session.query,也不要要求真實帳密。
請把問題分成「會造成資料錯誤」「效能風險」「維護性」三類,
每項提供最小修正範例與可執行測試。

Python一下:從SQLite走向MySQL——建立多人共用的智慧養殖資料庫

《Python一下:從風土資料到智慧生活》第 27 篇

從SQLite走向MySQL——建立多人共用的智慧養殖資料庫

讓場域設備、學生團隊與管理平台安全共用資料,而不是共用一個資料庫檔案。

CH13 關聯式資料庫MySQLPyMySQL
學習目標
完成本篇後,你能比較SQLite與MySQL的適用情境,建立UTF-8資料庫及最小權限帳號,使用環境變數與PyMySQL安全連線,以交易完成批次寫入、查詢與更新,並說明遠端部署、備份及個資保護的基本原則。

一、SQLite很好,為何還需要MySQL?

SQLite把整個資料庫放在單一檔案中,適合個人練習、離線程式、小型裝置及單機原型。當多座智慧養殖場域、Django平台與學生團隊需要同時存取,伺服器型資料庫較容易集中管理帳號、連線、權限、交易與備份。

比較SQLiteMySQL
架構程式直接讀寫檔案用戶端連線至資料庫伺服器
安裝Python內建sqlite3需安裝、啟動及維護服務
多人並行讀取方便,寫入並行有限適合多使用者與網路服務
帳號權限主要依賴檔案權限可依帳號、主機與資料庫授權
適合本系列課堂練習、離線採集多人共用平台與正式場域
不是資料越多就一定要換MySQL:是否遷移應看同時使用者、寫入頻率、權限、維運能力、可用性及備份需求。若SQLite已能安全滿足需求,不必為了「看起來專業」增加系統複雜度。

二、準備MySQL與Python驅動程式

先由教師或系統管理者安裝受支援版本的MySQL Server。Python端使用PyMySQL:

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "PyMySQL"],
    check=True,
)

在終端機直接執行時,也可輸入 python -m pip install PyMySQL。教室與正式專案應使用虛擬環境,並將套件版本記錄於requirements.txt。

三、由管理者建立資料庫與專用帳號

以下SQL應由有權限的管理者在MySQL工具中執行;請替換密碼,且不要直接開放給整個網際網路。

ADMIN_SQL = """
CREATE DATABASE IF NOT EXISTS shuijing_demo
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

CREATE USER IF NOT EXISTS
  'shuijing_app'@'10.0.0.%'
  IDENTIFIED BY '請換成長且唯一的密碼';

GRANT SELECT, INSERT, UPDATE, DELETE
  ON shuijing_demo.*
  TO 'shuijing_app'@'10.0.0.%';

FLUSH PRIVILEGES;
"""

print(ADMIN_SQL)

10.0.0.%只是私有網段示例,必須依實際網路縮小允許來源。應另外設計遷移帳號負責CREATE與ALTER;平常執行的應用程式帳號不需要DROP、GRANT等高權限。

四、把連線設定放在環境變數

不要把密碼寫進Python、Colab、GitHub或部落格。不同作業系統的設定方式不同,程式只負責讀取:

import os


DB_CONFIG = {
    "host": os.getenv("SHUIJING_DB_HOST", "127.0.0.1"),
    "port": int(os.getenv("SHUIJING_DB_PORT", "3306")),
    "user": os.getenv("SHUIJING_DB_USER", "shuijing_app"),
    "password": os.getenv("SHUIJING_DB_PASSWORD", ""),
    "database": os.getenv("SHUIJING_DB_NAME", "shuijing_demo"),
}

if not DB_CONFIG["password"]:
    raise RuntimeError("尚未設定SHUIJING_DB_PASSWORD")
Google Colab提醒:不要在公開Notebook中顯示密碼。可使用Colab的Secrets功能讀取密鑰;資料庫也不應直接暴露3306連接埠給所有來源。教學可使用校內測試網段、VPN、SSH通道或受控雲端環境。

五、建立安全連線函式

import pymysql


def connect_database(config):
    return pymysql.connect(
        host=config["host"],
        port=config["port"],
        user=config["user"],
        password=config["password"],
        database=config["database"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
        autocommit=False,
        connect_timeout=10,
        read_timeout=10,
        write_timeout=10,
    )


connection = connect_database(DB_CONFIG)
with connection.cursor() as cursor:
    cursor.execute("SELECT VERSION() AS version")
    print(cursor.fetchone())

連線逾時避免程式無限等待;DictCursor讓查詢結果以欄名存取。正式遠端連線應依部署環境驗證TLS憑證,不應只為方便而關閉驗證。

六、建立三張InnoDB關聯表

SCHEMA_SQL = [
    """
    CREATE TABLE IF NOT EXISTS sites (
      site_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      site_code VARCHAR(30) NOT NULL UNIQUE,
      display_name VARCHAR(100) NOT NULL,
      active BOOLEAN NOT NULL DEFAULT TRUE,
      created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
    """
    CREATE TABLE IF NOT EXISTS sensors (
      sensor_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      sensor_code VARCHAR(50) NOT NULL UNIQUE,
      site_id BIGINT UNSIGNED NOT NULL,
      kind VARCHAR(30) NOT NULL,
      unit VARCHAR(20) NOT NULL,
      CONSTRAINT fk_sensors_site
        FOREIGN KEY (site_id) REFERENCES sites(site_id)
        ON UPDATE CASCADE ON DELETE RESTRICT
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
    """
    CREATE TABLE IF NOT EXISTS readings (
      reading_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      sensor_id BIGINT UNSIGNED NOT NULL,
      observed_at DATETIME(6) NOT NULL,
      value DOUBLE NULL,
      quality ENUM('valid','unknown','invalid') NOT NULL,
      note VARCHAR(255) NOT NULL DEFAULT '',
      received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
      UNIQUE KEY uq_sensor_time (sensor_id, observed_at),
      KEY idx_readings_time (observed_at),
      CONSTRAINT fk_readings_sensor
        FOREIGN KEY (sensor_id) REFERENCES sensors(sensor_id)
        ON DELETE RESTRICT
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
]

try:
    with connection.cursor() as cursor:
        for statement in SCHEMA_SQL:
            cursor.execute(statement)
    connection.commit()
except Exception:
    connection.rollback()
    raise

使用InnoDB才能可靠支援交易與外鍵。時間欄位的時區策略要先約定:常見做法是以UTC寫入 DATETIME,應用程式顯示時再轉成臺灣時間,避免夏令時間或跨區部署造成歧義。

七、SQLite與MySQL最容易混淆的差異

用途SQLitePyMySQL/MySQL
參數佔位符?%s
自動編號INTEGER PRIMARY KEYAUTO_INCREMENT
真假值通常以0與1儲存BOOLEAN實際為TINYINT(1)
新增或忽略ON CONFLICT DO NOTHINGINSERT IGNORE或ON DUPLICATE KEY UPDATE
列資料sqlite3.RowDictCursor

八、用參數化SQL新增場域

def add_site(connection, site_code, display_name):
    sql = """
        INSERT INTO sites (site_code, display_name)
        VALUES (%s, %s)
    """
    try:
        with connection.cursor() as cursor:
            cursor.execute(sql, (site_code, display_name))
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise


add_site(connection, "SITE-01", "示範池A")

即使PyMySQL的佔位符長得像字串格式化,也不能自己使用 % 或f-string拼接SQL;參數必須交給 execute() 的第二個參數處理。

九、重複執行也安全的Upsert

def upsert_site(connection, site_code, display_name):
    sql = """
        INSERT INTO sites (site_code, display_name)
        VALUES (%s, %s) AS new
        ON DUPLICATE KEY UPDATE
          display_name = new.display_name
    """
    try:
        with connection.cursor() as cursor:
            cursor.execute(sql, (site_code, display_name))
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise


upsert_site(connection, "SITE-01", "示範池A")

Upsert可讓同步工作重跑而不產生重複場域,但更新哪些欄位仍需明確規劃,避免新資料不小心覆蓋人工校正內容。

十、交易:場域與感測器一起成功

def create_site_with_sensor(
    connection, site_code, display_name,
    sensor_code, kind, unit
):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                INSERT INTO sites (site_code, display_name)
                VALUES (%s, %s)
                """,
                (site_code, display_name),
            )
            site_id = cursor.lastrowid
            cursor.execute(
                """
                INSERT INTO sensors
                  (sensor_code, site_id, kind, unit)
                VALUES (%s, %s, %s, %s)
                """,
                (sensor_code, site_id, kind, unit),
            )
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise

任何一步失敗都回滾,避免只留下沒有設備的半套資料。不要在尚未完成整個工作單元前提早commit。

十一、批次寫入感測資料

def insert_readings(connection, sensor_id, items):
    sql = """
        INSERT INTO readings
          (sensor_id, observed_at, value, quality, note)
        VALUES (%s, %s, %s, %s, %s)
        ON DUPLICATE KEY UPDATE
          value = VALUES(value),
          quality = VALUES(quality),
          note = VALUES(note)
    """
    rows = [
        (
            sensor_id,
            item["observed_at"],
            item.get("value"),
            item["quality"],
            item.get("note", ""),
        )
        for item in items
    ]
    try:
        with connection.cursor() as cursor:
            cursor.executemany(sql, rows)
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise
先驗證再入庫:程式仍應檢查時間格式、資料型別、quality允許值與合理範圍。超出範圍的原始數值可標成invalid保留,以利追查;設備離線則用NULL加unknown,不要偽裝成0。

十二、JOIN查出最新有效水溫

def latest_temperature(connection, site_code):
    sql = """
        SELECT s.site_code,
               s.display_name,
               e.sensor_code,
               r.observed_at,
               r.value,
               e.unit
        FROM sites AS s
        JOIN sensors AS e ON e.site_id = s.site_id
        JOIN readings AS r ON r.sensor_id = e.sensor_id
        WHERE s.site_code = %s
          AND e.kind = 'temperature'
          AND r.quality = 'valid'
        ORDER BY r.observed_at DESC, r.reading_id DESC
        LIMIT 1
    """
    with connection.cursor() as cursor:
        cursor.execute(sql, (site_code,))
        return cursor.fetchone()


print(latest_temperature(connection, "SITE-01"))

十三、鎖定資料後更新:避免互相覆蓋

def deactivate_site(connection, site_code):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                SELECT site_id, active
                FROM sites
                WHERE site_code = %s
                FOR UPDATE
                """,
                (site_code,),
            )
            site = cursor.fetchone()
            if site is None:
                raise ValueError("找不到場域")
            cursor.execute(
                "UPDATE sites SET active = FALSE WHERE site_id = %s",
                (site["site_id"],),
            )
        connection.commit()
    except Exception:
        connection.rollback()
        raise

FOR UPDATE需置於交易中,適合「先讀再改」且不能被他人同時改動的流程。鎖定範圍與交易時間應盡量縮小,以免造成等待或死結。

十四、連線生命週期與錯誤紀錄

import logging


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("shuijing-db")


def run_job(config):
    connection = None
    try:
        connection = connect_database(config)
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1 AS healthy")
            logger.info("database health=%s", cursor.fetchone()["healthy"])
    except pymysql.MySQLError as error:
        logger.error("資料庫作業失敗:%s", type(error).__name__)
        raise
    finally:
        if connection is not None:
            connection.close()

正式Log不要輸出密碼、完整連線字串、Token、住戶姓名或精確座標。網站服務通常使用連線池;每次請求都新建連線會浪費資源,下篇SQLAlchemy會再處理。

十五、從SQLite搬到MySQL的正確流程

  1. 盤點SQLite結構、型別、外鍵、索引、NULL及重複資料。
  2. 先在MySQL測試環境建立結構,不直接動正式系統。
  3. 以Python分批讀取SQLite、驗證、轉換時間與寫入MySQL。
  4. 比對每表筆數、抽樣內容、外鍵及彙總結果。
  5. 安排短暫停止寫入或雙寫切換,保留可回復方案。
  6. 確認應用程式穩定後,再依保存政策封存舊資料。
def compare_counts(sqlite_connection, mysql_connection, table):
    allowed = {"sites", "sensors", "readings"}
    if table not in allowed:
        raise ValueError("不允許的資料表")

    sqlite_count = sqlite_connection.execute(
        f"SELECT COUNT(*) FROM {table}"
    ).fetchone()[0]
    with mysql_connection.cursor() as cursor:
        cursor.execute(f"SELECT COUNT(*) AS total FROM {table}")
        mysql_count = cursor.fetchone()["total"]
    return sqlite_count, mysql_count

表名不能使用一般參數佔位符,所以只能從程式內的允許清單選取。筆數相等只是第一關,還要比對內容、關聯與統計結果。

十六、備份不是複製資料夾

MySQL運作中不能只複製資料目錄。應使用資料庫提供的邏輯備份或經驗證的實體備份機制,安排自動化、異地保存、加密、保存期限及定期還原演練。

BACKUP_CHECKLIST = [
    "備份範圍包含結構、資料、觸發器與必要帳號設定",
    "備份檔加密,存取權限與正式資料一致或更嚴格",
    "至少保存一份於不同故障範圍",
    "記錄備份時間、版本、雜湊值與執行結果",
    "定期在隔離環境實際還原並驗證查詢",
]

for number, item in enumerate(BACKUP_CHECKLIST, start=1):
    print(number, item)

十七、水井村USR的資料治理界線

做法原因
教材使用匿名場域代碼避免揭露養殖戶身分與精確位置
設備只寫入指定表格降低憑證外洩造成的損害
管理者、教師、學生分開帳號便於停權、稽核與責任釐清
平台不直接回傳所有原始資料依角色與任務提供最少資料
保留品質標記與規則版本避免把設備異常誤認為養殖異常

十八、課堂挑戰

挑戰A|基礎:建立一個只能SELECT指定資料表的唯讀帳號,說明它和應用程式讀寫帳號的差別。
挑戰B|進階:設計Python匯入程式,將1000筆資料分批寫入,每批失敗時只回滾該批,並記錄可重跑的錯誤清單。
挑戰C|USR場域:畫出「感測器→閘道器→API→MySQL→儀表板」的資料流,為每一段標示傳輸加密、身分驗證、最小權限與斷線補傳策略。

十九、用AI協助檢查遷移設計

請擔任MySQL資料庫助教,檢查SQLite移轉到MySQL的設計。
資料表為sites、sensors、readings,使用Python與PyMySQL。
請逐項檢查:資料型別、utf8mb4、時區、NULL、外鍵、
唯一鍵、索引、參數化SQL、交易、最小權限、TLS、備份與回復。
不要要求我貼出密碼或真實個資。
請將「一定要改」「建議改進」「需要場域確認」分成三類,
並為每一項提供可驗證方法。

Python一下:從SELECT到JOIN——用SQL回答智慧養殖問題

《Python一下:從風土資料到智慧生活》第 26 篇

從SELECT到JOIN——用SQL回答智慧養殖問題

讓資料庫不只是存資料,而能回答「哪座池需要優先巡查?」

CH13 關聯式資料庫SQL查詢智慧養殖
學習目標
完成本篇後,你能使用SELECT、WHERE、ORDER BY、LIMIT、GROUP BY與HAVING查詢資料;利用INNER JOIN及LEFT JOIN連接場域、感測器與讀值;以參數化SQL產生巡查清單,並說明「異常資料」不等於「養殖異常」。

一、先把場域問題翻成資料問題

場域想知道的事SQL工具
最近有哪些有效讀值?SELECT、WHERE、ORDER BY、LIMIT
各場域平均水溫是多少?GROUP BY、AVG、COUNT
哪些場域目前沒有資料?LEFT JOIN、IS NULL
哪些讀值超出教材示範門檻?JOIN、CASE、參數化條件
重要觀念:SQL查到的是「符合規則的紀錄」,不是自動完成專業診斷。感測器誤差、校正狀態、物種、成長階段、天候與養殖戶經驗都會影響判讀;系統應協助巡查,不應直接取代人的決策。

二、建立可重複練習的記憶體資料庫

以下資料全部為匿名教材資料。使用 :memory: 可在記憶體中練習,程式結束後不留下檔案。

import sqlite3


connection = sqlite3.connect(":memory:")
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")

connection.executescript("""
CREATE TABLE sites (
    site_id INTEGER PRIMARY KEY,
    site_code TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL
);
CREATE TABLE sensors (
    sensor_id INTEGER PRIMARY KEY,
    sensor_code TEXT NOT NULL UNIQUE,
    site_id INTEGER NOT NULL,
    kind TEXT NOT NULL,
    unit TEXT NOT NULL,
    FOREIGN KEY (site_id) REFERENCES sites(site_id)
);
CREATE TABLE readings (
    reading_id INTEGER PRIMARY KEY,
    sensor_id INTEGER NOT NULL,
    observed_at TEXT NOT NULL,
    value REAL,
    quality TEXT NOT NULL,
    FOREIGN KEY (sensor_id) REFERENCES sensors(sensor_id)
);
""")

三、放入水井村智慧養殖示範資料

sites = [
    (1, "SITE-01", "示範池A"),
    (2, "SITE-02", "示範池B"),
    (3, "SITE-03", "示範池C"),
]
sensors = [
    (1, "TEMP-01", 1, "temperature", "°C"),
    (2, "TEMP-02", 2, "temperature", "°C"),
    (3, "PH-01", 1, "ph", "pH"),
]
readings = [
    (1, 1, "2026-08-31T08:00:00+08:00", 27.2, "valid"),
    (2, 1, "2026-08-31T09:00:00+08:00", 29.4, "valid"),
    (3, 2, "2026-08-31T08:00:00+08:00", 30.1, "valid"),
    (4, 2, "2026-08-31T09:00:00+08:00", None, "unknown"),
    (5, 3, "2026-08-31T09:00:00+08:00", 7.4, "valid"),
]

with connection:
    connection.executemany(
        "INSERT INTO sites VALUES (?, ?, ?)", sites
    )
    connection.executemany(
        "INSERT INTO sensors VALUES (?, ?, ?, ?, ?)", sensors
    )
    connection.executemany(
        "INSERT INTO readings VALUES (?, ?, ?, ?, ?)", readings
    )

四、SELECT:決定要看哪些欄位

rows = connection.execute("""
    SELECT sensor_id, observed_at, value, quality
    FROM readings
""").fetchall()

for row in rows:
    print(dict(row))

教學初期可用 SELECT * 探索資料;正式查詢最好明確列出欄位,避免表格修改後輸出跟著意外改變,也減少不必要的資料傳輸。

五、WHERE:只留下可用的水溫資料

rows = connection.execute("""
    SELECT observed_at, value
    FROM readings
    WHERE sensor_id = ?
      AND quality = ?
      AND value IS NOT NULL
""", (1, "valid")).fetchall()

for row in rows:
    print(row["observed_at"], row["value"])

SQL判斷空值要用 IS NULL 或 IS NOT NULL,不能用 = NULL。查詢值仍採參數化方式傳入。

六、ORDER BY與LIMIT:找最新紀錄

latest = connection.execute("""
    SELECT observed_at, value, quality
    FROM readings
    WHERE sensor_id = ?
    ORDER BY observed_at DESC, reading_id DESC
    LIMIT 1
""", (1,)).fetchone()

print(dict(latest) if latest else "尚無資料")

本例時間使用含時區的ISO 8601字串,格式一致時可正確排序。真實系統宜統一以UTC儲存,再於顯示時轉為臺灣時間。

七、計算欄位與CASE:把數值轉成巡查提示

low, high = 26.0, 30.0
rows = connection.execute("""
    SELECT observed_at, value,
           CASE
             WHEN quality != 'valid' THEN '資料待確認'
             WHEN value < ? THEN '低於示範門檻'
             WHEN value > ? THEN '高於示範門檻'
             ELSE '示範範圍內'
           END AS status
    FROM readings
    WHERE sensor_id = ?
    ORDER BY observed_at
""", (low, high, 1)).fetchall()

for row in rows:
    print(row["observed_at"], row["value"], row["status"])

26~30°C僅為SQL教學用門檻,不代表任何物種或場域的正式養殖標準。正式規則應由養殖專業者確認,並保留版本、適用條件及修改紀錄。

八、GROUP BY:各場域有幾筆有效水溫?

rows = connection.execute("""
    SELECT s.site_code,
           COUNT(r.reading_id) AS valid_count,
           ROUND(AVG(r.value), 2) AS average_temperature,
           MIN(r.value) AS minimum_temperature,
           MAX(r.value) AS maximum_temperature
    FROM sites AS s
    LEFT JOIN sensors AS e
      ON e.site_id = s.site_id
     AND e.kind = 'temperature'
    LEFT JOIN readings AS r
      ON r.sensor_id = e.sensor_id
     AND r.quality = 'valid'
    GROUP BY s.site_id, s.site_code
    ORDER BY s.site_code
""").fetchall()

for row in rows:
    print(dict(row))

COUNT(r.reading_id) 不計NULL,因此沒有有效讀值的示範池C會得到0;其平均值仍為NULL,而不是被誤寫成0°C。

九、HAVING:篩選彙總後的結果

rows = connection.execute("""
    SELECT sensor_id,
           COUNT(*) AS sample_count,
           ROUND(AVG(value), 2) AS average_value
    FROM readings
    WHERE quality = 'valid'
    GROUP BY sensor_id
    HAVING COUNT(*) >= ?
    ORDER BY sensor_id
""", (2,)).fetchall()

for row in rows:
    print(dict(row))

WHERE 在分組前篩選每一列;HAVING 在分組後篩選統計結果。

十、INNER JOIN:把代碼翻成場域資訊

rows = connection.execute("""
    SELECT s.display_name,
           e.sensor_code,
           e.kind,
           r.observed_at,
           r.value,
           e.unit
    FROM readings AS r
    JOIN sensors AS e ON e.sensor_id = r.sensor_id
    JOIN sites AS s ON s.site_id = e.site_id
    WHERE r.quality = 'valid'
    ORDER BY r.observed_at DESC, s.site_code
""").fetchall()

for row in rows:
    print(dict(row))

INNER JOIN只保留兩邊都對得上的紀錄,適合查看已產生讀值的感測器。

十一、LEFT JOIN:找出沒有資料的場域

rows = connection.execute("""
    SELECT s.site_code, s.display_name
    FROM sites AS s
    LEFT JOIN sensors AS e ON e.site_id = s.site_id
    LEFT JOIN readings AS r ON r.sensor_id = e.sensor_id
    GROUP BY s.site_id, s.site_code, s.display_name
    HAVING COUNT(r.reading_id) = 0
    ORDER BY s.site_code
""").fetchall()

for row in rows:
    print(row["site_code"], row["display_name"])

若改用INNER JOIN,完全沒有感測器或讀值的場域會消失,反而看不見最需要確認的缺漏。

十二、子查詢:取得每支感測器的最新讀值

rows = connection.execute("""
    SELECT e.sensor_code, r.observed_at, r.value, r.quality
    FROM sensors AS e
    JOIN readings AS r ON r.sensor_id = e.sensor_id
    WHERE r.reading_id = (
        SELECT r2.reading_id
        FROM readings AS r2
        WHERE r2.sensor_id = e.sensor_id
        ORDER BY r2.observed_at DESC, r2.reading_id DESC
        LIMIT 1
    )
    ORDER BY e.sensor_code
""").fetchall()

for row in rows:
    print(dict(row))

用reading_id作第二排序條件,可在時間相同時得到穩定結果。若資料量很大,可再學習視窗函式與複合索引。

十三、綜合應用:產生優先巡查清單

def patrol_list(connection, low, high):
    return connection.execute("""
        SELECT s.site_code,
               s.display_name,
               e.sensor_code,
               r.observed_at,
               r.value,
               r.quality,
               CASE
                 WHEN r.quality != 'valid' THEN 1
                 WHEN r.value < ? OR r.value > ? THEN 2
                 ELSE 3
               END AS priority
        FROM readings AS r
        JOIN sensors AS e ON e.sensor_id = r.sensor_id
        JOIN sites AS s ON s.site_id = e.site_id
        WHERE e.kind = 'temperature'
          AND r.observed_at = (
              SELECT MAX(r2.observed_at)
              FROM readings AS r2
              WHERE r2.sensor_id = e.sensor_id
          )
        ORDER BY priority, s.site_code
    """, (low, high)).fetchall()


for item in patrol_list(connection, 26.0, 30.0):
    print(dict(item))
結果怎麼讀?優先級1表示資料未知或品質待確認,應先檢查供電、網路、探頭與校正;優先級2才是有效數值超出教材門檻。兩者需要不同的巡查行動。

十四、安全地讓使用者選擇排序

SQL參數只能替代「資料值」,不能直接替代欄名。動態排序應使用固定允許清單。

ALLOWED_SORTS = {
    "time": "r.observed_at DESC",
    "value": "r.value DESC",
    "site": "s.site_code ASC",
}


def query_readings(connection, sort_name="time"):
    order_sql = ALLOWED_SORTS.get(sort_name)
    if order_sql is None:
        raise ValueError("不支援的排序方式")

    sql = f"""
        SELECT s.site_code, r.observed_at, r.value
        FROM readings AS r
        JOIN sensors AS e ON e.sensor_id = r.sensor_id
        JOIN sites AS s ON s.site_id = e.site_id
        WHERE r.quality = ?
        ORDER BY {order_sql}
    """
    return connection.execute(sql, ("valid",)).fetchall()


print([dict(row) for row in query_readings(connection, "site")])

這裡的f-string只插入程式內已核准的SQL片段;外部輸入仍不能直接放進SQL。

十五、看懂查詢計畫與索引

connection.execute("""
    CREATE INDEX idx_readings_sensor_time
    ON readings(sensor_id, observed_at DESC)
""")

plan = connection.execute("""
    EXPLAIN QUERY PLAN
    SELECT reading_id, value
    FROM readings
    WHERE sensor_id = ?
    ORDER BY observed_at DESC
    LIMIT 1
""", (1,)).fetchall()

for step in plan:
    print(tuple(step))

索引應依常用查詢建立,不是越多越好;每個索引都會占空間,也增加新增與更新資料的成本。

十六、課堂挑戰

挑戰A|基礎:查出所有 quality='unknown' 的紀錄,依時間由新到舊排列。
挑戰B|進階:列出每座場域的感測器數量,沒有設備的場域也必須顯示為0。
挑戰C|USR場域:和養殖者共同定義「設備待確認、數值待確認、正常追蹤」三類,不記錄個資,並讓規則附上來源、適用物種與版本日期。

十七、用AI協助,但要自己驗證

請擔任SQL學習助教。資料表為sites、sensors、readings。
請先用一句話說明查詢邏輯,再提供SQLite相容SQL。
所有外部資料值必須參數化;不得虛構欄位。
我的問題是:找出每座場域最新一筆有效水溫,
沒有有效資料的場域也要顯示。
最後請提供兩筆可驗證LEFT JOIN行為的最小測試資料。

收到AI產生的SQL後,至少檢查欄名、JOIN條件、NULL處理、重複列、參數化及測試資料;能執行不代表答案一定正確。

Python一下:用SQLite建立水井村場域、感測器與巡查資料庫

《Python一下:從風土資料到智慧生活》第 25 篇

用SQLite建立水井村場域、感測器與巡查資料庫

把散落的CSV整理成有關聯、有約束、可查詢,也能安全備份的場域資料。

CH13 關聯式資料庫SQLite參數化SQL
學習目標
完成本篇後,你能解釋資料表、列、欄位、主鍵與外鍵,使用Python內建 sqlite3 建立資料庫及關聯表,以參數化SQL新增、查詢、更新與刪除資料,使用交易維持一致性,並建立安全備份。

一、CSV不夠用的時候

CSV適合交換資料,但多座場域、感測器與大量巡查紀錄之間具有關係。若每列重複場域名稱和設備資訊,容易出現拼字不一、重複資料及刪改不同步。關聯式資料庫以表格和鍵值建立一致關係。

概念本篇例子
資料表sites、sensors、readings
主鍵每個場域或設備的唯一id
外鍵感測器所屬場域、讀值所屬感測器
約束名稱不可空白、pH基本理論範圍
資料治理提醒:教材使用「示範池A~G」匿名名稱,不保存真實養殖戶姓名、電話、精確座標、帳密或設備Token。資料庫檔案本身沒有自動等同完善權限控制;真實系統仍需最小權限、加密、備份、存取紀錄與資料保存政策。

二、連線並啟用外鍵

import sqlite3
from pathlib import Path


def connect_database(path):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    connection = sqlite3.connect(path, timeout=10)
    connection.execute("PRAGMA foreign_keys = ON")
    connection.row_factory = sqlite3.Row
    return connection


connection = connect_database("data/shuijing_demo.db")
print(connection.execute(
    "PRAGMA foreign_keys"
).fetchone()[0])

SQLite外鍵檢查應在每個連線明確啟用。Row 讓查詢結果可用欄位名稱讀取。

三、設計三張關聯表

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS sites (
    site_id INTEGER PRIMARY KEY,
    site_code TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL,
    active INTEGER NOT NULL DEFAULT 1
        CHECK (active IN (0, 1))
);

CREATE TABLE IF NOT EXISTS sensors (
    sensor_id INTEGER PRIMARY KEY,
    sensor_code TEXT NOT NULL UNIQUE,
    site_id INTEGER NOT NULL,
    kind TEXT NOT NULL,
    unit TEXT NOT NULL,
    FOREIGN KEY (site_id) REFERENCES sites(site_id)
        ON UPDATE CASCADE ON DELETE RESTRICT
);

CREATE TABLE IF NOT EXISTS readings (
    reading_id INTEGER PRIMARY KEY,
    sensor_id INTEGER NOT NULL,
    observed_at TEXT NOT NULL,
    value REAL,
    quality TEXT NOT NULL
        CHECK (quality IN ('valid', 'unknown', 'invalid')),
    note TEXT NOT NULL DEFAULT '',
    FOREIGN KEY (sensor_id) REFERENCES sensors(sensor_id)
        ON DELETE RESTRICT,
    UNIQUE (sensor_id, observed_at)
);
"""

with connection:
    connection.executescript(SCHEMA_SQL)

value 允許NULL,因為設備離線時「未知」不等於0;同時用quality標記資料品質。真實門檻不寫死在這個通用資料表。

四、新增七座匿名場域

sites = [
    (f"SITE-{number:02d}", f"示範池{letter}")
    for number, letter in enumerate("ABCDEFG", start=1)
]

with connection:
    connection.executemany(
        """
        INSERT INTO sites (site_code, display_name)
        VALUES (?, ?)
        ON CONFLICT(site_code) DO NOTHING
        """,
        sites,
    )

print("場域筆數:", connection.execute(
    "SELECT COUNT(*) FROM sites"
).fetchone()[0])

問號是參數佔位符。資料值永遠透過第二個參數傳入,不用f-string或字串拼接產生SQL。

五、新增感測器並取得場域主鍵

def find_site_id(connection, site_code):
    row = connection.execute(
        "SELECT site_id FROM sites WHERE site_code = ?",
        (site_code,),
    ).fetchone()
    if row is None:
        raise ValueError(f"找不到場域:{site_code}")
    return row["site_id"]


site_id = find_site_id(connection, "SITE-01")

with connection:
    connection.execute(
        """
        INSERT INTO sensors
            (sensor_code, site_id, kind, unit)
        VALUES (?, ?, ?, ?)
        ON CONFLICT(sensor_code) DO NOTHING
        """,
        ("TEMP-01", site_id, "temperature", "°C"),
    )

六、為何不能拼接SQL?

# 錯誤示範:不要把外部文字拼進SQL
unsafe_name = "示範池A"
unsafe_sql = (
    "SELECT * FROM sites WHERE display_name = '"
    + unsafe_name
    + "'"
)

# 正確方式:SQL與資料值分開
safe_row = connection.execute(
    "SELECT * FROM sites WHERE display_name = ?",
    (unsafe_name,),
).fetchone()

print(dict(safe_row) if safe_row else "找不到")

參數化SQL可防止資料被解讀成SQL語法,也能正確處理引號。表名、欄名不能用一般值參數代替;若需要動態欄位,必須從程式內固定允許清單選擇。

七、驗證後新增讀值

from datetime import datetime


def insert_reading(
    connection, sensor_code, observed_at,
    value, quality, note=""
):
    datetime.fromisoformat(observed_at)
    if quality not in {"valid", "unknown", "invalid"}:
        raise ValueError("不支援的quality")
    if quality == "unknown":
        value = None
    elif value is None:
        raise ValueError("valid或invalid必須保留原始數值")
    else:
        value = float(value)

    with connection:
        connection.execute(
            """
            INSERT INTO readings
                (sensor_id, observed_at, value, quality, note)
            SELECT sensor_id, ?, ?, ?, ?
            FROM sensors
            WHERE sensor_code = ?
            """,
            (
                observed_at, value, quality,
                str(note), sensor_code,
            ),
        )


insert_reading(
    connection, "TEMP-01",
    "2026-08-31T08:00:00+08:00",
    27.2, "valid",
)

八、確認INSERT真的找到感測器

前一個 INSERT...SELECT 若找不到sensor_code,可能插入0列而不報錯。可檢查游標的 rowcount:

def insert_unknown(connection, sensor_code, observed_at):
    with connection:
        cursor = connection.execute(
            """
            INSERT INTO readings
                (sensor_id, observed_at, value, quality, note)
            SELECT sensor_id, ?, NULL, 'unknown', ?
            FROM sensors
            WHERE sensor_code = ?
            """,
            (observed_at, "設備離線", sensor_code),
        )
        if cursor.rowcount != 1:
            raise ValueError(f"找不到感測器:{sensor_code}")

九、JOIN查詢巡查紀錄

rows = connection.execute(
    """
    SELECT
        s.site_code,
        s.display_name,
        e.sensor_code,
        e.kind,
        r.observed_at,
        r.value,
        r.quality
    FROM readings AS r
    JOIN sensors AS e ON e.sensor_id = r.sensor_id
    JOIN sites AS s ON s.site_id = e.site_id
    ORDER BY r.observed_at DESC, s.site_code
    """
).fetchall()

for row in rows:
    print(dict(row))

十、彙整有效資料

summary = connection.execute(
    """
    SELECT
        s.site_code,
        COUNT(r.reading_id) AS valid_count,
        ROUND(AVG(r.value), 2) AS average_value
    FROM sites AS s
    LEFT JOIN sensors AS e ON e.site_id = s.site_id
    LEFT JOIN readings AS r
        ON r.sensor_id = e.sensor_id
        AND r.quality = 'valid'
    GROUP BY s.site_id, s.site_code
    ORDER BY s.site_code
    """
).fetchall()

for row in summary:
    print(dict(row))

只對quality為valid的資料取平均,並同時呈現有效筆數。沒有有效資料時AVG為NULL,不應自動改成0。

十一、交易:全部成功或全部取消

def rename_site_and_sensor(
    connection, site_code, new_name,
    sensor_code, new_unit
):
    try:
        with connection:
            connection.execute(
                """
                UPDATE sites SET display_name = ?
                WHERE site_code = ?
                """,
                (new_name, site_code),
            )
            connection.execute(
                """
                UPDATE sensors SET unit = ?
                WHERE sensor_code = ?
                """,
                (new_unit, sensor_code),
            )
    except sqlite3.DatabaseError:
        print("交易失敗,已回復本次變更。")
        raise

with connection 正常結束時提交,出現例外時回復。批次匯入應以合理大小分批,不要每一列各自提交。

十二、更新與刪除要先預覽

def preview_inactive_sites(connection):
    return connection.execute(
        """
        SELECT site_id, site_code, display_name
        FROM sites
        WHERE active = 0
        ORDER BY site_code
        """
    ).fetchall()


for row in preview_inactive_sites(connection):
    print("待確認:", dict(row))

# 教材預設不執行DELETE
# 刪除前還要檢查外鍵、備份、權限與保存政策

十三、索引與查詢計畫

with connection:
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS
            idx_readings_sensor_time
        ON readings(sensor_id, observed_at)
        """
    )

plan = connection.execute(
    """
    EXPLAIN QUERY PLAN
    SELECT * FROM readings
    WHERE sensor_id = ? AND observed_at >= ?
    """,
    (1, "2026-08-01"),
).fetchall()

for row in plan:
    print(tuple(row))

索引可加速特定查詢,但會增加儲存與寫入成本。應依實際查詢及資料量建立,不是每個欄位都加索引。

十四、使用SQLite備份API

import sqlite3
from pathlib import Path


def backup_database(source_connection, target_path):
    target_path = Path(target_path)
    if target_path.exists():
        raise FileExistsError(f"備份已存在:{target_path}")
    target_path.parent.mkdir(parents=True, exist_ok=True)

    target = sqlite3.connect(target_path)
    try:
        source_connection.backup(target)
    finally:
        target.close()
    return target_path


# backup_database(connection, "backup/shuijing_demo_v1.db")

不要在資料庫正在寫入時直接複製檔案當作唯一備份流程。SQLite備份API可在連線層建立一致副本;備份後仍要驗證並做還原演練。

十五、完整性檢查與關閉連線

foreign_key_errors = connection.execute(
    "PRAGMA foreign_key_check"
).fetchall()
integrity = connection.execute(
    "PRAGMA integrity_check"
).fetchone()[0]

print("外鍵錯誤:", len(foreign_key_errors))
print("完整性:", integrity)

connection.close()

十六、常見錯誤檢查表

現象原因修正
刪除場域後留下孤兒設備未啟用外鍵每個連線PRAGMA foreign_keys=ON
含引號資料造成錯誤或注入拼接SQL所有值使用參數化SQL
設備離線被記為0混淆未知與零值value=NULL並記錄quality
批次中途留下半套資料未使用交易用with connection包住整批
備份檔無法還原未驗證與演練完整性檢查並測試還原

十七、USR實作挑戰

挑戰A|七座場域模型
為七座匿名教學場域各建立水溫與pH設備,加入正常、未知及無效讀值,查詢各場域的資料品質數量。
挑戰B|CSV匯入交易
讀取上一章CSV,整批驗證後寫入;故意加入重複時間,確認交易能回復且錯誤可追查。
挑戰C|權限與公開版
設計內部資料庫與公開摘要的欄位差異,公開版不得包含養殖戶、精確位置與設備識別資訊。

十八、與生成式AI協作

請擔任Python sqlite3與USR資料治理助教。
請檢查我的場域資料庫程式:
1. 啟用外鍵並設計主鍵、唯一鍵與CHECK;
2. 所有資料值必須參數化,不可拼接SQL;
3. 明確區分0、NULL、unknown與invalid;
4. 批次寫入使用交易,失敗時完整回復;
5. UPDATE與DELETE先預覽,教材預設不刪除;
6. 不保存個資、精確位置、密碼或Token;
7. 使用SQLite backup API並驗證還原。
請提供測試資料與失敗情境。
本篇小結
關聯式資料庫不只是把CSV搬進表格,而是透過主鍵、外鍵、約束、交易與參數化查詢維持資料品質。未知值不造假、敏感資料最小化、備份可還原,才是USR場域資料庫的底線。

Python一下:用CSV記錄七座養殖場巡查資料

《Python一下:從風土資料到智慧生活》第 18 篇

用CSV記錄七座養殖場巡查資料

把一列列巡查紀錄變成可驗證、可交換、可追查的結構化資料。

CH10 CSVcsv模組智慧養殖
學習目標
完成本篇後,你能使用 csv.reader、writer、DictReader 與 DictWriter,正確處理標題列、UTF-8、換行、逗號與引號;並完成巡查資料驗證、錯誤隔離、統計與安全匯出。

一、CSV不是「用逗號自己切」

CSV適合交換表格資料,但欄位本身可能含逗號、引號或換行,因此不應直接使用 line.split(",")。Python的 csv 模組會依CSV規則處理這些細節。

教學資料聲明:本篇使用「示範池A~G」匿名資料,數值與判斷僅供程式練習。真實場域資料可能涉及養殖戶營運、設備位置與生產資訊,公開前須取得同意並去識別;示意門檻不可直接用於設備控制。

二、用writer建立第一份CSV

import csv
from pathlib import Path

path = Path("七座場域_巡查資料.csv")
rows = [
    ["日期", "場域", "水溫", "pH", "備註"],
    ["2026-08-31", "示範池A", 27.2, 7.4, "正常"],
    ["2026-08-31", "示範池B", 28.1, 7.1, "待人工複核"],
]

with path.open("w", encoding="utf-8-sig", newline="") as file:
    writer = csv.writer(file)
    writer.writerows(rows)

print("已建立:", path)

newline="" 可避免某些平台出現多餘空白列。utf-8-sig 有助部分試算表軟體辨識UTF-8中文;純Python系統通常可使用 utf-8。讀寫雙方應先約定。

三、用reader逐列讀取

import csv
from pathlib import Path

path = Path("七座場域_巡查資料.csv")

with path.open("r", encoding="utf-8-sig", newline="") as file:
    reader = csv.reader(file)
    header = next(reader)
    print("欄位:", header)

    for row_number, row in enumerate(reader, start=2):
        print(row_number, row)

CSV讀出的內容預設都是字串,即使畫面看起來是數字,也必須明確轉換後才能計算。

四、DictWriter讓欄位更清楚

import csv
from pathlib import Path

fieldnames = ["日期", "場域", "水溫", "pH", "狀態"]
records = [
    {"日期": "2026-08-31", "場域": "示範池A",
     "水溫": 27.2, "pH": 7.4, "狀態": "已讀取"},
    {"日期": "2026-08-31", "場域": "示範池B",
     "水溫": 28.1, "pH": 7.1, "狀態": "已讀取"},
]

path = Path("巡查資料_字典版.csv")
with path.open("x", encoding="utf-8-sig", newline="") as file:
    writer = csv.DictWriter(file, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(records)

使用 x 模式可避免不小心覆蓋既有紀錄。若確實要產生新版,請更換檔名並保留來源與版本資訊。

五、DictReader:用欄位名稱取資料

import csv
from pathlib import Path

path = Path("巡查資料_字典版.csv")

with path.open("r", encoding="utf-8-sig", newline="") as file:
    reader = csv.DictReader(file)
    print("標題列:", reader.fieldnames)

    for record in reader:
        temperature = float(record["水溫"])
        ph = float(record["pH"])
        print(record["場域"], temperature, ph)

六、先檢查必要欄位

def validate_header(fieldnames):
    required = {"日期", "場域", "水溫", "pH", "狀態"}
    actual = set(fieldnames or [])
    missing = required - actual
    if missing:
        names = "、".join(sorted(missing))
        raise ValueError(f"CSV缺少必要欄位:{names}")


validate_header(["日期", "場域", "水溫", "pH", "狀態"])
print("標題列檢查完成")

欄位檢查應在逐筆轉換之前完成,否則資料跑到一半才發現整份格式不相容。

七、驗證每一列並隔離錯誤

from datetime import date


def parse_record(record):
    patrol_date = date.fromisoformat(record["日期"].strip())
    site = record["場域"].strip()
    if not site:
        raise ValueError("場域不可空白")

    temperature = float(record["水溫"])
    ph = float(record["pH"])
    if not 0.0 <= ph <= 14.0:
        raise ValueError("pH超出理論範圍")

    return {
        "日期": patrol_date.isoformat(),
        "場域": site,
        "水溫": temperature,
        "pH": ph,
        "狀態": record["狀態"].strip(),
    }

這裡只檢查日期、數字格式及pH理論範圍,不判定養殖安全。場域管理門檻必須由養殖戶與專業人員共同設定。

八、完整匯入:單筆錯誤不中止整批

import csv
from pathlib import Path


def load_patrol_csv(path):
    valid_records = []
    errors = []

    with Path(path).open(
        "r", encoding="utf-8-sig", newline=""
    ) as file:
        reader = csv.DictReader(file)
        validate_header(reader.fieldnames)

        for row_number, record in enumerate(reader, start=2):
            try:
                valid_records.append(parse_record(record))
            except (KeyError, TypeError, ValueError) as error:
                errors.append({
                    "列號": row_number,
                    "原因": str(error),
                    "原始資料": dict(record),
                })

    return valid_records, errors
設計重點:成功資料與錯誤資料分開,錯誤紀錄保留列號與原因;但錯誤報告若含個資或敏感營運內容,不可直接公開。

九、建立七座匿名教學場域資料

demo_records = [
    {"日期": "2026-08-31", "場域": f"示範池{letter}",
     "水溫": 26.0 + index * 0.4,
     "pH": 7.0 + index * 0.1,
     "狀態": "教學資料"}
    for index, letter in enumerate("ABCDEFG")
]

for record in demo_records:
    print(record)

十、計算摘要,不把錯誤值混進去

def summarize(records):
    if not records:
        return {"筆數": 0, "平均水溫": None, "平均pH": None}

    temperature_average = sum(
        record["水溫"] for record in records
    ) / len(records)
    ph_average = sum(
        record["pH"] for record in records
    ) / len(records)

    return {
        "筆數": len(records),
        "平均水溫": round(temperature_average, 2),
        "平均pH": round(ph_average, 2),
    }


print(summarize(demo_records))

平均值只描述已通過格式驗證的教學資料,不代表整個場域狀態。報告應同時呈現錯誤筆數、缺漏情況與資料時間範圍。

十一、匯出前防範試算表公式注入

若CSV會由試算表開啟,來自外部的文字若以 =、+、- 或 @ 開頭,可能被當成公式。可在匯出前標記為文字,並限制可接受欄位。

def safe_csv_text(value):
    text = str(value)
    if text.startswith(("=", "+", "-", "@")):
        return "'" + text
    return text


examples = ["正常", '=HYPERLINK("bad")', "@command"]
for value in examples:
    print(safe_csv_text(value))
注意:這是降低常見風險的教學措施,不是完整安全保證。匯入外部CSV仍應視為不可信資料,避免啟用巨集或未知連結。

十二、安全匯出驗證後資料

import csv
from pathlib import Path


def export_records(path, records):
    fieldnames = ["日期", "場域", "水溫", "pH", "狀態"]
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    with path.open(
        "x", encoding="utf-8-sig", newline=""
    ) as file:
        writer = csv.DictWriter(file, fieldnames=fieldnames)
        writer.writeheader()
        for record in records:
            safe_record = {
                key: safe_csv_text(record[key])
                for key in fieldnames
            }
            writer.writerow(safe_record)


# export_records("輸出/七座場域_驗證版.csv", demo_records)

十三、常見錯誤檢查表

現象原因修正
CSV多出空白列未設定newline=""開啟CSV時加上newline=""
Excel中文亂碼編碼辨識不同交換規格約定UTF-8,必要時utf-8-sig
數字無法計算CSV讀出的是字串驗證後轉成float或int
含逗號備註被拆壞自行split(",")使用csv模組
一筆壞資料整批中止未做逐列例外隔離保留列號、原因並繼續
開啟後執行公式外部文字被試算表解讀驗證、標記文字並避免不可信檔案

十四、USR實作挑戰

挑戰A|七座場域巡查表
建立七座匿名場域各三筆教學資料,混入缺欄、錯誤日期及非數字讀值,輸出成功資料與錯誤清單。
挑戰B|資料品質報告
除平均值外,列出總筆數、有效筆數、錯誤筆數、日期範圍及各場域資料量。
挑戰C|公開版CSV
建立不含養殖戶姓名、精確位置及設備識別資訊的公開版,請場域合作夥伴確認欄位與授權範圍。

十五、與生成式AI協作

請擔任Python CSV與智慧養殖資料品質助教。
請檢查我的巡查CSV程式:
1. 使用csv模組,不可自行split逗號;
2. 明確處理UTF-8與newline="";
3. 先檢查標題列,再逐列驗證;
4. 單筆錯誤不能拖垮整批;
5. 無效值不可當成0或正常;
6. 匯出時防範試算表公式注入;
7. 公開資料不得包含個資、精確位置或設備憑證。
請提供測試資料與預期結果。
本篇小結
CSV看似簡單,可靠使用卻需要處理編碼、換行、引號、資料型別與錯誤列。先驗證、再計算、最後才匯出,才能讓場域資料真正可用。

Python一下:用類別打造智慧養殖場域與感測器物件

《Python一下:從風土資料到智慧生活》第 12 篇

用類別打造智慧養殖場域與感測器物件

把場域資料與操作規則放在一起,讓程式更接近真實世界的設備與管理工作。

CH8 類別物件導向智慧養殖
學習目標
完成本篇後,你能建立類別與物件,理解 self、建構子、實例屬性、類別屬性與方法;能使用屬性驗證、類別方法、組合及繼承,建立多場域感測資料模型。

一、只用字典有什麼問題?

字典很適合保存一筆資料,但資料與規則分散後,容易寫錯鍵名,也難以確保每座場域都具有相同欄位。

site = {
    "name": "示範池 A",
    "temperature": 27.2,
    "ph": 7.4,
}

if 24.0 <= site["temperature"] <= 30.0:
    print(site["name"], "水溫在示意範圍內")

若每次檢查都重新寫判斷式,規則很可能不一致。類別可以定義共同藍圖,物件則是依藍圖建立的個別場域。

概念智慧養殖比喻Python 表示
類別 class場域資料的設計規格AquacultureSite
物件 object依規格建立的某座場域site_a
屬性 attribute名稱、水溫、pHsite_a.temperature
方法 method更新讀值、產生摘要site_a.summary()
場域安全提醒:本篇所有門檻與資料均為程式教學示意。不同養殖物種、成長階段、季節與設備會有不同需求,真實控制系統必須加入校正、人工覆核、故障保護與專業設定。

二、第一個場域類別

__init__ 是建立物件時自動執行的建構方法;self 代表目前正在操作的物件。

class AquacultureSite:
    def __init__(self, name, temperature, ph):
        self.name = name
        self.temperature = temperature
        self.ph = ph

    def summary(self):
        return (
            f"{self.name}|水溫 {self.temperature}°C"
            f"|pH {self.ph}"
        )


site_a = AquacultureSite("示範池 A", 27.2, 7.4)
site_b = AquacultureSite("示範池 B", 28.1, 7.1)

print(site_a.summary())
print(site_b.summary())

site_a 與 site_b 各自保存一份實例屬性。呼叫 site_a.summary() 時,Python 會把 site_a 自動傳給 self。

三、讓物件負責自己的資料更新

不要任由外部程式到處修改屬性,可建立方法統一驗證流程。

class SensorReading:
    def __init__(self, sensor_id, value, unit):
        self.sensor_id = sensor_id
        self.unit = unit
        self.update(value)

    def update(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("感測值必須是數字")
        self.value = float(value)

    def display(self):
        return f"{self.sensor_id}: {self.value}{self.unit}"


temperature = SensorReading("TEMP-01", 27.2, "°C")
temperature.update(27.6)
print(temperature.display())

raise 會主動產生例外,阻止不合理資料進入物件。下一篇將完整學習如何捕捉與處理例外。

四、使用 property 驗證屬性

property 讓外部仍以屬性方式讀寫,但實際上會經過驗證方法。前置底線 _value 表示這是內部使用的慣例名稱,並非真正禁止存取。

class PHSensor:
    def __init__(self, sensor_id, value):
        self.sensor_id = sensor_id
        self.value = value

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_value):
        new_value = float(new_value)
        if not 0.0 <= new_value <= 14.0:
            raise ValueError("pH 必須介於 0 到 14")
        self._value = new_value


sensor = PHSensor("PH-01", 7.4)
sensor.value = 7.2
print(sensor.value)

五、實例屬性與類別屬性

實例屬性屬於個別物件;類別屬性由所有物件共享,適合記錄共同版本或物件數量。

class Device:
    platform_name = "水井村智慧巡查教學平台"
    device_count = 0

    def __init__(self, device_id):
        self.device_id = device_id
        Device.device_count += 1


device_a = Device("TEMP-01")
device_b = Device("PH-01")

print(Device.platform_name)
print("裝置數量:", Device.device_count)
判斷方法:每個物件可能不同的資料放在 self;整個類別共同使用的資料才放成類別屬性。可變的串列或字典若設成類別屬性,所有物件會共用同一份,須特別小心。

六、類別方法:從字典建立物件

@classmethod 接收類別本身 cls,常用於提供另一種建立物件的方式。

class AquacultureSite:
    def __init__(self, name, temperature, ph):
        self.name = name
        self.temperature = float(temperature)
        self.ph = float(ph)

    @classmethod
    def from_dict(cls, data):
        return cls(
            data["場域"],
            data["水溫"],
            data["pH"],
        )

    def summary(self):
        return f"{self.name}|{self.temperature}°C|pH {self.ph}"


data = {"場域": "示範池 C", "水溫": 26.8, "pH": 7.3}
site_c = AquacultureSite.from_dict(data)
print(site_c.summary())

七、組合:一座場域擁有多個感測器

「場域擁有感測器」適合使用組合,而不是繼承。場域物件內保存感測器物件,讓兩種責任保持清楚。

class Sensor:
    def __init__(self, sensor_id, kind, value, unit):
        self.sensor_id = sensor_id
        self.kind = kind
        self.value = float(value)
        self.unit = unit

    def display(self):
        return f"{self.kind}: {self.value}{self.unit}"


class Site:
    def __init__(self, name):
        self.name = name
        self.sensors = []

    def add_sensor(self, sensor):
        if not isinstance(sensor, Sensor):
            raise TypeError("只能加入 Sensor 物件")
        self.sensors.append(sensor)

    def report(self):
        lines = [f"【{self.name}】"]
        lines.extend(sensor.display() for sensor in self.sensors)
        return "\n".join(lines)


site = Site("示範池 A")
site.add_sensor(Sensor("TEMP-01", "水溫", 27.2, "°C"))
site.add_sensor(Sensor("PH-01", "pH", 7.4, ""))
print(site.report())

八、繼承:建立不同種類的感測器

若多種感測器都具有編號、數值與顯示功能,可把共同部分放在父類別,再由子類別補充差異。

class Sensor:
    def __init__(self, sensor_id, value):
        self.sensor_id = sensor_id
        self.value = float(value)

    def status(self):
        return "尚未定義判斷規則"


class TemperatureSensor(Sensor):
    def status(self):
        return "讀值已收到,請依場域標準判讀"


class PHSensor(Sensor):
    def status(self):
        if 0.0 <= self.value <= 14.0:
            return "讀值格式合理,仍須依場域標準判讀"
        return "讀值超出 pH 理論範圍,請檢查設備"


sensors = [
    TemperatureSensor("TEMP-01", 27.2),
    PHSensor("PH-01", 7.4),
]

for sensor in sensors:
    print(sensor.sensor_id, sensor.status())

兩個子類別都能呼叫 status(),卻可有不同實作,這稱為多型。外部程式不必用大量 if 判斷感測器種類。

九、組合還是繼承?

關係設計方式例子
「是一種」is-a可考慮繼承pH 感測器是一種感測器
「擁有」has-a優先考慮組合養殖場域擁有多個感測器

不要為了使用類別而建立很深的繼承層次。若兩個物件只是合作關係,組合通常更容易替換與測試。

十、物件如何轉回可儲存資料?

物件不能直接假設可以存進 JSON。先提供 to_dict(),把需要公開的欄位轉成基本資料型別。

class Sensor:
    def __init__(self, sensor_id, kind, value, unit):
        self.sensor_id = sensor_id
        self.kind = kind
        self.value = float(value)
        self.unit = unit

    def to_dict(self):
        return {
            "sensor_id": self.sensor_id,
            "kind": self.kind,
            "value": self.value,
            "unit": self.unit,
        }


sensor = Sensor("TEMP-01", "水溫", 27.2, "°C")
print(sensor.to_dict())
資料治理提醒:公開資料前應移除可辨識養殖戶、精確位置、設備憑證及未授權營運資訊。類別封裝方便程式設計,但不會自動完成個資或資安保護。

十一、常見錯誤除錯表

現象可能原因修正方式
方法缺少參數錯誤忘記在實例方法第一個參數寫 self補上 self,並由物件呼叫
所有物件共用同一串列把可變串列設成類別屬性在 __init__ 建立實例串列
建立物件時立即出錯建構子參數數量或型別不符檢查類別介面並加入清楚驗證
屬性名稱打錯AttributeError統一命名,必要時提供 property
繼承後父類別資料不存在子類別未呼叫父類別建構子使用 super().__init__(...)

十二、USR 實作挑戰

挑戰 A|七座場域物件
建立七個匿名教學場域物件,每座加入水溫與 pH 感測器,再輸出巡查摘要。公開展示時不要放入養殖戶姓名或精確位置。
挑戰 B|設備狀態
替 Sensor 加入最後更新時間與連線狀態,建立 is_stale() 方法判斷資料是否過期。
挑戰 C|共同定義規則
與養殖戶共同討論規則應放在哪個物件,區分「感測器讀值是否合理」與「養殖管理是否需要處理」。

十三、與生成式 AI 協作

請擔任 Python 物件導向設計助教。
我要建立智慧養殖教學系統,包含場域、感測器與巡查報告。
請先用「是一種」與「擁有」分析類別關係,再提出簡潔的類別設計。
要求:
1. 不建立過深的繼承;
2. 感測資料必須驗證型別;
3. 不自行設定真實養殖安全門檻;
4. 提供 to_dict() 供後續存檔;
5. 說明哪些資料不宜公開。
本篇小結
類別是資料與操作規則的共同藍圖,物件則代表個別場域或設備。先釐清責任,再選擇組合或繼承,才能讓程式隨場域擴充而不失去可讀性。