はじめに
前回、Savitzky–Golayフィルタを導入して標高データの形状をできるだけ保ちながら平滑化を試みました。しかし、実際に複数のFITファイルで獲得標高を計算してみると、サイコン本体の表示値との誤差がまだ残るケースが目立ちました。
特に問題だったのは以下の2点です:
- 平坦路や緩やかなローリングヒルで微小な上下動(ノイズ)が過剰にカウントされ、獲得標高が過大になる
- 一方で、急峻なヒルクライムでは比較的良好に一致する
単純に「現在の高度 - 前の高度 > minDiff なら加算」という方法では、微細な揺らぎや路面の凹凸が積み重なってしまい、
現実の「登った感覚」と乖離してしまいます。
そこで導入したのがヒステリシス(hysteresis)付きの区間ベース上昇検出です。
ヒステリシスロジックとは
ヒステリシスとは、「状態が変化する際に、上昇時と下降時で異なる閾値を用いる」 手法です。これにより、小さなジグザグを無視しつつ、意味のある連続した上昇区間だけをカウントできます。
- 上昇開始の判定: 直近の最低点から
ascThreshold メートル上昇したら「上昇開始」とみなす
- 下降開始の判定: 直近の最高点から
descThreshold メートル下降したら「下降開始(上昇終了)」とみなす
- ascThreshold > descThreshold とすることで、ノイズによる誤判定を防止する
アルゴリズムの詳細
状態遷移
アルゴリズムは2つの状態を持ちます:
- descending(下降中): 最低点(
lastLow)を更新しながら、上昇開始を待つ
- ascending(上昇中): 最高点(
lastHigh)を更新しながら、下降開始を待つ
実装
state := "descending"
lastLow := altitudes[0]
lastHigh := altitudes[0]
for each point in smoothedAltitudes {
current := point
if state == "descending" {
if current > lastLow + hystAsc {
state = "ascending"
lastHigh = current
} else {
lastLow = min(lastLow, current)
}
} else {
if current < lastHigh - hystDesc {
gain += lastHigh - lastLow
state = "descending"
lastLow = current
} else {
lastHigh = max(lastHigh, current)
}
}
}
if state == "ascending" {
gain += lastHigh - lastLow
}
図解
標高
^
| /\ lastHigh
| / \
| / \ /\
| / \ / \
| / \ / \
| / \/ \
| / lastLow \
| / \
+---------------------------> 時間
|← asc →|
|← desc →|
- 下降中(
descending)に最低点(lastLow)を更新し続ける
lastLow + ascThreshold を超えたら上昇状態に遷移
- 上昇中(
ascending)に最高点(lastHigh)を更新し続ける
lastHigh - descThreshold を下回ったら下降状態に遷移し、lastHigh - lastLow を獲得標高として加算
最適値を検索
go run main.go --tune --hyst-asc-min=2.0 --hyst-asc-max=3.0 --hyst-asc-step=0.2 --hyst-desc-min=1.0 --hyst-desc-max=2.5 --hyst-desc-step=0.2 Activities/*.fit
結果は、前回よりもサイコン本体の表示値に近づきました。
前回は、移動平均でスムージング後に単純に「現在の高度 - 前の高度 > minDiff なら加算」というロジックでした。
だいぶいい感じになりました。
実際の実験結果
ある走行で、以下の3つの獲得標高を比較してみました。
| 測定方法 |
獲得標高 |
| サイコン (Bryton Rider 320) |
629 m |
| スマホ Strava アプリ |
597 m |
| 本ツール (ヒステリシス方式) |
641.79 m |
パラメータ: hysteresis-asc=2.60, hysteresis-desc=2.40, window=17
サイコンと本ツールの計算値は約2%の差異に収まりました。
一方、スマホアプリ(Strava)はサイコンより約5%低い値を示しました。この差異は、GPSセンサーの精度差や、各サービス・デバイス独自の計算アルゴリズムの違いによるものと考えられます。
サイコンから得られた標高データから「獲得標高(Elevation Gain)」を正確に算出することは、
一見簡単そうに見えて実は難しい問題ですね。
| 項目 |
フィルタ |
最適 window |
最適 hyst-asc |
最適 hyst-desc |
平均絶対誤差(MAE) |
| 前回(min-diff方式) |
移動平均 |
21 |
na |
na |
23.65 m |
| 今回(ヒステリシス方式) |
移動平均 |
17 |
2.60 |
2.40 |
12.68 m |
パラメータ探索結果 (--tune)
==================================================
対象ファイル数: 25
探索範囲: window=3..21 (奇数), hyst-asc=2.00..3.00 (step=0.20), hyst-desc=1.00..2.50 (step=0.20)
フィルタータイプ: ma
評価指標: MAE
最適 window: 17
最適 hyst-asc: 2.60
最適 hyst-desc: 2.40
平均絶対誤差(MAE): 12.68 m
ファイル別比較 (最適パラメータ適用)
--------------------------------------------------
251128104532.fit: ref=628, calc=640.98, diff=+12.98 (+2.07%)
251201074633.fit: ref=1442, calc=1434.35, diff=-7.65 (-0.53%)
251205095644.fit: ref=1056, calc=1054.22, diff=-1.78 (-0.17%)
251207101744.fit: ref=288, calc=293.21, diff=+5.21 (+1.81%)
251208101118.fit: ref=650, calc=645.99, diff=-4.01 (-0.62%)
251210095356.fit: ref=697, calc=692.48, diff=-4.52 (-0.65%)
251213100936.fit: ref=699, calc=694.16, diff=-4.84 (-0.69%)
251217075556.fit: ref=1506, calc=1471.28, diff=-34.72 (-2.31%)
251219102311.fit: ref=684, calc=692.35, diff=+8.35 (+1.22%)
251223111148.fit: ref=376, calc=377.68, diff=+1.68 (+0.45%)
251226095732.fit: ref=729, calc=713.98, diff=-15.02 (-2.06%)
251228063640.fit: ref=1138, calc=1161.04, diff=+23.04 (+2.02%)
251231095634.fit: ref=683, calc=687.21, diff=+4.21 (+0.62%)
260102085559.fit: ref=743, calc=750.87, diff=+7.87 (+1.06%)
260104110841.fit: ref=716, calc=739.68, diff=+23.68 (+3.31%)
260110110319.fit: ref=869, calc=829.67, diff=-39.33 (-4.53%)
260112090012.fit: ref=374, calc=380.16, diff=+6.16 (+1.65%)
260118061703.fit: ref=712, calc=712.59, diff=+0.59 (+0.08%)
260125062210.fit: ref=836, calc=855.41, diff=+19.41 (+2.32%)
260131092057.fit: ref=841, calc=848.52, diff=+7.52 (+0.89%)
260205062152.fit: ref=245, calc=234.36, diff=-10.64 (-4.34%)
260207053732.fit: ref=647, calc=665.87, diff=+18.87 (+2.92%)
260215063644.fit: ref=1460, calc=1450.52, diff=-9.48 (-0.65%)
260221070452.fit: ref=786, calc=787.99, diff=+1.99 (+0.25%)
260223080851.fit: ref=1309, calc=1265.63, diff=-43.37 (-3.31%)
全コード
package main
import (
"encoding/csv"
"flag"
"fmt"
"log"
"math"
"os"
"path/filepath"
"strings"
"github.com/tormoder/fit"
"gonum.org/v1/gonum/mat"
)
var sgCache = make(map[int]map[int][]float64)
const (
movingAverageWindow = 21
defaultFilterType = "ma"
defaultSGOrder = 2
defaultHystAsc = 0.05
defaultHystDesc = 0.03
)
func exportAltitudeCSV(filePath string, window int, filterType string, order int, outCSV string) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
fitFile, err := fit.Decode(file)
if err != nil {
return err
}
activity, err := fitFile.Activity()
if err != nil {
return err
}
rawAltitudes := make([]float64, 0, len(activity.Records))
for _, record := range activity.Records {
if record.Altitude != 0xFFFF {
rawAltitudes = append(rawAltitudes, float64(record.Altitude)/5.0-500.0)
}
}
smoothedAltitudes := smoothValues(rawAltitudes, filterType, window, order)
f, err := os.Create(outCSV)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
w.Write([]string{"raw", "smoothed"})
for i := 0; i < len(rawAltitudes) && i < len(smoothedAltitudes); i++ {
w.Write([]string{
fmt.Sprintf("%.3f", rawAltitudes[i]),
fmt.Sprintf("%.3f", smoothedAltitudes[i]),
})
}
return nil
}
func movingAverage(values []float64, window int) []float64 {
if len(values) == 0 || window <= 1 {
copied := make([]float64, len(values))
copy(copied, values)
return copied
}
result := make([]float64, len(values))
half := window / 2
for index := range values {
start := index - half
if start < 0 {
start = 0
}
end := index + half
if end >= len(values) {
end = len(values) - 1
}
sum := 0.0
count := 0
for i := start; i <= end; i++ {
sum += values[i]
count++
}
result[index] = sum / float64(count)
}
return result
}
func savitzkyGolayCoefficients(window, order int) []float64 {
if window%2 == 0 || order < 0 || order >= window {
return nil
}
if _, ok := sgCache[window]; !ok {
sgCache[window] = make(map[int][]float64)
}
if coeffs, ok := sgCache[window][order]; ok {
return coeffs
}
half := window / 2
n := window
m := order
A := mat.NewDense(n, m+1, nil)
for i := 0; i < n; i++ {
x := float64(i - half)
for j := 0; j <= m; j++ {
if j == 0 {
A.Set(i, j, 1.0)
} else {
A.Set(i, j, A.At(i, j-1)*x)
}
}
}
var At mat.Dense
At.CloneFrom(A.T())
var AtA mat.Dense
AtA.Mul(&At, A)
var AtAInv mat.Dense
err := AtAInv.Inverse(&AtA)
if err != nil {
return nil
}
e0 := mat.NewVecDense(m+1, nil)
e0.SetVec(0, 1.0)
var c mat.VecDense
c.MulVec(&AtAInv, e0)
var w mat.VecDense
w.MulVec(A, &c)
result := make([]float64, n)
for i := 0; i < n; i++ {
result[i] = w.AtVec(i)
}
sgCache[window][order] = result
return result
}
func savitzkyGolay(values []float64, window, order int) []float64 {
if len(values) == 0 || window <= 1 || order < 0 {
copied := make([]float64, len(values))
copy(copied, values)
return copied
}
if window%2 == 0 {
window++
}
if order >= window {
order = window - 1
}
coeffs := savitzkyGolayCoefficients(window, order)
if coeffs == nil {
return movingAverage(values, window)
}
result := make([]float64, len(values))
half := window / 2
for index := range values {
sum := 0.0
for i := 0; i < window; i++ {
srcIndex := index - half + i
if srcIndex < 0 {
srcIndex = 0
} else if srcIndex >= len(values) {
srcIndex = len(values) - 1
}
sum += values[srcIndex] * coeffs[i]
}
result[index] = sum
}
return result
}
func smoothValues(values []float64, filterType string, window, order int) []float64 {
switch filterType {
case "sg":
return savitzkyGolay(values, window, order)
case "ma":
fallthrough
default:
return movingAverage(values, window)
}
}
func getSmoothedAltitudes(activity *fit.ActivityFile, window int, filterType string, order int) []float64 {
rawAltitudes := make([]float64, 0, len(activity.Records))
for _, record := range activity.Records {
if record.Altitude != 0xFFFF {
rawAltitudes = append(rawAltitudes, float64(record.Altitude)/5.0-500.0)
}
}
return smoothValues(rawAltitudes, filterType, window, order)
}
func calculateGainWithHysteresis(altitudes []float64, ascThreshold, descThreshold float64) float64 {
if len(altitudes) < 2 {
return 0
}
if ascThreshold <= descThreshold || descThreshold <= 0 {
return 0
}
gain := 0.0
state := "descending"
lastLow := altitudes[0]
lastHigh := altitudes[0]
for i := 1; i < len(altitudes); i++ {
current := altitudes[i]
if state == "descending" {
if current > lastLow+ascThreshold {
state = "ascending"
lastHigh = current
} else {
lastLow = math.Min(lastLow, current)
}
} else {
if current < lastHigh-descThreshold {
gain += lastHigh - lastLow
state = "descending"
lastLow = current
} else {
lastHigh = math.Max(lastHigh, current)
}
}
}
if state == "ascending" {
gain += lastHigh - lastLow
}
return gain
}
func calculateElevationGainFromActivity(activity *fit.ActivityFile, forceCalc bool, window int, hystAsc, hystDesc float64, filterType string, order int) float64 {
if !forceCalc {
if len(activity.Sessions) > 0 {
session := activity.Sessions[0]
if session.TotalAscent != 0xFFFF {
return float64(session.TotalAscent)
}
}
}
smoothedAltitudes := getSmoothedAltitudes(activity, window, filterType, order)
return calculateGainWithHysteresis(smoothedAltitudes, hystAsc, hystDesc)
}
func calculateElevationGain(filePath string, forceCalc bool, window int, hystAsc, hystDesc float64, filterType string, order int) (float64, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer file.Close()
fitFile, err := fit.Decode(file)
if err != nil {
return 0, err
}
activity, err := fitFile.Activity()
if err != nil {
return 0, err
}
return calculateElevationGainFromActivity(activity, forceCalc, window, hystAsc, hystDesc, filterType, order), nil
}
func tuneParameters(files []string, windowMin, windowMax int, hystAscMin, hystAscMax, hystAscStep, hystDescMin, hystDescMax, hystDescStep float64, metric string, filterType string, orderMin, orderMax int) error {
type sample struct {
name string
activity *fit.ActivityFile
reference float64
rawAltitudes []float64
}
samples := make([]sample, 0, len(files))
for _, filePath := range files {
file, err := os.Open(filePath)
if err != nil {
return err
}
fitFile, err := fit.Decode(file)
file.Close()
if err != nil {
return err
}
activity, err := fitFile.Activity()
if err != nil {
return err
}
if len(activity.Sessions) == 0 {
continue
}
session := activity.Sessions[0]
if session.TotalAscent == 0xFFFF {
continue
}
rawAltitudes := make([]float64, 0, len(activity.Records))
for _, record := range activity.Records {
if record.Altitude != 0xFFFF {
rawAltitudes = append(rawAltitudes, float64(record.Altitude)/5.0-500.0)
}
}
samples = append(samples, sample{
name: filepath.Base(filePath),
activity: activity,
reference: float64(session.TotalAscent),
rawAltitudes: rawAltitudes,
})
}
if len(samples) == 0 {
return fmt.Errorf("比較可能な Session.TotalAscent を持つファイルがありません")
}
if windowMin < 1 || windowMax < windowMin {
return fmt.Errorf("不正な window 範囲: min=%d max=%d", windowMin, windowMax)
}
if hystAscMin < 0 || hystAscMax < hystAscMin || hystAscStep <= 0 {
return fmt.Errorf("不正な hyst-asc 範囲: min=%.3f max=%.3f step=%.3f", hystAscMin, hystAscMax, hystAscStep)
}
if hystDescMin < 0 || hystDescMax < hystDescMin || hystDescStep <= 0 {
return fmt.Errorf("不正な hyst-desc 範囲: min=%.3f max=%.3f step=%.3f", hystDescMin, hystDescMax, hystDescStep)
}
if metric != "mae" && metric != "rmse" {
return fmt.Errorf("不正な metric: %s (mae または rmse を指定してください)", metric)
}
if filterType == "sg" {
if orderMin < 1 || orderMax < orderMin {
return fmt.Errorf("不正な order 範囲: min=%d max=%d", orderMin, orderMax)
}
}
if windowMin%2 == 0 {
windowMin++
}
if windowMax%2 == 0 {
windowMax--
}
if windowMax < windowMin {
return fmt.Errorf("window 範囲に奇数が含まれていません: min=%d max=%d", windowMin, windowMax)
}
bestWindow := 0
bestHystAsc := 0.0
bestHystDesc := 0.0
bestOrder := orderMin
bestScore := math.MaxFloat64
if filterType == "sg" {
for window := windowMin; window <= windowMax; window += 2 {
for order := orderMin; order <= orderMax; order++ {
if order >= window {
continue
}
for asc := hystAscMin; asc <= hystAscMax+1e-9; asc += hystAscStep {
for desc := hystDescMin; desc <= hystDescMax+1e-9; desc += hystDescStep {
if asc <= desc {
continue
}
totalAbsError := 0.0
totalSquaredError := 0.0
for _, sample := range samples {
smoothed := smoothValues(sample.rawAltitudes, filterType, window, order)
gain := calculateGainWithHysteresis(smoothed, asc, desc)
err := gain - sample.reference
totalAbsError += math.Abs(err)
totalSquaredError += err * err
}
score := 0.0
if metric == "rmse" {
score = math.Sqrt(totalSquaredError / float64(len(samples)))
} else {
score = totalAbsError / float64(len(samples))
}
if score < bestScore {
bestScore = score
bestWindow = window
bestHystAsc = asc
bestHystDesc = desc
bestOrder = order
}
}
}
}
}
} else {
for window := windowMin; window <= windowMax; window += 2 {
for asc := hystAscMin; asc <= hystAscMax+1e-9; asc += hystAscStep {
for desc := hystDescMin; desc <= hystDescMax+1e-9; desc += hystDescStep {
if asc <= desc {
continue
}
totalAbsError := 0.0
totalSquaredError := 0.0
for _, sample := range samples {
smoothed := smoothValues(sample.rawAltitudes, filterType, window, orderMin)
gain := calculateGainWithHysteresis(smoothed, asc, desc)
err := gain - sample.reference
totalAbsError += math.Abs(err)
totalSquaredError += err * err
}
score := 0.0
if metric == "rmse" {
score = math.Sqrt(totalSquaredError / float64(len(samples)))
} else {
score = totalAbsError / float64(len(samples))
}
if score < bestScore {
bestScore = score
bestWindow = window
bestHystAsc = asc
bestHystDesc = desc
}
}
}
}
}
fmt.Println("パラメータ探索結果 (--tune)")
fmt.Println("==================================================")
fmt.Printf("対象ファイル数: %d\n", len(samples))
fmt.Printf("探索範囲: window=%d..%d (奇数), hyst-asc=%.2f..%.2f (step=%.2f), hyst-desc=%.2f..%.2f (step=%.2f)\n", windowMin, windowMax, hystAscMin, hystAscMax, hystAscStep, hystDescMin, hystDescMax, hystDescStep)
fmt.Printf("フィルタータイプ: %s\n", filterType)
if filterType == "sg" {
fmt.Printf("多項式次数範囲: %d..%d\n", orderMin, orderMax)
}
fmt.Printf("評価指標: %s\n", strings.ToUpper(metric))
fmt.Printf("最適 window: %d\n", bestWindow)
fmt.Printf("最適 hyst-asc: %.2f\n", bestHystAsc)
fmt.Printf("最適 hyst-desc: %.2f\n", bestHystDesc)
if filterType == "sg" {
fmt.Printf("最適 order: %d\n", bestOrder)
}
if metric == "rmse" {
fmt.Printf("二乗平均平方根誤差(RMSE): %.2f m\n\n", bestScore)
} else {
fmt.Printf("平均絶対誤差(MAE): %.2f m\n\n", bestScore)
}
fmt.Println("ファイル別比較 (最適パラメータ適用)")
fmt.Println("--------------------------------------------------")
for _, sample := range samples {
smoothed := smoothValues(sample.rawAltitudes, filterType, bestWindow, bestOrder)
gain := calculateGainWithHysteresis(smoothed, bestHystAsc, bestHystDesc)
diff := gain - sample.reference
diffPercent := 0.0
if sample.reference > 0 {
diffPercent = diff / sample.reference * 100.0
}
fmt.Printf("%s: ref=%.0f, calc=%.2f, diff=%+.2f (%+.2f%%)\n", sample.name, sample.reference, gain, diff, diffPercent)
}
return nil
}
func main() {
fs := flag.NewFlagSet("elevationgain", flag.ContinueOnError)
fs.Usage = printUsage
forceCalc := fs.Bool("force-calc", false, "手動計算モード(Session.TotalAscent を無視)")
shortForceCalc := fs.Bool("f", false, "force-calc の短形式")
tuneMode := fs.Bool("tune", false, "パラメータ最適化モード")
hystAsc := fs.Float64("hysteresis-asc", defaultHystAsc, "ヒステリシス上昇閾値 (m, 旧min-diff)")
hystDesc := fs.Float64("hysteresis-desc", defaultHystDesc, "ヒステリシス下降閾値 (m)")
window := fs.Int("window", movingAverageWindow, "窓幅(手動計算用)")
metric := fs.String("metric", "mae", "評価指標: mae|rmse")
filterType := fs.String("filter", defaultFilterType, "フィルタタイプ: ma|sg")
order := fs.Int("order", defaultSGOrder, "多項式次数(--filter=sg 使用時)")
exportCSV := fs.String("export-csv", "", "標高データをCSV出力 (1ファイル指定時のみ)")
tuneWindowMin := fs.Int("window-min", 3, "window 探索下限(奇数推奨)")
tuneWindowMax := fs.Int("window-max", 21, "window 探索上限(奇数推奨)")
tuneHystAscMin := fs.Float64("hyst-asc-min", 0.0, "hyst-asc 探索下限")
tuneHystAscMax := fs.Float64("hyst-asc-max", 10.0, "hyst-asc 探索上限")
tuneHystAscStep := fs.Float64("hyst-asc-step", 0.5, "hyst-asc 探索ステップ")
tuneHystDescMin := fs.Float64("hyst-desc-min", 0.0, "hyst-desc 探索下限")
tuneHystDescMax := fs.Float64("hyst-desc-max", 10.0, "hyst-desc 探索上限")
tuneHystDescStep := fs.Float64("hyst-desc-step", 0.5, "hyst-desc 探索ステップ")
tuneOrderMin := fs.Int("order-min", 1, "order 探索下限(--filter=sg --tune 使用時)")
tuneOrderMax := fs.Int("order-max", 5, "order 探索上限(--filter=sg --tune 使用時)")
if err := fs.Parse(os.Args[1:]); err != nil {
if err == flag.ErrHelp {
os.Exit(0)
}
log.Fatalf("オプション解析エラー: %v", err)
}
if *exportCSV != "" {
patterns := fs.Args()
if len(patterns) != 1 {
log.Fatalf("--export-csv は1ファイル指定時のみ利用可能です")
}
err := exportAltitudeCSV(patterns[0], *window, *filterType, *order, *exportCSV)
if err != nil {
log.Fatalf("CSV出力エラー: %v", err)
}
fmt.Printf("CSV出力完了: %s\n", *exportCSV)
return
}
if *shortForceCalc {
*forceCalc = true
}
patterns := fs.Args()
if len(patterns) == 0 {
printUsage()
os.Exit(1)
}
if *hystAsc <= *hystDesc || *hystDesc <= 0 {
log.Fatalf("不正なヒステリシス閾値: asc=%.3f, desc=%.3f (asc > desc > 0 を指定してください)", *hystAsc, *hystDesc)
}
if *window < 1 {
log.Fatalf("不正な --window 値: %d (1以上の整数を指定してください)", *window)
}
if *order < 1 {
log.Fatalf("不正な --order 値: %d (1以上の整数を指定してください)", *order)
}
if *metric != "mae" && *metric != "rmse" {
log.Fatalf("不正な --metric 値: %s (mae または rmse を指定してください)", *metric)
}
if *filterType != "ma" && *filterType != "sg" {
log.Fatalf("不正な --filter 値: %s (ma または sg を指定してください)", *filterType)
}
if *tuneWindowMin < 1 || *tuneWindowMax < *tuneWindowMin {
log.Fatalf("不正な window 範囲: min=%d max=%d", *tuneWindowMin, *tuneWindowMax)
}
if *tuneHystAscMin < 0 || *tuneHystAscMax < *tuneHystAscMin || *tuneHystAscStep <= 0 {
log.Fatalf("不正な hyst-asc 範囲: min=%.3f max=%.3f step=%.3f", *tuneHystAscMin, *tuneHystAscMax, *tuneHystAscStep)
}
if *tuneHystDescMin < 0 || *tuneHystDescMax < *tuneHystDescMin || *tuneHystDescStep <= 0 {
log.Fatalf("不正な hyst-desc 範囲: min=%.3f max=%.3f step=%.3f", *tuneHystDescMin, *tuneHystDescMax, *tuneHystDescStep)
}
if *tuneOrderMin < 1 || *tuneOrderMax < *tuneOrderMin {
log.Fatalf("不正な order 範囲: min=%d max=%d", *tuneOrderMin, *tuneOrderMax)
}
var files []string
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
log.Printf("警告: パターン '%s' の処理に失敗: %v", pattern, err)
continue
}
if len(matches) == 0 {
files = append(files, pattern)
} else {
files = append(files, matches...)
}
}
if len(files) == 0 {
log.Fatalf("処理するFITファイルが見つかりません")
}
if *tuneMode {
err := tuneParameters(files, *tuneWindowMin, *tuneWindowMax, *tuneHystAscMin, *tuneHystAscMax, *tuneHystAscStep, *tuneHystDescMin, *tuneHystDescMax, *tuneHystDescStep, *metric, *filterType, *tuneOrderMin, *tuneOrderMax)
if err != nil {
log.Fatalf("パラメータ探索エラー: %v", err)
}
return
}
if *forceCalc {
fmt.Printf("手動計算モード: filter=%s, window=%d, hysteresis-asc=%.2f, desc=%.2f", *filterType, *window, *hystAsc, *hystDesc)
if *filterType == "sg" {
fmt.Printf(", order=%d", *order)
}
fmt.Println()
}
fmt.Printf("FITファイルを解析中... (%d ファイル)\n\n", len(files))
totalGain := 0.0
successCount := 0
for _, file := range files {
fileName := filepath.Base(file)
gain, err := calculateElevationGain(file, *forceCalc, *window, *hystAsc, *hystDesc, *filterType, *order)
if err != nil {
fmt.Printf("❌ %s: エラー - %v\n", fileName, err)
continue
}
fmt.Printf("✓ %s: %.2f m\n", fileName, gain)
totalGain += gain
successCount++
}
fmt.Printf("\n==================================================\n")
fmt.Printf("処理ファイル数: %d / %d\n", successCount, len(files))
fmt.Printf("合計獲得標高: %.2f m\n", totalGain)
if successCount > 0 {
fmt.Printf("平均獲得標高: %.2f m\n", totalGain/float64(successCount))
}
}
func printUsage() {
fmt.Fprintf(os.Stderr, `使用方法: elevationgain [オプション] <fitファイル1> [fitファイル2] ...
オプション:
-force-calc
手動計算モード(Session.TotalAscent を無視)
-f
force-calc の短形式
-tune
パラメータ最適化モード
-hysteresis-asc float
ヒステリシス上昇閾値 (m) (デフォルト: %.2f)
-hysteresis-desc float
ヒステリシス下降閾値 (m) (デフォルト: %.2f)
-window int
窓幅(手動計算用) (デフォルト: %d)
-metric string
評価指標: mae|rmse (デフォルト: mae)
-filter string
フィルタタイプ: ma|sg (デフォルト: %s)
-order int
多項式次数(--filter=sg 使用時) (デフォルト: %d)
-export-csv string
標高データをCSV出力 (1ファイル指定時のみ)
-window-min int
window 探索下限(奇数推奨) (デフォルト: 3)
-window-max int
window 探索上限(奇数推奨) (デフォルト: 21)
-hyst-asc-min float
hyst-asc 探索下限 (デフォルト: 0.00)
-hyst-asc-max float
hyst-asc 探索上限 (デフォルト: 10.00)
-hyst-asc-step float
hyst-asc 探索ステップ (デフォルト: 0.50)
-hyst-desc-min float
hyst-desc 探索下限 (デフォルト: 0.00)
-hyst-desc-max float
hyst-desc 探索上限 (デフォルト: 10.00)
-hyst-desc-step float
hyst-desc 探索ステップ (デフォルト: 0.50)
-order-min int
order 探索下限(--filter=sg --tune 使用時) (デフォルト: 1)
-order-max int
order 探索上限(--filter=sg --tune 使用時) (デフォルト: 5)
例:
elevationgain Activities/251128104532.fit
elevationgain --force-calc --window=11 --hysteresis-asc=0.15 Activities/*.fit
elevationgain --hysteresis-asc=0.10 --hysteresis-desc=0.05 Activities/*.fit
elevationgain --tune --filter=sg --order-min=1 --order-max=4 Activities/*.fit
elevationgain --tune --metric=rmse --window-min=5 --window-max=25 --hyst-asc-min=0.0 --hyst-asc-max=0.5 Activities/*.fit
`, defaultHystAsc, defaultHystDesc, movingAverageWindow, defaultFilterType, defaultSGOrder)
}