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秒は、ケイデンスが上がり始めたところ。

秒ペースケイデンスパワー標高
−55:56 /km168151 W26 m
04:49 /km188175 W26 m
53:33 /km204227 W26 m
103:08 /km204307 W27 m
153:18 /km200400 W28 m
203:30 /km196450 W30 m
255:26 /km194445 W32 m
308:18 /km164391 W34 m

データをMCPで取得して分かったことは次のとおり。

  • ケイデンスはすぐ上昇する:走り出した瞬間に190前後まで上がり、止まるとすぐ下がる。
  • 速度は遅れて上昇する:GPSの速度はならされていて、最高速度になるのは走り出して10秒ほどたってから。
  • パワーはさらに遅れて上昇する:パワーがいちばん高くなるのは、ダッシュの終わりごろ。
  • 標高は終わったあとも上昇する:ダッシュが終わったあとも、数秒間は標高が上がり続ける。これは坂の途中でダッシュを終えるからである。

そこで、ダッシュがあるかどうかは速度とケイデンスで見つけ、始まりと終わりはケイデンスで決めることにした。

判定のしかた

  1. 速度の平均をとる:1秒ごとの速度を3秒平均にする。
  2. 巡航速度を決める:前後90秒の速度の中央値を、その時点の巡航速度にする。中央値なので、20〜60秒のダッシュ自体には引っ張られにくい。
  3. ダッシュ中の時間を選ぶ:次の3つを満たす時間を「ダッシュ中」とする。
    • 速度が巡航速度の1.3倍以上
    • 4:10/km(4.0m/s)より速い
    • ケイデンスが180歩/分以上
  4. 区間にまとめる:ダッシュ中の秒をつなげて区間にする。3秒以内の途切れはつなぐ。
  5. 距離で絞る:8〜90秒・40〜400mの区間だけ残す。1kmのペース走のように長く速い区間は、ここで外れる。そうした区間では巡航速度も上がるので、そもそも1.3倍に届きにくい。
  6. 始まりと終わりを決め直す:ケイデンスが190歩/分以上の範囲まで、区間を広げる。
  7. 坂か平地かを分ける:区間の開始点から、終わりの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ファイルから取り出したい。今はラップから取り出しているが、ラップを取り忘れた日に困る。
  • 本数が分かっている日を増やして、答え合わせを続ける。正解データがたまったら、機械学習のモデルも試してみたい。