COROS MCPとClaude Codeで、ランニングの記録を記事にするの最後に、坂ダッシュや流しを自動で見つけるモデルを作りたいと書いた。まず坂ダッシュと流しについて、COROSの.fitファイルから見つけるスクリプトをClaude Codeで作ったので、その中身をまとめる。
結論から書くと、機械学習ではなく、速度・ケイデンス・標高のしきい値で判定するルールにした。手動でラップを取った流し18本をすべて検出し、誤検出は0本だった。
何に困っていたか
日曜は坂練習で、坂の区間に100m前後の坂ダッシュを入れている。しかし、ダッシュごとにラップは取っていない。そのため、1kmごとのラップをLLMに分析させると、坂の区間を5:30/km前後でゆっくり走っていると解釈してしまう。坂ダッシュをしていることに気づかず、ジョグだと認識する。
これまでは、何本走ったかをこちらから説明して記事に書いていた。データから本数や1本ごとの中身を出せれば、毎週説明しなくて済む。また、数字の並びを毎回LLMに読ませて判断させるのは、tokenの無駄も大きい。
.fitファイルの1秒ごとの記録
COROS MCPのqueryActivityFitFileDownloadUrlsに、ランのlabelIdを渡すと、.fitファイルのダウンロードURLが返ってくる。.fitファイルには、1秒ごとの記録(record)が入っている。今回使ったのは次の項目。
| 項目 | 内容 |
|---|---|
enhanced_speed | 速度(m/s) |
cadence | ケイデンス。片脚の回数なので、2倍して歩/分にする |
enhanced_altitude | 標高(m、1m単位) |
distance | 走り始めからの距離(m) |
power、heart_rate | パワー(W)、心拍 |
坂ダッシュは、データにどう出るか
10/18の坂ダッシュの1本を、1秒ごとの記録から5秒おきに抜き出すと次のようになる。0秒は、ケイデンスが上がり始めたところ。
| 秒 | ペース | ケイデンス | パワー | 標高 |
|---|---|---|---|---|
| −5 | 5:56 /km | 168 | 151 W | 26 m |
| 0 | 4:49 /km | 188 | 175 W | 26 m |
| 5 | 3:33 /km | 204 | 227 W | 26 m |
| 10 | 3:08 /km | 204 | 307 W | 27 m |
| 15 | 3:18 /km | 200 | 400 W | 28 m |
| 20 | 3:30 /km | 196 | 450 W | 30 m |
| 25 | 5:26 /km | 194 | 445 W | 32 m |
| 30 | 8:18 /km | 164 | 391 W | 34 m |
データをMCPで取得して分かったことは次のとおり。
- ケイデンスはすぐ上昇する:走り出した瞬間に190前後まで上がり、止まるとすぐ下がる。
- 速度は遅れて上昇する:GPSの速度はならされていて、最高速度になるのは走り出して10秒ほどたってから。
- パワーはさらに遅れて上昇する:パワーがいちばん高くなるのは、ダッシュの終わりごろ。
- 標高は終わったあとも上昇する:ダッシュが終わったあとも、数秒間は標高が上がり続ける。これは坂の途中でダッシュを終えるからである。
そこで、ダッシュがあるかどうかは速度とケイデンスで見つけ、始まりと終わりはケイデンスで決めることにした。
判定のしかた
- 速度の平均をとる:1秒ごとの速度を3秒平均にする。
- 巡航速度を決める:前後90秒の速度の中央値を、その時点の巡航速度にする。中央値なので、20〜60秒のダッシュ自体には引っ張られにくい。
- ダッシュ中の時間を選ぶ:次の3つを満たす時間を「ダッシュ中」とする。
- 速度が巡航速度の1.3倍以上
- 4:10/km(4.0m/s)より速い
- ケイデンスが180歩/分以上
- 区間にまとめる:ダッシュ中の秒をつなげて区間にする。3秒以内の途切れはつなぐ。
- 距離で絞る:8〜90秒・40〜400mの区間だけ残す。1kmのペース走のように長く速い区間は、ここで外れる。そうした区間では巡航速度も上がるので、そもそも1.3倍に届きにくい。
- 始まりと終わりを決め直す:ケイデンスが190歩/分以上の範囲まで、区間を広げる。
- 坂か平地かを分ける:区間の開始点から、終わりの10秒後までの最高点の差を「上り」とする。3m以上かつ勾配3%以上なら坂ダッシュ、そうでなければ流し。
判定の中心は、次の部分。
def detect(rows):
speed = _rolling_mean([r["speed"] for r in rows], 1)
alt = _rolling_mean([r["alt"] for r in rows], 2)
baseline = []
for i in range(len(rows)):
w = speed[max(0, i - BASELINE_SEC): i + BASELINE_SEC + 1]
baseline.append(statistics.median(w))
hot = [speed[i] >= max(MIN_SPEED, baseline[i] * SPEED_RATIO) and rows[i]["cad"] >= MIN_CADENCE
for i in range(len(rows))]
# (hot の秒をつなげて segments にする部分は省略)
dashes = []
for a, b in segments:
sec = rows[b]["t"] - rows[a]["t"] + 1
dist = rows[b]["dist"] - rows[a]["dist"]
if not (MIN_SEC <= sec <= MAX_SEC and MIN_M <= dist <= MAX_M):
continue
# 速度は GPS でならされて立ち上がりが遅れるので、始まりと終わりはケイデンスで決める
while a > 0 and rows[a - 1]["cad"] >= EDGE_CADENCE:
a -= 1
while b < len(rows) - 1 and rows[b + 1]["cad"] >= EDGE_CADENCE:
b += 1
# 終わりの 10 秒後までの最高点で上りを測る
tail = min(len(rows) - 1, b + 10)
gain = max(alt[a: tail + 1]) - alt[a]
# (このあと、時間・距離・最高速度・上りなどを1本ごとにまとめる)
1本ごとに、時間、距離、平均ペース、最高速度、上った高さ、最大ケイデンス、最大パワー、最大心拍を出す。記事を書くときは、この結果だけをClaude Codeに渡せばよく、数字の並びをLLMに読ませなくてよくなった。
機械学習にしなかった理由
最初は機械学習のモデルを作るつもりだったが、次の理由でルールにした。
- 正解データが少ない:ダッシュの本数が分かっているランは、まだ数本しかない。これで学習させても、うまくいったかどうかを確かめられない。
- 高い説明性:ルールなら、なぜダッシュと判定したかが分かる。外れたときに、どのしきい値を直せばいいかも分かる。
- ルールベースで十分に分類できる:上の表のとおり、ダッシュはケイデンスと速度がはっきり跳ね上がる。普段のジョグやペース走と混ざらない。
答え合わせ
流しは、火・水・金・土に手動でラップを取っている。150〜350mで4:00/kmより速いラップを正解にして照合した。坂ダッシュは、自分で数えた本数と比べた。
| 日付 | 練習 | 正解 | 検出 | 誤検出 |
|---|---|---|---|---|
| 10/13(火) | 中強度+流し | 約310m×4本+約160m×1本 | 流し5本 | 0本 |
| 10/14(水) | 高強度+流し | 約220m×5本 | 流し5本 | 0本 |
| 10/16(金) | 中強度+流し | 約320m×4本 | 流し4本 | 0本 |
| 10/17(土) | 高強度+流し | 約320m×4本 | 流し4本 | 0本 |
| 10/12・10/15 | 低強度 | なし | 0本 | 0本 |
| 10/11(日) | 坂練習 | 7本ほど | 坂ダッシュ7本 | − |
| 10/18(日) | 坂練習 | 6本 | 坂ダッシュ6本 | − |
流しはラップの18本をすべて検出し、ラップと重ならない検出はなかった。水曜は8kmを平均3:36/km(最後の1kmは3:20/km)で走っているが、これも流しとは判定されなかった。
ただし、距離と時間はラップより少し短く出る。たとえば、ラップで310m・60秒の流しは、285〜313m・56〜61秒になった。全体として、距離で5〜10%、時間で1〜3秒短い。坂ダッシュの距離は約100〜110mと出た。
10/18の坂ダッシュ6本は、1本25〜28秒で約8〜9m上り、最高速度は3:08〜3:29/kmだった(週間ランニング記録:2026/10/12〜10/18に載せた)。
苦手なところ
- 答え合わせの量が少ない:流しは4日分、坂ダッシュは2日分だけ。
- 短いインターバルとの区別:ポイント練習で200m×N本のような短い速い区間を走ると、それも「流し」に数えられる。データの形がほぼ同じなので、ルールでは分けられない。ラップを取っている日は、ラップのほうが確実。
- 速いペース走のすぐあとの流し:巡航速度が高いままなので、1.3倍に届かず見逃すおそれがある。今回のデータでは、流しの前にジョグがあったので問題は出ていない。
- ゆるい坂での流し:3m以上上ると「坂ダッシュ」になる。標高は1m単位の値。
- 最高速度は遅めに出る:GPSの速度はならされているので、短いダッシュでは実際より少し遅めに出る。
これから
- インターバルの速い区間とつなぎも、.fitファイルから取り出したい。今はラップから取り出しているが、ラップを取り忘れた日に困る。
- 本数が分かっている日を増やして、答え合わせを続ける。正解データがたまったら、機械学習のモデルも試してみたい。