どうもこんにちは!まろんです!
今日はちょっと実務よりな話、CarScannerで撮った複数のCSVログを、Pythonでまとめて結合する方法について書いていこうと思います!
以前、CarScannerのCSVをPandasで整形してグラフ化する記事(post112)を書いたんですが、あれはあくまで1ファイル分の話でした。実際に3年分のログを使って「実測燃費を検証する記事」や「EVならない病を調べる記事」を書こうとした時、219本のCSVを1本ずつ手作業で開いて処理していくのはさすがに無理があって…。じゃあどうやってまとめて集計したのか、今日はその「結合」の部分だけに絞って、実際に使っているコードを書いていきます!
この記事でできるようになること
先に結論です。この記事の内容を押さえると、次のところまでたどり着けます。
- CarScannerのCSVを複数まとめて、1つのDataFrameに結合できるようになる
- ファイルごとに列(記録されているPID)が違っても、結合処理が崩れないようにできる
- ファイル名から日時を自動で取り出し、どのファイル・どの日の記録かを後から追跡できるようにする
逆に言うと、今日書くのは「結合」までです。結合した後の燃費計算やEV走行の判定といった集計は、公開済みのpost448(実測燃費3年分の検証記事)や、近く公開予定のpost460(EVならない病の調査記事)で扱う内容なので、この記事では触れません。競合ほぼゼロのニッチな話ですが、CarScannerで長くログを取っている方には刺さる内容だと思います!
前提:なぜファイルが複数に分かれるのか(ここでは深掘りしません)
CarScannerは、手動で「Disconnect」ボタンを押した時か、記録(Record data)をオフにした時にファイルが確定する仕様です。エンジンの始動・停止では区切られないので、これを知らずに運用していると、複数のトリップが1つのファイルに繋がってしまうことがあります。
この仕様そのものは前回の記事で詳しく書いたので、気になる方はそちらを先に読んでみてください。この記事では「ファイルは基本的に1回の運転=1本になっている」状態からスタートします。
CarScannerのログが途中で分かれる・繋がる理由(前回の記事)
今回の前提:post112のコードで1ファイルずつ整形したものを使う
CarScannerが書き出す生のCSVは、SECONDS・PID・VALUE・UNITSといった列を持つ「1行=1つのPIDの1回の記録」という縦長の形をしています。post112では、この形をPandasのpivot_tableで「日時×PID」の横持ちの表に変換するコードを紹介しました。
このpivot_table変換自体は1ファイルなら難しくありません。ただし1つだけ面倒な点があって、SECONDS列をそのままUnix時間に変換すると、日付が「1970-01-01」になってしまうんです。post112では、この日付をファイルごとに手入力で今の日付に置き換えていました。1本ずつ処理する分にはそれで十分だったんですが、219本もあると手入力はさすがに現実的じゃありません。
結合の壁①:ファイル名からトリップの日時を自動で取り出す
ここでCarScannerの仕様がひとつ役に立ちます。公式サイトによると、記録ファイルのデフォルトのファイル名は「YEAR-MONTH-DAY HOUR-MINUTE-SECOND」形式のタイムスタンプで付けられるとのことです(CarScanner公式・Data recordingページ、2026/08/07現在)。つまりファイル名自体に、その記録を始めた日時が書かれているわけです。
これを正規表現で読み取れば、手入力していた日付を自動で取得できます。
import re
from datetime import datetime
# CarScannerのデフォルトファイル名 "YEAR-MONTH-DAY HOUR-MINUTE-SECOND" から日時を取り出す
FILENAME_RE = re.compile(
r'(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2}) '
r'(?P<h>\d{2})-(?P<mi>\d{2})-(?P<s>\d{2})'
)
def parse_trip_start(path):
m = FILENAME_RE.search(path)
if not m:
# リネームしてしまったファイルなどは日時が取れないのでNoneを返す
return None
g = m.groupdict()
return datetime(
int(g['y']), int(g['mo']), int(g['d']),
int(g['h']), int(g['mi']), int(g['s'])
)
注意点がひとつあります。公式サイトにも書かれている通り、このファイル名は後から手動でリネームできてしまいます。うちでも一部リネームしていたファイルがあって、その分は日時が拾えずNoneになりました。全部のファイルで日時が取れる保証はない、という前提で組んでおくのが安全です。
結合の壁②:ファイルごとに列(PID)が違う
CarScannerは「画面に表示されているデータのみ」を記録する仕様です(同じく公式サイトより)。表示していたPIDに付随する関連パラメータも合わせて記録されるとのことですが、逆に言えば、その時に表示していなかったPIDはそもそも記録されません。
3年間も運用していると、表示するPIDの組み合わせを変えたタイミングが何度かあります。結果として、pivot_tableで横持ちに変換した後の列(PIDごとの列)は、ファイルによって完全には揃いません。単純にpandas.concatで縦に繋げようとすると、この「列が揃っていない」問題にそのままぶつかります。
実際に使っているコード:複数ファイルを読み込んで結合する
さきほどのparse_trip_startと組み合わせて、実際に使っているコードはこんな感じです。
import pandas as pd
import glob
def load_one_trip(path):
trip_start = parse_trip_start(path)
# ここまではpost112と同じ読み込み・pivot_table処理
df = pd.read_csv(path, sep=';')
df['SECONDS'] = df['SECONDS'].round(0).astype('int64')
df['datetime'] = pd.to_datetime(df['SECONDS'], unit='s')
if trip_start is not None:
# post112では手入力していた「1970-01-01を実際の日付に置き換える」処理を自動化
# 注意: 日をまたぐトリップ(23時台開始→0時台へ進行、など)では
# year/month/dayをtrip_startで一律上書きするこの補正が崩れるので注意
df['datetime'] = df['datetime'].apply(
lambda x: x.replace(
year=trip_start.year, month=trip_start.month, day=trip_start.day
)
)
# pivot_tableはindex/columns/valuesに指定した列しか見ないので、
# UNITSなど他の列はそのままで大丈夫です
df_pivot = df.pivot_table(index='datetime', columns='PID', values='VALUE')
df_pivot = df_pivot.reset_index()
# 後から「どのファイル由来か」を追跡できるように列を追加
df_pivot['trip_start'] = trip_start
df_pivot['source_file'] = path
return df_pivot
csv_paths = sorted(glob.glob('ログが入っているフォルダのパス/*.csv'))
trip_frames = [load_one_trip(p) for p in csv_paths]
# axis=0の結合はデフォルトでjoin='outer'なので、列が揃っていなくても
# 自動的に列の和集合を取り、無い列はNaNで埋めてくれます。
# sort=Falseは列の並び順をアルファベット順にしないためのオプションです。
merged = pd.concat(trip_frames, ignore_index=True, sort=False)
# 219本を毎回読み直すのは時間がかかるので、結合済みのものを1つのファイルに保存しておきます
merged.to_csv('merged_all_trips.csv', index=False)
ポイントはpd.concatのjoin='outer'(デフォルト値なので明示していませんが)です。列が完全に一致していなくても、エラーにはならず、片方のファイルにしかない列は自動的に追加され、値が無い部分はNaN(欠損値)で埋まります。「列がバラバラだから結合できない」と身構える必要は、実はそこまでありませんでした。
結合したデータ、そのあとどうしているか
マージ後のデータにはトリップをまたいだ全期間のデータが入っていて、trip_startとsource_fileの列を使えば、あとからgroupbyで1トリップずつに区切って集計できます。ここから先の話、たとえば「どの条件を満たしたトリップだけ燃費計算に使うか」「EV走行をどう代理判定するか」といった中身の集計ロジックは、実際に公開済みのpost448や、近く公開予定のpost460で使っているものなので、この記事では数字ごと譲ります。
正直に言うと、これは地味な下ごしらえの話です。ただ、この結合処理を先に作っておかないと、219本を横断した「3年分の答え合わせ」自体がそもそも成立しませんでした。派手さはないですが、後ろの記事たちを支えている土台がここです。
正直に言うと、この記事は収益にはほぼ直結しません
この記事はコードの解説が中心で、直接おすすめできる商品はほとんどありません。Python・Pandasはどちらも無料のツールなので、アフィリエイトリンクの出しようがないんです。無料のアプリやツールにリンクを貼らないのは、このブログの方針でもあります。
強いて紹介できるとしたら、そもそものログの取得に使っているアダプタくらいです。うちはOBDLink MX+というBluetoothアダプタでCarScannerに記録していますが、OBD機器を車両に接続してデータを取得する行為自体は、車両や機器の組み合わせによっては不具合の原因になりうるものです。あくまで自己責任での利用となり、ディーラーやメーカー保証の扱いに影響する可能性もある点はご承知おきください。
ScanTool OBDLink MX+(Bluetooth OBD2アダプタ・iOS対応)
この記事で結合しているCSVも、元はこのアダプタでCarScannerに記録したものです。
Amazonで見るこんな人には向かない記事です
- CarScanner以外のロガーアプリを使っている人(保存形式や列構成の前提がそもそも違います)
- ログがまだ数本しかない人(post112の1ファイル分の処理で十分間に合います)
- Pythonを普段使わない人(コードのコピペと多少の書き換えが発生します)
よくある質問
Q1. 結合したCSVを、またCarScannerアプリに読み込ませることはできますか?
できません。公式サイトでも「インポートできるのはBRC形式のファイルのみで、CSV形式のインポートはできない」と明記されています。Pythonで加工したデータは、アプリの外(PythonやExcelなど)で扱う前提になります。
Q2. ログが数本くらいなら、この結合処理は必要ないですか?
はい、数本程度で列構成もほぼ同じなら、素直にpandas.concatで縦に繋げるだけで十分だと思います。列がファイルごとに変わってくるのは、表示するPIDを変えたり、期間が長くなったりしてからの話です。
Q3. 文字コードでエラーが出たらどうすればいいですか?
うちの環境ではUTF-8で問題なく読み込めていますが、環境によっては文字コードでつまずくこともあるようです。うまく読み込めない場合は、pd.read_csvのencoding引数に'shift_jis'や'cp932'を指定して試してみてください。
Q4. 何本くらい溜まったら結合処理をした方がいいですか?
正直、明確な基準は持っていません。私の場合は3年分・219本になった時点で「さすがに1本ずつは無理」と感じて手を付けましたが、もっと少ない本数でも面倒に感じる人はいると思います。手作業がしんどくなったタイミングで、というのが実感に近いです。
Q5. Excelでも同じことはできますか?
公式サイトにもある通り、CSV自体はExcelでも開ける単純な形式です。ただ219本のファイルを1つずつ開いて手作業で結合するのは、私にはちょっと現実的ではありませんでした。数本程度ならExcelでも十分だと思います。
もう少し基本的な部分(1ファイルの整形・グラフ化)や、ファイルが分かれる仕様そのものが気になった方は、こちらもどうぞ!


まとめ:結合の鍵は「列の和集合」と「ファイル名からの日時取得」
- 列の違い:
pandas.concatのデフォルト(join=’outer’)に任せれば、列が揃っていなくても自動的に和集合を取ってくれる - 日付の自動化:ファイル名の「YEAR-MONTH-DAY HOUR-MINUTE-SECOND」形式を使えば、1970-01-01になってしまう日付を自動で修正できる
- 集計は別記事:結合後の燃費計算やEV判定といった中身の集計は、公開済みのpost448や、近く公開予定のpost460に譲る
というわけで、地味な下ごしらえの話でしたが、219本のログを横断する記事たちの裏側はこんな感じでした!派手な発見はありませんが、同じようにログが溜まってきて「そろそろまとめて見たいな」と思っている方の参考になれば嬉しいです。では今回はこの程度で〜!

コメント