📝

Pythonでサイクリングデータを深掘り!自作FITファイル解析アプリ紹介(範囲指定機能付き)

に公開

サイクリストの皆さん、日々のライドデータをどのように活用していますか? Garmin ConnectやStravaなどのプラットフォームは非常に便利ですが、「もっと特定の区間に注目したい」「自分だけの指標で分析したい」と感じたことはありませんか?
今回は、以前の記事で紹介したFITファイルの解析スクリプトをベースに、PythonのTkinterでアプリケーションを自作してみたのでご紹介します。このアプリを使えば、FITファイル形式の走行データを読み込み、様々な角度からグラフや数値で可視化・分析できます。さらに、指定した距離範囲だけを切り出して分析する機能も搭載しています!

このアプリでできること

• .fit ファイルの読み込み: Garminデバイスなどで記録される標準的なFITファイルを直接読み込めます。
• 多彩なグラフ表示:
    ◦ 時間や距離に対する速度、高度、勾配の変化
    ◦ 速度と高度、速度と勾配の相関(2軸グラフ)
    ◦ 勾配の分布(ヒストグラム、箱ひげ図、バイオリンプロット)
    ◦ 勾配ごとの平均速度や走行距離(バーチャート)
• カスタム分析(激坂ファクター): 勾配のきつさを独自の指標で数値化。
• ★範囲指定分析: 「あの峠だけ」「この平坦区間だけ」といった特定の距離範囲を指定して、上記の分析を実行可能!
• データ・グラフの保存: 分析結果の元データ(フィルタリング後も可)をCSVファイルに、表示中のグラフを画像ファイル(PNG/JPG等)に保存できます。

アプリの画面紹介
アプリを起動すると、以下のようなシンプルな画面が表示されます。


アプリのメイン画面。ファイル選択、範囲指定、解析内容選択、操作ボタンが配置されています。

主な操作エリア:
1. ファイル選択: 解析したい .fit ファイルを選びます。ファイルを選択すると自動でデータが読み込まれます。
2. 範囲指定 (距離/m): 分析したい区間の開始距離と終了距離をメートル単位で入力し、「適用」ボタンを押します。データ全体の距離が自動で終了距離の初期値として入ります。「リセット」で全区間に戻せます。現在の指定範囲は下に表示されます。
3. 解析内容選択: ドロップダウンリストから、表示したいグラフや分析の種類を選びます。
4. 実行: 「実行」ボタンを押すと、指定された範囲のデータ(範囲指定がない場合は全データ)に対して、選択された解析が実行され、別ウィンドウに結果が表示されます。
5. 操作: 分析結果の元データ(CSV)やグラフ画像(PNG/JPGなど)を保存したり、アプリを終了したりできます。データ保存時は、範囲指定が適用されていればその範囲のデータが保存されます。
解析結果の表示例
「実行」ボタンを押すと、下のような別ウィンドウが開き、選択した解析結果が表示されます。

例1:距離 vs 速度&高度 (全範囲)


区間を指定なしで「距離/速度&勾配」を表示した例。
青線が速度(左軸)、赤線が勾配(右軸)。

例2:距離 vs 速度&勾配 (範囲指定あり)


5000mから15000mの区間を指定して「距離/速度&勾配」を表示した例。
青線が速度(左軸)、緑線が勾配(右軸)。

例3:勾配距離分布(範囲指定あり)

例4:激坂ファクター (全範囲)
勾配に関する分析として、独自の「激坂ファクター」も計算できます。これは、指定区間内の各勾配範囲(3-6%, 6.5-9%, ... , 15.5%以上)の走行距離の比率や、勾配のきつさを加味した重み付け係数を算出したものです。具体的にはファクター4ではそれぞれ「9.5-12%」✗1、「12.5-15%」✗2、「15.5-18%」✗3した距離に対する全体の距離、ファクター5ではそれぞれ「3-6%」✗0.5、「6.5-9%」✗1、「9.5-12%」✗1.5、「12.5-15%」✗3、「15.5-18%」✗5した距離に対する全体の距離で算出しています。値が大きいほど、激坂成分が多い区間であることを示します。


「激坂ファクター」の計算結果。

上記はある坂について激坂ファクターを算出してみた結果になります。数字だけみてもピンとこないかもしれませんが、いろんな坂で比較してみると「激坂ファクター5」が「1」を超えると個人的には「きつーい」坂に分類できているように思います。興味があれば皆さんのデータでも計算されてみてもいかがでしょうか。

このアプリのコードについて

このアプリは Python と以下の主要なライブラリを使って作られています。
• Tkinter: Python標準のGUIライブラリ。アプリのウィンドウやボタンなどを作成。
• fitdecode: FITファイルを解析し、データレコードを読み取るためのライブラリ。
• Pandas: 読み込んだデータを効率的に処理・分析するための強力なライブラリ。データフレームという形式でデータを扱います。範囲指定のフィルタリングもPandasの機能を使っています。
• NumPy: 数値計算、特に配列操作や統計計算に使用。
• Matplotlib: グラフ描画ライブラリ。Tkinterのウィンドウ内にグラフを埋め込んで表示します。
全体の構造としては、FitAnalyzerApp というクラスに必要な機能(ファイル読み込み、範囲指定、データ処理、グラフ描画、保存など)をメソッドとしてまとめ、GUIの操作と連携させています。エラーが発生した場合も、メッセージを表示して可能な限り処理を続けるように工夫されています(エラーハンドリング)。

コードの全体の記載


import tkinter as tk
from tkinter import ttk, filedialog, messagebox, StringVar, DoubleVar
import pandas as pd
import numpy as np
import fitdecode
import matplotlib.pyplot as plt 
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import japanize_matplotlib
import os
import traceback # エラー詳細表示用


# 定数定義
KMH_CONVERSION = 3.6 # m/s から km/h への変換係数

class FitAnalyzerApp:
    """
    .fit ファイルを解析し、結果をグラフや数値で表示するTkinterアプリケーション。
    距離によるデータ範囲指定機能付き。
    """
    def __init__(self, master):
        """アプリケーションの初期化"""
        self.master = master
        self.master.title("Fit Data 解析 (範囲指定対応)")
        self.master.geometry('800x700') # 高さを少し増やす
        self.master.minsize(600, 550) # 最小サイズも調整

        self.df = None # 元のデータフレーム
        self.df_filtered = None # 範囲指定後のデータフレーム
        self.is_range_applied = False # 範囲指定が適用されているか
        self.filepath_var = StringVar()
        self.treatment_var = StringVar()
        self.start_dist_var = DoubleVar(value=0.0) # 開始距離 (初期値0)
        self.end_dist_var = DoubleVar()          # 終了距離 (ファイル読み込み後に設定)
        self.range_status_var = StringVar(value="範囲指定: 全区間") # 範囲指定状況表示用

        self.fig = None
        self.ax = None
        self.ax2 = None
        self.canvas = None
        self.graph_window = None
        self.graph_frame = None
        self.info_frame = None

        self._create_widgets()

    def _create_widgets(self):
        """GUIウィジェットを作成し配置する"""
        main_frame = ttk.Frame(self.master, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)

        # --- ファイル選択セクション ---
        file_frame = ttk.LabelFrame(main_frame, text="1. ファイル選択")
        file_frame.pack(fill=tk.X, padx=10, pady=5, anchor=tk.N)
        # (ファイル選択のウィジェットは変更なし)
        ttk.Label(file_frame, text="解析する .fit ファイルを選択してください:").grid(
            row=0, column=0, columnspan=3, padx=5, pady=5, sticky=tk.W)
        ttk.Entry(file_frame, textvariable=self.filepath_var, width=60).grid(
            row=1, column=0, columnspan=2, padx=5, pady=5, sticky=tk.EW)
        ttk.Button(file_frame, text="選択", command=self._select_file).grid(
            row=1, column=2, padx=5, pady=5)
        file_frame.columnconfigure(0, weight=1)

        # --- ★範囲指定セクション ---
        range_frame = ttk.LabelFrame(main_frame, text="2. 範囲指定 (距離/m)")
        range_frame.pack(fill=tk.X, padx=10, pady=5, anchor=tk.N)

        ttk.Label(range_frame, text="開始:").grid(row=0, column=0, padx=5, pady=5, sticky=tk.W)
        start_entry = ttk.Entry(range_frame, textvariable=self.start_dist_var, width=10)
        start_entry.grid(row=0, column=1, padx=5, pady=5)

        ttk.Label(range_frame, text="終了:").grid(row=0, column=2, padx=5, pady=5, sticky=tk.W)
        end_entry = ttk.Entry(range_frame, textvariable=self.end_dist_var, width=10)
        end_entry.grid(row=0, column=3, padx=5, pady=5)

        apply_button = ttk.Button(range_frame, text="適用", command=self._apply_range, width=10)
        apply_button.grid(row=0, column=4, padx=(10, 5), pady=5)

        reset_button = ttk.Button(range_frame, text="リセット", command=self._reset_range, width=10)
        reset_button.grid(row=0, column=5, padx=5, pady=5)

        # 範囲指定の状況表示ラベル
        range_status_label = ttk.Label(range_frame, textvariable=self.range_status_var)
        range_status_label.grid(row=1, column=0, columnspan=6, padx=5, pady=(0,5), sticky=tk.W)

        # --- 解析選択セクション ---
        analysis_frame = ttk.LabelFrame(main_frame, text="3. 解析内容選択") 
        analysis_frame.pack(fill=tk.X, padx=10, pady=5, anchor=tk.N)
        # (解析選択のウィジェットは変更なし)
        ttk.Label(analysis_frame, text="作成するグラフ、データ等を選択してください:").pack(
            padx=5, pady=5, anchor=tk.W)
        graphs = (
            '距離/時間', '高度/時間', '高度/距離', '時間/速度',
            '距離/速度', '距離/速度&高度', '時間/勾配', '距離/勾配',
            '距離/速度&勾配', 'ヒストグラムー時間/勾配', '平均速度/勾配ごと',
            '積算距離/勾配ごと', '箱ひげ図(坂)', 'バイオリンプロット(坂)',
            '激坂ファクター'
        )
        combobox = ttk.Combobox(analysis_frame, textvariable=self.treatment_var,
                                values=graphs, state='readonly', width=30, height=15)
        combobox.pack(padx=5, pady=5, anchor=tk.W)
        if graphs: combobox.set(graphs[0])
        ttk.Button(analysis_frame, text="実行", command=self._run_analysis, width=20).pack(
            padx=5, pady=10, anchor=tk.W)

        # --- 操作ボタンセクション ---
        action_frame = ttk.LabelFrame(main_frame, text="4. 操作") 
        action_frame.pack(fill=tk.X, padx=10, pady=(10, 5), anchor=tk.S)
        # (操作ボタンのウィジェットは変更なし)
        button_frame = ttk.Frame(action_frame)
        button_frame.pack(fill=tk.X, padx=5, pady=5)
        ttk.Button(button_frame, text="データ保存 (CSV)", command=self._save_data, width=20).pack(side=tk.LEFT, padx=5)
        ttk.Button(button_frame, text="グラフ保存 (JPG/PNG)", command=self._save_figure, width=20).pack(side=tk.LEFT, padx=5)
        spacer = ttk.Frame(button_frame)
        spacer.pack(side=tk.LEFT, expand=True, fill=tk.X)
        ttk.Button(button_frame, text="終了", command=self.master.quit, width=20).pack(side=tk.RIGHT, padx=5)

    def _select_file(self):
        """ファイル選択ダイアログを開き、選択されたファイルパスをStringVarにセットする"""
        filetypes = [('fitファイル', '*.fit')]
        filepath = filedialog.askopenfilename(filetypes=filetypes, title="FITファイルを選択")
        if filepath:
            self.filepath_var.set(filepath)
            print(f"選択されたファイル: {filepath}")
            # ファイルが選択されたら、まず読み込みを試みる
            self._load_fit_file() # 読み込み処理内で範囲リセットも行う

    def _load_fit_file(self):
        """
        選択された .fit ファイルを読み込み、DataFrame を作成して self.df に格納する。
        成功した場合 True、失敗した場合 False を返す。
        読み込み成功時に範囲指定をリセットする。
        """
        filepath = self.filepath_var.get()
        if not filepath:
            # messagebox.showerror("エラー", "ファイルパスが指定されていません。") # _select_fileから呼ばれるので不要かも
            return False
        if not os.path.exists(filepath):
            messagebox.showerror("エラー", f"ファイルが見つかりません:\n{filepath}")
            return False

        # 読み込み前に以前のデータをクリア
        self.df = None
        self.df_filtered = None
        self.is_range_applied = False
        self.range_status_var.set("範囲指定: 全区間")

        try:
            # (fitdecodeによるファイル読み込み処理 )
            datas = []
            required_fields = {'timestamp', 'distance', 'altitude', 'speed', 'grade'}
            print(f"'{filepath}' を読み込み中...")
            with fitdecode.FitReader(filepath) as fit:
                for frame in fit:
                    if isinstance(frame, fitdecode.FitDataMessage) and frame.name == 'record':
                        data = {}
                        for field in frame.fields:
                            data[field.name] = field.value
                        if 'timestamp' in data:
                             datas.append(data)

            if not datas:
                messagebox.showwarning("警告", "ファイルから有効な 'record' タイプのデータが見つかりませんでした。")
                self.df = None
                self._reset_range() # データがない場合もリセット
                return False

            self.df = pd.DataFrame(datas)
            print(f"読み込み完了: {len(self.df)}件のレコード")

            # (データ型のチェックと変換、欠損列の処理 )
            missing_cols = []
            for col in required_fields:
                if col not in self.df.columns:
                    missing_cols.append(col)
                    self.df[col] = np.nan
                else:
                    if col == 'timestamp':
                        try:
                            self.df[col] = pd.to_datetime(self.df[col])
                        except Exception as e:
                            print(f"警告: '{col}' 列のdatetime変換に失敗しました: {e}")
                            self.df[col] = pd.NaT
                    elif col in ['distance', 'altitude', 'speed', 'grade']:
                        self.df[col] = pd.to_numeric(self.df[col], errors='coerce')

            if missing_cols:
                 messagebox.showwarning("データ列不足", f"ファイルに必須データ列が含まれていませんでした:\n{', '.join(missing_cols)}")

            
            if 'grade' in self.df.columns:
                 self.df['grade'].fillna(0, inplace=True)

            # --- ★範囲指定のリセットと初期値設定 ---
            self._reset_range() # ファイル読み込み成功時に範囲をリセット

            print("データフレーム情報:")
            print(self.df.info())
            return True

        except fitdecode.FitDecodeError as e:
            messagebox.showerror("ファイルエラー", f".fit ファイルの解析中にエラーが発生しました:\n{e}")
            self._reset_range() # エラー時もリセット
            self.df = None
            return False
        except FileNotFoundError:
            messagebox.showerror("エラー", f"ファイルが見つかりません:\n{filepath}")
            self._reset_range()
            self.df = None
            return False
        except Exception as e:
            messagebox.showerror("エラー", f"データの読み込み中に予期せぬエラーが発生しました:\n{e}")
            traceback.print_exc()
            self._reset_range()
            self.df = None
            return False

    def _apply_range(self):
        """入力された距離範囲をデータに適用する"""
        if self.df is None or 'distance' not in self.df.columns or self.df['distance'].isnull().all():
            messagebox.showerror("エラー", "範囲指定を行うための有効な距離データがありません。\nファイルを読み込んでください。")
            return

        try:
            start_dist = self.start_dist_var.get()
            end_dist = self.end_dist_var.get()
        except tk.TclError:
            messagebox.showerror("入力エラー", "開始距離と終了距離には数値を入力してください。")
            return

        # バリデーション
        min_dist_data = self.df['distance'].min()
        max_dist_data = self.df['distance'].max()

        if start_dist < 0:
            messagebox.showerror("入力エラー", "開始距離は0以上である必要があります。")
            return
        # end_dist が start_dist より小さい場合や、データ範囲外の場合は調整するかエラーにする
        if end_dist <= start_dist:
            messagebox.showerror("入力エラー", "終了距離は開始距離より大きい値を入力してください。")
            return
        # 終了距離がデータの最大値を超えている場合は、最大値に丸めるか選択させる (ここでは最大値にする)
        if end_dist > max_dist_data:
             print(f"情報: 終了距離({end_dist}m)がデータ最大値({max_dist_data:.1f}m)を超えているため、最大値に調整します。")
             end_dist = max_dist_data
             self.end_dist_var.set(end_dist) # Entryの値も更新

        # 開始距離がデータの最小値より小さい場合(通常は0だが念のため)
        if start_dist < min_dist_data:
            start_dist = min_dist_data
            self.start_dist_var.set(start_dist)

        try:
            # 範囲内のデータを抽出 (.copy() で SettingWithCopyWarning を回避)
            self.df_filtered = self.df[(self.df['distance'] >= start_dist) & (self.df['distance'] <= end_dist)].copy()

            if self.df_filtered.empty:
                 messagebox.showwarning("範囲指定結果", "指定された範囲にデータポイントが存在しませんでした。")
                 self._reset_range() # 範囲指定をリセット
                 return

            self.is_range_applied = True
            status_text = f"範囲指定: {start_dist:.1f} m から {end_dist:.1f} m"
            self.range_status_var.set(status_text)
            messagebox.showinfo("成功", f"{status_text} の範囲が適用されました。\n({len(self.df_filtered)}件のデータ)")
            print(f"{status_text} の範囲が適用されました。")

        except Exception as e:
            messagebox.showerror("エラー", f"範囲の適用中にエラーが発生しました:\n{e}")
            traceback.print_exc()
            self._reset_range()

    def _reset_range(self):
        """距離範囲指定をリセットし、全データを対象とする"""
        self.start_dist_var.set(0.0)
        max_dist = 0.0
        # dfが存在し、distance列があり、かつNaNでない値が存在する場合に最大値を取得
        if self.df is not None and 'distance' in self.df.columns and self.df['distance'].notna().any():
             max_dist = self.df['distance'].dropna().max()
        self.end_dist_var.set(max_dist) # 読み込み時の最大値を設定

        self.df_filtered = None
        self.is_range_applied = False
        self.range_status_var.set("範囲指定: 全区間")
        print("範囲指定がリセットされました。")
        # 必要であればリセットされたことをユーザーに通知
        # messagebox.showinfo("情報", "範囲指定がリセットされました。全区間が対象です。")

    def _prepare_graph_window(self):
        """グラフ表示用のウィンドウとCanvasを準備または再利用する"""
        # (変更なし)
        if self.graph_window is None or not self.graph_window.winfo_exists():
            self.graph_window = tk.Toplevel(self.master)
            self.graph_window.title("解析結果グラフ")
            self.graph_window.geometry('800x600')
            self.graph_window.minsize(500, 400)
            self.graph_window.protocol("WM_DELETE_WINDOW", self._on_graph_window_close)

            self.info_frame = ttk.Frame(self.graph_window, relief=tk.GROOVE, borderwidth=1)
            self.info_frame.pack(fill=tk.X, side=tk.TOP, padx=10, pady=(10, 5))

            self.graph_frame = ttk.Frame(self.graph_window)
            self.graph_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))

            self.fig = Figure(figsize=(7, 5), dpi=100)
            self.ax = self.fig.add_subplot(1, 1, 1)
            self.ax2 = None

            self.canvas = FigureCanvasTkAgg(self.fig, master=self.graph_frame)
            self.canvas_widget = self.canvas.get_tk_widget()
            self.canvas_widget.pack(fill=tk.BOTH, expand=True)

        else:
            self.graph_window.lift()
            self.ax.clear()
            if self.ax2:
                if self.ax2 in self.fig.axes: self.fig.delaxes(self.ax2)
                self.ax2 = None
            for widget in self.info_frame.winfo_children(): widget.destroy()
            self.ax.grid(False)

    def _on_graph_window_close(self):
        """グラフウィンドウが閉じられたときのクリーンアップ処理"""
       
        if self.graph_window: self.graph_window.destroy()
        self.graph_window = None; self.canvas = None; self.fig = None
        self.ax = None; self.ax2 = None; self.graph_frame = None; self.info_frame = None
        print("グラフウィンドウを閉じました。")

    def _run_analysis(self):
        """選択された解析を実行し、結果をグラフまたはテキストで表示する"""
        # --- ★解析対象データフレームの決定 ---
        if self.is_range_applied and self.df_filtered is not None and not self.df_filtered.empty:
            target_df = self.df_filtered
            print(f"解析対象: 指定範囲 ({self.range_status_var.get()})")
        elif self.df is not None and not self.df.empty:
            target_df = self.df
            print("解析対象: 全区間")
        else:
            messagebox.showerror("エラー", "解析するデータがありません。\nファイルを読み込んでください。")
            return

        treatment = self.treatment_var.get()
        if not treatment:
            messagebox.showerror("エラー", "解析内容が選択されていません。")
            return

        print(f"解析実行: {treatment}")

        try:
            self._prepare_graph_window()
        except Exception as e:
            messagebox.showerror("エラー", f"グラフウィンドウの準備中にエラーが発生しました:\n{e}")
            traceback.print_exc()
            return

        try:
            # --- ★データ準備 (target_df から) ---
            time_seconds = None
            if 'timestamp' in target_df.columns and target_df['timestamp'].notna().any():
                valid_timestamps = target_df['timestamp'].dropna()
                if not valid_timestamps.empty:
                    # フィルタリング後の最初の点を0秒とする
                    time_seconds = (valid_timestamps - valid_timestamps.iloc[0]).dt.total_seconds()
                    time_seconds = time_seconds.reindex(target_df.index) # 元のindexに合わせる
            if time_seconds is None or time_seconds.isnull().all():
                # タイムスタンプがない or 全て無効な場合はレコード番号(フィルタリング後の連番)
                time_seconds = pd.Series(np.arange(len(target_df)), index=target_df.index)

            # 必要な列の存在チェック関数 (target_df に対して)
            def check_data_availability(required_cols):
                missing = [col for col in required_cols if col not in target_df.columns or target_df[col].isnull().all()]
                if missing:
                    self._show_missing_data_error(missing, is_filtered=self.is_range_applied)
                    return False
                for col in required_cols:
                    # dropna().shape[0] で有効データ数をカウント
                    if target_df[col].dropna().shape[0] < 2 and treatment not in ['ヒストグラムー時間/勾配','箱ひげ図(坂)','バイオリンプロット(坂)','激坂ファクター','平均速度/勾配ごと','積算距離/勾配ごと']:
                        # messagebox.showwarning("データ不足", f"列 '{col}' の有効なデータが少なすぎます(2点未満)。")
                        print(f"警告: 列 '{col}' の有効なデータが少なすぎます ({target_df[col].dropna().shape[0]}点)。")
                        # 処理によっては継続可能
                return True

            # 解析に使用するデータ系列 (target_df から取得)
            distance = target_df['distance'] if 'distance' in target_df.columns else None
            altitude = target_df['altitude'] if 'altitude' in target_df.columns else None
            speed_kmh = (target_df['speed'] * KMH_CONVERSION) if 'speed' in target_df.columns else None
            grade = target_df['grade'] if 'grade' in target_df.columns else None

            # --- 解析と描画の分岐 (使用する変数を distance, altitude, speed_kmh, grade, time_seconds に統一) ---
            # 各解析処理内の self.df を target_df にする必要はない(上記の変数を使っていれば)
            # 例:
            if treatment == '距離/時間':
                if not check_data_availability(['distance']): return
                self.ax.plot(time_seconds, distance) # time_seconds と distance を使用
                self.ax.set_xlabel('Time (s, relative to start of selection)')
                self.ax.set_ylabel('Distance (m)')
                self.fig.suptitle(f'Distance / Time ({self.range_status_var.get()})') # タイトルに範囲情報を追加

            elif treatment == '高度/時間':
                if not check_data_availability(['altitude']): return
                self.ax.plot(time_seconds, altitude)
                self.ax.set_xlabel('Time (s, relative to start of selection)')
                self.ax.set_ylabel('Altitude (m)')
                self.fig.suptitle(f'Altitude / Time ({self.range_status_var.get()})')

            # ... (他の解析タイプも同様に distance, altitude, speed_kmh, grade, time_seconds を使用) ...
            # ... (check_data_availability の呼び出しを追加) ...

            elif treatment == '高度/距離':
                 if not check_data_availability(['altitude', 'distance']): return
                 self.ax.plot(distance, altitude)
                 self.ax.set_xlabel('Distance (m)')
                 self.ax.set_ylabel('Altitude (m)')
                 self.fig.suptitle(f'Altitude / Distance ({self.range_status_var.get()})')

            elif treatment == '時間/速度':
                 if not check_data_availability(['speed']): return
                 self.ax.plot(time_seconds, speed_kmh)
                 self.ax.set_xlabel('Time (s, relative to start of selection)')
                 self.ax.set_ylabel('Speed (km/h)')
                 self.fig.suptitle(f'Speed / Time ({self.range_status_var.get()})')

            elif treatment == '距離/速度':
                 if not check_data_availability(['speed', 'distance']): return
                 self.ax.plot(distance, speed_kmh)
                 self.ax.set_xlabel('Distance (m)')
                 self.ax.set_ylabel('Speed (km/h)')
                 self.fig.suptitle(f'Speed / Distance ({self.range_status_var.get()})')

            elif treatment == '距離/速度&高度':
                if not check_data_availability(['speed', 'altitude', 'distance']): return
                self.ax2 = self.ax.twinx()
                color1 = 'tab:blue'; color2 = 'tab:red'
                line1, = self.ax.plot(distance, speed_kmh, label='Speed', color=color1)
                line2, = self.ax2.plot(distance, altitude, label='Altitude', color=color2)
                self.ax.set_xlabel('Distance (m)'); self.ax.set_ylabel('Speed (km/h)', color=color1)
                self.ax2.set_ylabel('Altitude (m)', color=color2)
                self.ax.tick_params(axis='y', labelcolor=color1); self.ax2.tick_params(axis='y', labelcolor=color2)
                lines = [line1, line2]; self.ax.legend(lines, [l.get_label() for l in lines])
                self.fig.suptitle(f'Speed & Altitude / Distance ({self.range_status_var.get()})')

            elif treatment == '時間/勾配':
                 if not check_data_availability(['grade']): return
                 self.ax.plot(time_seconds, grade)
                 self.ax.set_xlabel('Time (s, relative to start of selection)')
                 self.ax.set_ylabel('Grade (%)')
                 self.fig.suptitle(f'Grade / Time ({self.range_status_var.get()})')

            elif treatment == '距離/勾配':
                 if not check_data_availability(['grade', 'distance']): return
                 self.ax.plot(distance, grade)
                 self.ax.set_xlabel('Distance (m)')
                 self.ax.set_ylabel('Grade (%)')
                 self.fig.suptitle(f'Grade / Distance ({self.range_status_var.get()})')

            elif treatment == '距離/速度&勾配':
                if not check_data_availability(['speed', 'grade', 'distance']): return
                self.ax2 = self.ax.twinx()
                color1 = 'tab:blue'; color2 = 'tab:green'
                line1, = self.ax.plot(distance, speed_kmh, label='Speed', color=color1)
                line2, = self.ax2.plot(distance, grade, label='Grade', color=color2)
                self.ax.set_xlabel('Distance (m)'); self.ax.set_ylabel('Speed (km/h)', color=color1)
                self.ax2.set_ylabel('Grade (%)', color=color2)
                self.ax.tick_params(axis='y', labelcolor=color1); self.ax2.tick_params(axis='y', labelcolor=color2)
                lines = [line1, line2]; self.ax.legend(lines, [l.get_label() for l in lines])
                self.fig.suptitle(f'Speed & Grade / Distance ({self.range_status_var.get()})')

            elif treatment == 'ヒストグラムー時間/勾配':
                if not check_data_availability(['grade']): return
                valid_grade = grade.dropna()
                if not valid_grade.empty:
                    self.ax.hist(valid_grade, bins=30, edgecolor='black')
                    self.ax.set_xlabel('Grade (%)')
                    self.ax.set_ylabel('Frequency (Data Points)')
                    self.fig.suptitle(f'Grade Distribution ({self.range_status_var.get()})')
                else:
                    messagebox.showwarning("警告", "有効な勾配データがありませんでした。")
                    return

            elif treatment == '平均速度/勾配ごと':
                 if not check_data_availability(['grade', 'speed']): return
                 min_g, max_g, step = -20.0, 30.0, 0.5
                 grade_bins = np.arange(min_g - step/2, max_g + step, step)
                 grade_labels = [f'{b+step/2:.1f}' for b in grade_bins[:-1]]
                 # ★target_df を使用
                 temp_df = pd.DataFrame({'grade': grade, 'speed_kmh': speed_kmh}).dropna()
                 if not temp_df.empty:
                     temp_df['grade_bin'] = pd.cut(temp_df['grade'], bins=grade_bins, labels=grade_labels, right=False)
                     avg_speed_per_grade = temp_df.dropna(subset=['grade_bin']).groupby('grade_bin')['speed_kmh'].mean()
                     avg_speed_per_grade = avg_speed_per_grade[avg_speed_per_grade.notna()]
                     if not avg_speed_per_grade.empty:
                          avg_speed_per_grade.plot(kind='bar', ax=self.ax, edgecolor='black')
                          self.ax.set_xlabel('Grade (%)'); self.ax.set_ylabel('Average Speed (km/h)')
                          self.fig.suptitle(f'Average Speed per Grade ({self.range_status_var.get()})')
                          # ... (ラベル調整) ...
                          if len(avg_speed_per_grade) > 20: self.ax.tick_params(axis='x', rotation=90, labelsize=7)
                          elif len(avg_speed_per_grade) > 10: self.ax.tick_params(axis='x', rotation=60, labelsize=8)
                          else: self.ax.tick_params(axis='x', rotation=45, labelsize=10)
                          self.fig.tight_layout(rect=[0, 0.05, 1, 0.95])
                     else: messagebox.showwarning("警告", "平均速度を計算できる勾配データがありませんでした。"); return
                 else: messagebox.showwarning("警告", "有効な勾配または速度データがありません。"); return

            elif treatment == '積算距離/勾配ごと':
                if not check_data_availability(['grade', 'distance']): return
                # ★target_df の distance から差分計算
                dist_diff = distance.diff().fillna(0).clip(lower=0)
                temp_df = pd.DataFrame({'grade': grade, 'distance_diff': dist_diff}).dropna(subset=['grade'])
                min_g, max_g, step = 3.0, 18.0, 0.5
                grade_bins = np.arange(min_g - step/2, max_g + step, step)
                grade_labels = [f'{b+step/2:.1f}' for b in grade_bins[:-1]]
                if not temp_df.empty:
                    temp_df['grade_bin'] = pd.cut(temp_df['grade'], bins=grade_bins, labels=grade_labels, right=False)
                    sum_dist_per_grade = temp_df.dropna(subset=['grade_bin']).groupby('grade_bin')['distance_diff'].sum()
                    sum_dist_per_grade = sum_dist_per_grade[sum_dist_per_grade > 0]
                    if not sum_dist_per_grade.empty:
                        sum_dist_per_grade.plot(kind='bar', ax=self.ax, edgecolor='black')
                        self.ax.set_xlabel('Grade (%)'); self.ax.set_ylabel('Total Distance (m)')
                        self.fig.suptitle(f'Total Distance per Grade (3-18%) ({self.range_status_var.get()})')
                        # ... (ラベル調整 & 統計情報表示) ...
                        if len(sum_dist_per_grade) > 15: self.ax.tick_params(axis='x', rotation=90, labelsize=8)
                        else: self.ax.tick_params(axis='x', rotation=45, labelsize=10)
                        self.fig.tight_layout(rect=[0, 0.05, 1, 0.95])

                        saka_df = temp_df[(temp_df['grade'] >= min_g) & (temp_df['grade'] <= max_g) & (temp_df['distance_diff'] > 0)]
                        if not saka_df.empty and saka_df['distance_diff'].sum() > 0.01:
                            valid_grades = saka_df['grade'].values; weights = saka_df['distance_diff'].values
                            avg_grade_dist = np.average(valid_grades, weights=weights)
                            variance = np.average((valid_grades - avg_grade_dist)**2, weights=weights)
                            std_grade_dist = np.sqrt(variance)
                            info_text = (f'平均勾配 (3-18%, 距離加重): {avg_grade_dist:.2f} %\n'
                                         f'標準偏差 (3-18%, 距離加重): {std_grade_dist:.2f} %')
                        else: info_text = "距離加重の勾配統計は計算できませんでした\n(対象データ不足)。"
                        ttk.Label(self.info_frame, text=info_text, justify=tk.LEFT).pack(pady=5, padx=10, anchor=tk.W)
                        print(info_text)
                    else: messagebox.showwarning("警告", "積算距離を計算できる勾配データ(3-18%)がありませんでした。"); return
                else: messagebox.showwarning("警告", "有効な勾配または距離データがありません。"); return

            elif treatment in ['箱ひげ図(坂)', 'バイオリンプロット(坂)']:
                 if not check_data_availability(['grade']): return
                 saka_grades = grade[(grade >= 3.0) & (grade <= 25.0)].dropna()
                 if not saka_grades.empty:
                     plot_title_suffix = f'Slope (3-25%) ({self.range_status_var.get()})'
                     if treatment == '箱ひげ図(坂)':
                         self.ax.boxplot(saka_grades, vert=True, showfliers=True, patch_artist=True)
                         self.fig.suptitle('Box Plot of Grade on ' + plot_title_suffix)
                     else:
                         parts = self.ax.violinplot(saka_grades, vert=True, showmeans=True, showmedians=False, widths=0.7)
                         # ... (色付け) ...
                         for pc in parts['bodies']: pc.set_facecolor('skyblue'); pc.set_edgecolor('black'); pc.set_alpha(0.7)
                         for partname in ('cbars','cmins','cmaxes','cmeans'): parts[partname].set_edgecolor('black'); parts[partname].set_linewidth(1)
                         parts['cmeans'].set_edgecolor('red')
                         self.fig.suptitle('Violin Plot of Grade on ' + plot_title_suffix)
                     self.ax.set_ylabel('Grade (%)'); self.ax.set_xticklabels(['Slope (3-25%)'])
                 else: messagebox.showwarning("警告", "3%から25%の範囲の有効な勾配データが見つかりませんでした。"); return

            elif treatment == '激坂ファクター':
                 if not check_data_availability(['grade', 'distance']): return
                 self.ax.clear(); self.ax.axis('off'); self.fig.suptitle('')
                 # ★激坂ファクターも target_df で計算
                 results = self._calculate_gekizaka_factor(target_df) # target_df を渡す
                 if results:
                     info_text = f"激坂ファクター ({self.range_status_var.get()}):\n"
                     factor_names = ["1 (15.5%+)", "2 (12.5-15%)", "3 (9.5-12%)", "4 (重視)", "5 (総合)"]
                     for i, factor in enumerate(results): info_text += f"  F{factor_names[i]}: {factor:.3f}\n"
                     label_gekizaka = ttk.Label(self.info_frame, text=info_text.strip(), font=("Meiryo UI", 10), justify=tk.LEFT)
                     label_gekizaka.pack(pady=5, padx=10, anchor=tk.NW)
                     print(info_text.strip())
                     self.info_frame.update_idletasks()
                 else:
                     info_text = f"激坂ファクターを計算できませんでした。\n({self.range_status_var.get()})"
                     ttk.Label(self.info_frame, text=info_text, font=("Meiryo UI", 10), justify=tk.LEFT).pack(pady=5, padx=10, anchor=tk.NW)
                     self.info_frame.update_idletasks()

            else:
                messagebox.showerror("エラー", f"未実装の解析タイプ: {treatment}")
                return

            # --- グラフ共通設定と最終描画 ---
            if treatment != '激坂ファクター':
                 self.ax.grid(True, linestyle='--', alpha=0.6)
                 self.fig.tight_layout(rect=[0, 0.05, 1, 0.95]) # 上下のマージン調整

            self.canvas.draw()
            print("グラフ/情報表示 完了。")

        except Exception as e:
            messagebox.showerror("解析エラー", f"解析または描画中に予期せぬエラーが発生しました:\n{e}")
            traceback.print_exc()
            if self.ax: self.ax.clear(); self.ax.set_title("エラー発生");
            if self.ax2:
                if self.ax2 in self.fig.axes: self.fig.delaxes(self.ax2);
                self.ax2 = None;
            if self.canvas: self.canvas.draw()

    def _show_missing_data_error(self, missing_cols, is_filtered=False):
        """必要なデータ列がない場合にエラーメッセージを表示"""
        range_info = "指定された範囲" if is_filtered else "ファイル全体"
        messagebox.showerror("データ不足エラー",
                             f"{range_info}において、解析に必要なデータが不足しているか、全て無効です:\n"
                             f" -> {', '.join(missing_cols)}\n"
                             f"入力ファイルを確認するか、範囲指定を変更してください。")
        # (エラー時のグラフクリア処理 - 変更なし)
        if self.graph_window and self.graph_window.winfo_exists():
            if self.ax:
                self.ax.clear(); self.ax.set_title("データ不足エラー")
                self.ax.text(0.5, 0.5, f"必要なデータ\n({', '.join(missing_cols)})\nがありません",
                             ha='center', va='center', transform=self.ax.transAxes, color='red', fontsize=12)
            if self.ax2:
                if self.ax2 in self.fig.axes: self.fig.delaxes(self.ax2)
                self.ax2 = None
            for widget in self.info_frame.winfo_children(): widget.destroy()
            if self.canvas: self.canvas.draw()


    def _calculate_grade_distribution_by_distance(self, df_to_analyze):
        """
        【引数追加】指定されたDataFrameの勾配ごとの積算距離を計算し、辞書として返す。
        """
        # ★引数 df_to_analyze を使用
        if df_to_analyze is None or not all(col in df_to_analyze.columns and not df_to_analyze[col].isnull().all() for col in ['grade', 'distance']):
            print("勾配または距離データが不足しているため、分布を計算できません。")
            return None

        dist_diff = df_to_analyze['distance'].diff().fillna(0).clip(lower=0)
        temp_df = pd.DataFrame({
            'grade': df_to_analyze['grade'],
            'distance_diff': dist_diff
        }).dropna(subset=['grade'])

        if temp_df.empty or temp_df['distance_diff'].sum() < 0.01:
             print("有効な勾配データまたは走行距離がありません。")
             return {}

        min_g, max_g, step = -30.0, 30.0, 0.5
        grade_bins = np.arange(min_g - step / 2, max_g + step, step)
        grade_labels = np.arange(min_g, max_g + step / 2, step)

        temp_df['grade_bin_label'] = pd.cut(temp_df['grade'], bins=grade_bins, labels=grade_labels, right=False)
        sum_dist_per_grade = temp_df.dropna(subset=['grade_bin_label']).groupby('grade_bin_label')['distance_diff'].sum()

        return sum_dist_per_grade.to_dict()


    def _calculate_gekizaka_factor(self, df_to_analyze):
        """【引数追加】指定されたDataFrameから激坂ファクターを計算する"""
        # ★引数 df_to_analyze を使用して勾配分布を計算
        dist_per_grade = self._calculate_grade_distribution_by_distance(df_to_analyze)
        if dist_per_grade is None: return None
        if not dist_per_grade:
            print("勾配ごとの距離データが空のため、激坂ファクターは計算できません。")
            return None

        # (ファクター計算ロジック - 変更なし、内部の関数呼び出しはOK)
        def get_dist_sum(min_g, max_g):
            return sum(dist for g, dist in dist_per_grade.items() if (min_g - 1e-6) <= g <= (max_g + 1e-6))

        sum_dist_3_6 = get_dist_sum(3.0, 6.0); sum_dist_65_9 = get_dist_sum(6.5, 9.0)
        sum_dist_95_12 = get_dist_sum(9.5, 12.0); sum_dist_125_15 = get_dist_sum(12.5, 15.0)
        sum_dist_155_18 = get_dist_sum(15.5, 18.0)
        total_dist_relevant = sum_dist_3_6 + sum_dist_65_9 + sum_dist_95_12 + sum_dist_125_15 + sum_dist_155_18

        if total_dist_relevant <= 1e-6:
            range_info = f" ({self.range_status_var.get()})" if self.is_range_applied else ""
            messagebox.showwarning("警告", f"激坂ファクター計算対象勾配(3%+)での走行距離がほぼ0です{range_info}。\nファクターは全て0になります。")
            return [0.0, 0.0, 0.0, 0.0, 0.0]
        try:
            factor1 = sum_dist_155_18 / total_dist_relevant; factor2 = sum_dist_125_15 / total_dist_relevant
            factor3 = sum_dist_95_12 / total_dist_relevant
            factor4 = (sum_dist_95_12 + 2 * sum_dist_125_15 + 3 * sum_dist_155_18) / total_dist_relevant
            factor5 = (0.5 * sum_dist_3_6 + sum_dist_65_9 + 1.5 * sum_dist_95_12 + 3 * sum_dist_125_15 + 5 * sum_dist_155_18) / total_dist_relevant
            return [factor1, factor2, factor3, factor4, factor5]
        except ZeroDivisionError: return [0.0, 0.0, 0.0, 0.0, 0.0]


    def _save_data(self):
        """現在のデータフレーム(範囲指定されていればその範囲)をCSVファイルに保存する"""
        # --- ★保存対象データフレームの決定 ---
        if self.is_range_applied and self.df_filtered is not None and not self.df_filtered.empty:
            df_to_save = self.df_filtered
            suggested_filename = f"data_{self.start_dist_var.get():.0f}m_{self.end_dist_var.get():.0f}m.csv"
        elif self.df is not None and not self.df.empty:
            df_to_save = self.df
            suggested_filename = "data_all.csv"
        else:
            messagebox.showwarning("保存不可", "保存するデータがありません。ファイルを読み込んでください。")
            return

        filepath = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSVファイル", "*.csv"), ("すべてのファイル", "*.*")],
            initialfile=suggested_filename, # ★ファイル名の候補を提示
            title="CSVファイルとして保存"
        )
        if not filepath: return

        try:
            df_to_save.to_csv(filepath, index=False, encoding='utf-8-sig')
            messagebox.showinfo("成功", f"データが正常に保存されました:\n{filepath}")
            print(f"データ保存完了: {filepath}")
        except Exception as e:
            messagebox.showerror("保存エラー", f"データのCSV保存中にエラーが発生しました:\n{e}")
            traceback.print_exc()

    def _save_figure(self):
        """現在表示されているグラフを画像ファイルとして保存する"""
        # (大きな変更なし、ファイル名候補に範囲情報を入れる)
        if self.fig is None or self.canvas is None or self.graph_window is None or not self.graph_window.winfo_exists():
             messagebox.showwarning("保存不可", "保存するグラフが表示されていません。\n解析を実行してください。")
             return
        if not self.ax or not self.ax.has_data():
             if self.treatment_var.get() == '激坂ファクター': messagebox.showwarning("保存不可", "激坂ファクターの結果表示はグラフとして保存できません。")
             else: messagebox.showwarning("保存不可", "表示されている内容にはグラフデータがありません。")
             return

        # ★ファイル名候補の生成
        treatment_safe = self.treatment_var.get().replace('/','_').replace('&','_').replace(' ','')
        range_suffix = f"_{self.start_dist_var.get():.0f}m_{self.end_dist_var.get():.0f}m" if self.is_range_applied else "_all"
        suggested_filename = f"graph_{treatment_safe}{range_suffix}.png"

        filepath = filedialog.asksaveasfilename(
            defaultextension=".png",
            filetypes=[("PNGファイル", "*.png"), ("JPEGファイル", "*.jpg"), ("PDFファイル", "*.pdf"), ("SVGファイル", "*.svg"), ("すべてのファイル", "*.*")],
            initialfile=suggested_filename, # ★ファイル名の候補を提示
            title="グラフを画像として保存"
        )
        if not filepath: return

        try:
            self.fig.savefig(filepath, dpi=300, bbox_inches='tight')
            messagebox.showinfo("成功", f"グラフが正常に保存されました:\n{filepath}")
            print(f"グラフ保存完了: {filepath}")
        except Exception as e:
            messagebox.showerror("保存エラー", f"グラフの保存中にエラーが発生しました:\n{e}")
            traceback.print_exc()


# アプリケーションの実行
if __name__ == "__main__":
    root = tk.Tk()
    app = FitAnalyzerApp(root)
    root.mainloop()

使ってみよう!

このアプリ(のコード)があれば、あなたも自分の走行データを自由自在に分析できます。
1. Python環境と上記のライブラリ (fitdecode, pandas, numpy, matplotlib) をインストールします。
2. 提供されたPythonコード (.py ファイル) を実行します。
3. アプリが起動したら、「選択」ボタンで .fit ファイルを選びます。
4. 必要に応じて「範囲指定」セクションで分析したい距離を入力し、「適用」ボタンを押します。
5. 「解析内容選択」で目的のグラフや分析を選び、「実行」ボタンを押します。
6. 別ウィンドウに表示された結果を確認します。
7. 必要なら「データ保存」や「グラフ保存」を行います。
8. 別の範囲や解析を試したい場合は、ステップ4や5に戻ります。

まとめ

今回は、Pythonで自作したFITファイル解析アプリをご紹介しました。特に距離範囲を指定して分析できる機能は、特定の峠のアタック分析や、コースのセクションごとの特性把握に役立つと思います。
プログラミングは少しハードルが高いかもしれませんが、自分のデータを深く知るための強力なツールになります。ケイデンスやパワーデータなどを加えるとより詳細な解析につながるかと思います。
ぜひ、このアプリ(コード)を参考に、あなただけのサイクリングデータ分析環境を構築してみてはいかがでしょうか?

Discussion