前 22 天已分別做過 PPG 視窗化心率與品質判定、日資料對齊與缺值標記、personal baseline、deviation detection,以及交給 LLM 前的 feature contract。Day 19 的範例 payload 能組出一個解讀日的資料,Day 21–22 也測過模型如何使用給定的摘要;但各段能單獨運作,不代表從資料入口到證據輸出的介面已經接得起來。若整合時丟掉品質原因、有效觀測數或「無法判定」狀態,下游仍可能收到格式正確、意思卻變了的數字。
今天先在 LLM 之前做一次整合實驗:以 wearable-ai run 作為入口,讓適用的訊號處理、品質檢查、對齊、baseline、deviation 與 feature contract 走到可檢查的 evidence bundle。PPG-DaLiA、LifeSnaps 和合成情境各自提供不同粒度與可驗證的資訊;我會用它們檢查資料分支、補上必要測試,並記錄真正串接後才看得見的介面不一致。這些是預定產出,實際能跑通哪些路徑、在哪一層失敗,留到 Hands-on 驗證。
PPG-DaLiA 的腕式 PPG 可走 Day 8 的逐窗心率與 Day 11 的品質三態;品質判定附原因碼,不能把「有心率」直接當成「可用」。LifeSnaps 則已是日層級匯出,走 Day 12–13 的日期對齊與缺值標記,再進入 Day 16–18 的 baseline、deviation 與跨指標分類。合成情境可提供已知注入事件,測試這些規則在指定條件下的行為。三者可以共用 CLI 入口與輸出檢查,但資料本身沒有相同的起點:現行的 feature-contract-v3(Day 19 定義、Day 20 升版)只涵蓋日層級,尚未把 PPG 視窗品質接成同一份日 payload。整合時要把這個邊界明示出來,不能靠補一個欄位就假裝已從 PPG 推出夜間 HRV 或完整的每日洞察。
Day 12 的同一個解讀日可能放進不同量測時窗;Day 13 的空值也可能只是來源沒有交代原因,不能一律推成未佩戴。到了 baseline,Day 16 分開記錄視窗、有效觀測數、狀態與凍結;Day 17 的 decidable=false 表示條件不足,不能在下一層翻成「沒有偏離」。Day 18 的跨指標標記還要保留各指標的方向和無法判定原因。這些不是附註,而是 evidence bundle 解讀數值所需的條件。
我會把每個資料分支的輸入身分、日期歸屬、單位、品質或缺值狀態、規則版本與輸出欄位逐層核對。特別要查 Day 19 已指出的命名落差:偏離模組輸出的 rule_version 到跨指標 wide 表叫 deviation_version;Day 19 已在 payload 層把兩者對應到 contract 的 RuleVersions.deviation;若 CLI 繞過 payload_for、自行按同名欄位拼接,仍會遺失版本,其他層有沒有類似的命名落差也要逐一確認。schema 驗證能擋掉部分缺欄與矛盾狀態,仍不能證明日期、單位或來源的語意正確,這些要靠對照原始資料與中間輸出檢查。
一個指令跑到底的驗收,不只是成功印出 JSON。至少要能區分資料讀取失敗、沒有足夠品質或有效觀測、baseline 尚未可用、偏離無法判定,以及 payload 不符合 contract;各種情況都應留下可追查的原因,而不是讓後一層自行猜測。對已有日層級資料的路徑,可用 Day 19 的 payload 規則檢查數值與狀態是否一致;對 PPG 路徑,先檢查視窗心率及品質輸出,日層級橋接若仍缺定義就明確停在該層。這次要測的是既有模組能否在相同資料口徑下接線,不能把合成情境的事件標籤或 PPG-DaLiA 的 ECG 參考偷放進正式 evidence bundle;它們只適合用來評估輸出。
suspected_illness(Day 14,seed 1403):一人 60 天,起日 2026-01-05。第 35–39 天(2026-02-09~02-13)注入 RHR +5 bpm、RMSSD −12 ms、步數 −4000,之後 3 天恢復,事件期沒戴的機率放大 3 倍。睡眠沒有注入。步數不在 contract 的三個指標裡。wearable-ai run synthetic、run lifesnaps 走日層級:對齊 → baseline → deviation → 跨指標標記 → payload_for → summary-v1。run ppg 只到窗層級:逐窗心率、品質三態與原因碼、動作旗標。PPG 不進 payload:每位受試者只有一段約 2.5 小時的白天紀錄,進不了跨日 baseline,contract 也還沒有窗層級欄位。summary.json 是唯一送模型的檔案,寫出前做洩漏檢查(情境名稱、情境路徑、subject_id);payload.json 是 contract v3 的完整 payload;trace/ 是這個人到解讀日為止的中間表(baseline/deviation 逐日、跨指標標記、日層級缺值旗標);manifest.json 記輸入、設定、各層版本、各階段狀態與逐指標狀態。metric_states。uv run wearable-ai run synthetic --scenario suspected_illness --person p01 --date 2026-02-09 --out runs/syn_0209
uv run wearable-ai run lifesnaps --id 621e375b --round 1 --date 2021-07-09 --out runs/ls
uv run wearable-ai run ppg --subject 2 --out runs/ppg_s2
日層級 bundle:
runs/syn_0209/
├── manifest.json # 輸入、設定、版本、階段、metric_states;files 標出哪個檔 to_model
├── summary.json # summary-v1,唯一送模型
├── payload.json # feature-contract-v3
└── trace/
├── long.csv # baseline/deviation 逐日
├── labels.csv # 跨指標標記
└── day_flags.csv # 列存在、缺值種類
PPG 分支的兩個模組各自找峰,CLI 在合併前檢查兩邊的視窗對得上(src/wearable_ai/run.py):
table = q.windowed_quality(ppg, fs_ppg_hz, PPG_WINDOW_S, PPG_HOP_S)
_, bpm, n_peaks, _ = hr.windowed_hr(ppg, fs_ppg_hz, PPG_WINDOW_S, PPG_HOP_S,
method=PPG_HR_METHOD, min_peaks=PPG_MIN_PEAKS)
if len(n_peaks) != len(table) or not np.array_equal(table["n_peaks"].to_numpy(), n_peaks):
raise ValueError("windowed_quality 與 windowed_hr 的視窗數或峰數對不上")
out = q.classify_table(table, PPG_THRESHOLDS) # QualityThresholds(w_low=0.87, w_high=0.96)
先看整體:tests/test_day23.py 54 個測試通過,全部測試 1660 passed、3 skipped(含審稿後補的回歸測試)。Day 19 的 9 份範例 payload(LifeSnaps 6、合成 3)由 CLI 重產,除了 contract 版本字串,逐欄相同。PPG S1、S2 都跑完,兩個模組的峰數全部對得上,品質判可用的窗都算得出心率。
| 原本以為 | 實際發現 |
|---|---|
我原本以為 Day 18 的 baseline/deviation 設定已經是 src/ 可直接引用的共用設定,CLI 只需接上既有模組。 |
Day 18 定案的 baseline/deviation 設定(Day 18)只寫在 days/day19/payload_demo.py;Day 20 是用 importlib 從 days 腳本載入。src/ 裡沒有這組設定。CLI 只好在 src/wearable_ai/run.py 另寫一份,再用測試檢查兩份相同。 |
我原本以為呼叫 QualityThresholds() 就會沿用 Day 11 凍結的 W 門檻,換成 CLI 入口也會得到相同的品質規則。 |
Day 11 凍結的 W 門檻 0.87/0.96 只存在 days/day11/signal_quality.py;QualityThresholds() 的預設是 w_low=None, w_high=None,W 規則不啟用,也不會報錯。S2 若用預設值,可用窗從 516 變成 685,保留 622 → 925,拒答 2,961 → 2,489。 |
| 我原本以為品質判定與心率計算處理同一批視窗時,峰數自然會一致,不需要在接線處再核對。 | windowed_quality 和 windowed_hr 各自找峰,沒有共用結果;兩邊一致要靠呼叫端檢查。Day 11 用 assert,CLI 改成丟錯、結束碼 4。這次 S1、S2 都一致,usable_without_hr 為 0。 |
| 我原本以為沿用 Day 11 的切窗參數,S1、S2 的窗數就能與當天結果直接對上。 | S2 用 CLI 是 4,099 窗,Day 11 是 4,039 窗;S1 是 4,603 對 4,551。Day 11 用活動標籤丟掉跨活動交界的窗,CLI 不讀標註,所以不丟。口徑不同,兩邊三態窗數不能逐格比較。 |
我原本以為舊流程的 motion_flag=False 都表示加速度足以判斷為沒有動作,可以直接沿用到 bundle。 |
Day 11 加速度湊不滿的窗,motion_flag 用 np.zeros 填成 False,等於把「沒資料」寫成「沒動」。CLI 改填 <NA>,另計 motion_unknown。S1、S2 實際上都是 0,這次的數字沒有受影響。 |
我原本以為 rule_version 與 deviation_version 的命名落差會在 CLI 拼接 payload 時讓版本遺失。 |
CLI 經過 payload_for,manifest 的 versions.deviation 是 deviation-v2,沒有遺失。但 trace 中間表仍保留兩種命名:long.csv 叫 rule_version,labels.csv 叫 deviation_version。 |
| 我原本以為事件首日注入 RHR 上升、RMSSD 下降後,至少一項會跨過既定的 |z| > 3 門檻而標旗。 | p01 2026-02-09 三項都可判定、baseline stable、未凍結,都沒有超過 |z| > 3:RHR 65.17 bpm(center 60.66、scale 1.88,z +2.396)、RMSSD 36.92 ms(46.20/6.89,z −1.347)、睡眠 400.04 分(423.52/35.42,z −0.663)。見下表。 |
| 我原本以為首日若沒過門檻,連看五天事件期仍會出現至少一次標旗,而且每天都能產生解讀日。 | 事件期 5 天中,02-13 三個指標都沒有列,不是解讀日;用它跑 CLI 停在 target_day,結束碼 3。其餘 4 天的 12 個指標日全部可判定、沒有標旗,事件期內 |z| 最大是 02-09 RHR +2.396 與 02-12 RMSSD −2.079。完整 60 天(55 個解讀日)標旗數為 0:以標旗為偵測依據,這個事件沒有被標出。 |
事件期與前後逐日(z 值;所有列 baseline stable、未凍結、未標旗):
| 日期 | 情境日 | 期間 | 睡眠 z | RMSSD z | RHR z | 跨指標 |
|---|---|---|---|---|---|---|
| 02-08 | 34 | 事件前 | +0.625 | —(缺值) | −0.514 | undecidable |
| 02-09 | 35 | 事件 | −0.663 | −1.347 | +2.396 | none |
| 02-10 | 36 | 事件 | −1.784 | −0.219 | +0.346 | none |
| 02-11 | 37 | 事件 | −0.668 | −0.435 | +1.026 | none |
| 02-12 | 38 | 事件 | −0.970 | −2.079 | +1.805 | none |
| 02-13 | 39 | 事件 | — | — | — | 不是解讀日 |
| 02-14 | 40 | 恢復 | — | — | — | 不是解讀日 |
| 02-15 | 41 | 恢復 | +0.883 | −2.676 | +1.437 | none |
| 02-16 | 42 | 恢復 | −0.260 | −1.666 | +0.322 | none |
這次驗證的是既有模組能否接線並保留可追查的狀態,還不能把三個資料分支視為同一條端到端路徑。PPG 只產出窗層級心率與品質結果,沒有接到日層級 baseline、deviation 或 feature contract;LifeSnaps 只重產 Day 19 已有的六份範例 payload,逐欄相同能檢查回歸,不能證明新日期或新人的輸出正確。CLI 的 baseline/deviation 設定和 PPG 門檻仍與已發布腳本各存一份,測試比對可發現數值漂移,卻不能消除兩份設定需同步維護的限制。W 門檻有無啟用的數量對照只在 S2 跑過;S1 參與門檻校準,不能拿它當獨立驗證。
合成事件的「未標旗」只是在 suspected_illness、p01、seed 1403 與 D 既定規則下的觀察:60 天中只有 55 個解讀日,事件期五天有一天三項都沒有列,其餘四天雖可判定,分數都未超過門檻。因此結果說明這組注入與觀測條件沒有觸發現行標旗規則,不能推成規則對其他人、事件強度或缺值型態的偵測率。日層級 bundle 使用最終抵達的資料產生回顧摘要,並非解讀日當時可見資料的 as-of 快照;這次逐日重跑與最後一日 trace 對上,也不能據此宣稱即時情境會得到相同輸出。合成情境的事件標籤是評估用的已知注入,並非醫療診斷真值。
最危險的介面不一致不會報錯。 今天找到的問題裡,只有峰數對不上會丟例外;W 門檻用預設值、motion_flag 把沒資料填成 False、設定只存在 days 腳本,三者都會讓程式安靜地跑完,產出格式正確的表。S2 若用預設門檻,可用窗多出 169 個;下游拿到的「可用」換了定義,卻沒有任何欄位標出這件事。所以 manifest 要記下實際用的門檻與設定,測試要釘住數值,不只檢查型別。
模型看到的是上游規則的結論。 suspected_illness 的事件從頭到尾沒有被標旗,事件期的 02-13 連解讀日都不是。事件期 4 個解讀日,只拿 summary.json 的模型看到的是「三項可判定、沒有偏離」;這句話對規則來說是對的,Day 21–22 檢查的也只是模型有沒有忠於摘要。summary 已有當日的 center、scale、z 與門檻,看得出當天離門檻多遠;要看 baseline 逐日怎麼演變、整段期間有沒有任何一天標旗,才需要 trace。把送模型的檔案和追查用的中間表分開,不是為了少給模型資料,而是讓「模型說錯」和「上游沒偵測到」分得開。
失敗要有類型,非失敗的狀態也要。 結束碼分出資料不在、參數不對、contract 不符、洩漏四類,其餘例外另歸一類;輸出位置可寫時,失敗也會留下這次的 manifest,寫不了就明說紀錄沒有更新。最容易出錯的反而是邊角:初版的參數錯誤和「資料不在」共用結束碼 2,--force 失敗時目錄裡還躺著上一次成功的 manifest,呼叫端會把舊結果當成這次的;寫到一半失敗時,新 manifest 已宣告成功,summary 卻還是上一次的。「baseline 尚未可用」「偏離無法判定」則不是失敗,bundle 照產,狀態寫進 metric_states。這是 Day 24 grounding 要引用的證據:每段解讀回指的不只是數值,還有這個數值當時能不能判定、為什麼。