iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 17

Day 17|把疲勞算出來:TRIMP → ATL / CTL / TSB

  • 分享至 

  • xImage
  •  

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 是負的不代表壞事—練到一半本來就該是負的,正的通常出現在減量或休賽期。

這套模型在各種訓練系統常出現,我們要做的只是把自己的資料餵進去。


TRIMP 不需要 FIT 檔

第二個原本以為的障礙:這個系列從第一天就在講 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 助教把這週的資料整理成一份週報,這是整個系列第一次,產出的東西給教練評估!



上一篇
Day 16|對話式紀錄:把「今天 12K 有點喘」變成一筆訓練紀錄
下一篇
Day 18|給教練的訓練週報:一頁、單檔,方便閱讀
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言