Day 10 把儀表板接上真實資料的時候,有一張圖沒有接上。
是「訓練負荷 ATL / CTL / TSB」,底下的線長得很漂亮,起伏規律、週期分明—但目前還是 Mock Data。
今天要來更新他!
8 月初做過一次可行性測試,結論已經有寫到:
模型可行,但資料不夠,涵蓋 29 天,其中有訓練的只有 8 天。
CTL 是 42 日 EMA、從 0 起算,約需 126 天才穩定。
於是那時候先把它記成「要等資料累積」,所以往後排。
今天回頭看那份測試才發現,那個評估有點問題。
當時餵給模型的是 coach/incoming/garmin_*.json—那是 Telegram bot 抓下來暫存的檔案,去重之後 14 筆。
但那 14 筆不是全部的資料,那只是最近幾週剛好被抓下來的。Garmin 帳號裡從年初到現在的活動一直都在,但沒有往前抓更多筆資料。
修改一下參數:
acts = garmin_sync.fetch_recent(480)
回來 120 筆活動,2026-01-14 起算,涵蓋 75 個不同日期,每一筆都有心率。
這樣資料就足夠了!
TRIMP(TRaining IMPulse,訓練衝量)把一次訓練換算成一個數字:時間乘上強度。跑一小時輕鬆跑和跑一小時間歇,時間一樣,數字差很多。
有了每天的 TRIMP,就能疊出另外三個:
| 全名 | 是什麼 | |
|---|---|---|
| ATL | Acute Training Load | 近 7 天的加權平均—現在有多累 |
| CTL | Chronic Training Load | 近 42 天的加權平均—體能累積到哪 |
| TSB | Training Stress Balance | CTL − ATL,兩者的差—狀態 |
直覺是這樣:練得多,ATL 衝得快、CTL 爬得慢,TSB 變負的,那就是偏累。假如在休息幾天後,ATL 就會掉得快、CTL 幾乎不動,TSB 轉正,那就是恢復了。
所以 TSB 是負的不代表壞事—練到一半本來就該是負的,正的通常出現在減量或休賽期。
這套模型在各種訓練系統常出現,我們要做的只是把自己的資料餵進去。
第二個原本以為的障礙:這個系列從第一天就在講 FIT 檔,我以為算負荷得去解逐秒的心率序列。
Banister TRIMP 的公式是這樣:
HRr = (平均心率 − 靜止心率) / (最大心率 − 靜止心率)
TRIMP = 時長(分) × HRr × 0.64 × e^(1.92 × HRr)
它只要兩個數字:平均心率、時長。
而 Garmin 的活動摘要 API 本來就給這兩個:
{'start': '2026-08-29 18:00:42', 'name': '6000m*2(r180's)2:00/400m',
'duration_s': 3781.7, 'avg_hr': 163.0, 'max_hr': 177.0}
那個 e^(1.92 × HRr) 是整條公式的重點,它讓強度的權重非線性放大—同樣一小時,配速課表的負荷遠高於輕鬆跑,而不是線性多一點。
公式裡的靜止心率和最大心率需要自己填,這兩個數字的取得方式完全不同,剛好是個對照。
靜止心率:Garmin 直接給。 get_rhr_day() 有現成的每日數值,近 14 天落在 44–50,取 47。
最大心率:公式和手錶不一樣。
常見的年齡推估有兩條:220 − 年齡 給我 191,Tanaka 公式 208 − 0.7 × 年齡 給 188。
而實際資料裡,192 出現過五次—3/28、5/21、7/16、8/11、8/25。
差距不大,但方向很明確:自己的實測值比兩條公式都高。 五次獨立觀測不太可能都是雜訊。所以用 192。
# 靜止心率:Garmin 近 14 天實測 44~50,取 47。
HR_REST = 47
# 最大心率:五次獨立觀測都摸到 192(2026-03/28、05/21、07/16、08/11、08/25),
# 比年齡推估(220-29=191、Tanaka 188)略高,採實測值。
HR_MAX = 192
註解寫上來源,提醒自己一下。
一次課表在 Garmin 裡常常是三筆活動:暖身、主課表、收操。8/27 那天就是 631 秒 + 2013 秒 + 302 秒。
疲勞不會因為你中間按了停止就分開結算,所以同一天的 TRIMP 相加:
out: dict[str, list[float]] = defaultdict(list)
for a in acts:
d = (a.get("start") or "")[:10]
hr, dur = a.get("avg_hr"), a.get("duration_s")
if not hr or not dur:
continue # 沒心率的活動跳過,不猜
out[d].append(trimp(dur, hr))
return {d: (sum(v), len(v)) for d, v in out.items()}
那個 continue 是刻意的。沒有心率就跳過,不用其他欄位去估—估出來的負荷混在真的裡面,之後分不出來哪個是哪個。
ATL 和 CTL 是指數移動平均(EMA),所以不能只在有訓練的日子往前推:
d = start
while d <= end:
t, n = per_day.get(d.isoformat(), (0.0, 0))
atl += (t - atl) / 7 # 短期疲勞
ctl += (t - ctl) / 42 # 長期體能
d += timedelta(days=1)
沒訓練的日子 TRIMP 是 0,但那個 0 要參與計算。休息會讓 ATL 掉得比 CTL 快(7 天的常數比 42 天靈敏),TSB 因此轉正—那正是「練完休息,狀態回來」在計算上的樣子。
算完第一版,圖上是一條從 1 月開始一路往上的 CTL。看起來像今年進步神速。
但去對數字就知道不對:
| 日期 | 當日 TRIMP | CTL |
|---|---|---|
| 01-03 | 165.8 | 3.9 |
| 01-23 | 0.0 | 21.8 |
| 02-12 | 41.2 | 45.1 |
| 03-04 | 17.8 | 50.7 |
1/3 那天練了 TRIMP 165.8 的量,圖上顯示體能 3.9。
不是因為當日狀態不好,因為程式裡 ctl = 0.0 從零起算,而 42 日 EMA 要跑滿約 42 天才收斂到真實水準。到 2 月中爬到 45 附近,之後才穩。
前六週那段爬升來自資料的累積,不是身體的變化。
8 月的可行性測試其實已記錄這個發現,但當時 29 天的資料全部都在恢復期內,整條曲線不太能參考。現在資料有 240 天,暖機期只佔開頭一小段,處理方式就簡單了:裁掉。
# CTL 從 0 起算,要約 42 天才收斂到真實水準,前面那段是計算暖機造成的
# 假性爬升 — 實測 01-03 當天 TRIMP 165.8,CTL 卻只有 3.9。故輸出時裁掉。
WARMUP_DAYS = CTL_DAYS
EMA 一路算滿,輸出時砍掉 CTL 還沒收斂的前 42 天(WARMUP_DAYS = CTL_DAYS),剩下的才寫進 load.json。
這邊需要澄清,因為它不是暫時的限制。
sessions.json 裡 56 筆訓練紀錄,29 筆是重訓,而重訓時我沒戴錶,Garmin 沒有那些活動的心率—所以它們一筆都不在這條曲線裡。
這不是漏做,是這個做法的邊界,TRIMP 是心率模型,沒有心率就沒有輸入。
所以我把它寫進程式的檔頭:
## 這條曲線看不到什麼
**重訓不在裡面。** 重訓時沒戴錶,Garmin 沒有那些活動的心率,所以這條曲線
只反映有氧負荷。腿的痠不會出現在圖上。
也寫進畫面上那張圖的底下。因為看圖的人不會去讀 Python 檔頭,而一條標著「訓練負荷」的線,讀者預設它涵蓋了全部訓練,但它只有跑步。
跑完的近十天:
| 日期 | TRIMP | ATL | CTL | TSB |
|---|---|---|---|---|
| 08-25 | 92.0 | 56.1 | 31.4 | −24.7 |
| 08-27 | 100.3 | 55.6 | 32.3 | −23.2 |
| 08-29 | 149.9 | 62.3 | 34.4 | −27.9 |
| 08-30 | 0.0 | 53.4 | 33.6 | −19.8 |
| 08-31 | 0.0 | 45.7 | 32.8 | −13.0 |
| 09-01 | 92.2 | 52.4 | 34.2 | −18.2 |
8/29 那天 TSB 掉到 −27.9,短期疲勞(ATL 62)是長期體能(CTL 34)的將近兩倍—數學上就是「最近練的量遠超過平常的水準」。
而這跟我自己的紀錄對得起來—8/27 那天 AI 助教就寫過「本週已疊 16K + VO2max + 2000m×3」。一邊是主觀的「這週有點多」,一邊是 −27.9。
接下來兩天沒練。同樣兩天,ATL 掉了 16.6,CTL 只掉 1.6—疲勞退得快,體能退得慢,這就是 7 天常數和 42 天常數的差別。
TSB 因此從 −27.9 回到 −13.0。然後 9/1 又練了一場 2000m×3,掉回 −18.2。
單日最高負荷是 4/25,TRIMP 641.7:3 小時 57 分、平均心率 169。那天的量是這半年任何一天的三倍以上,而且圖上一眼就分得出來。
TSB −18.2 不等於「該休息了」。
這是個純粹的心率積分模型:它不知道我睡得好不好、不知道那 29 次重訓、不知道下週課表要幹嘛,更不知道 12/20 臺北馬在多久之後。
它只是把「量 × 強度」隨時間攤開來,讓一個原本只有體感的東西有了一條可以指的線。
要不要調整課表是要和教練討論的事,這條線的用處是:下次跟教練說「這週有點累」的時候,旁邊多一個 −27.9。
資料到今天算是齊了—訓練紀錄、體重、課表、負荷曲線。
但教練沒辦法登入我的儀表板,他要的是一頁看得完的東西。
明天讓 AI 助教把這週的資料整理成一份週報,這是整個系列第一次,產出的東西給教練評估!