Claude CodeでバスのGTFSを集計し、地域の公共交通計画の現況表にする
GTFS-JPのstops・stop_times・routes・calendarをClaude Codeに読ませ、停留所別の便数・路線別の運行間隔・最終便の時刻を出す手順。24時超えと運行日の例外で数字がずれる点まで押さえます。
地域の公共交通計画の現況編には、路線ごとの便数や運行間隔、最終便の時刻といった表が並びます。バス事業者から時刻表のPDFをもらって手で数えると、半日では終わりません。
バス路線のデータが標準的なバス情報フォーマット(GTFS-JP)で手元にあるなら、この集計はスクリプトにできます。この記事では、停留所別の便数、路線別の運行間隔、最終便の時刻をClaude Codeに出させる手順を組みます。つまずきやすいのは、集計の書き方よりも「何を1便と数えるか」の定義です。例のデータは手順確認用に作った合成のもので、実在の路線ではありません。
現況編の表は、GTFSのどのファイルから出るか
必要な数字は3種類で、使うファイルは決まっています。
| 現況編の数字 | 使うファイル | 結合のしかた |
|---|---|---|
| 停留所別の1日の便数 | 使うファイルstops.txt / stop_times.txt / trips.txt / calendar.txt | 結合のしかたstop_idで停留所、trip_idで便、service_idで運行日 |
| 路線別の運行間隔 | 使うファイルroutes.txt / trips.txt / stop_times.txt | 結合のしかたroute_idで路線、始発停留所の発車時刻を並べる |
| 最終便の時刻 | 使うファイルstop_times.txt(departure_time) | 結合のしかた運行日ごとに最大値を取る |
GTFS-JPの仕様書は、バスの運行経路を表すroutes.txtと、同じ経路を走る便をまとめる関係を次のように定めています。同一経路を運行する便ID(trip_id)を経路ID(route_id)に紐付けることで、複数の便をまとめて管理します。停留所は停留所・標柱区分(location_type)で、停留所と標柱(バス停のポール)に分かれます。標柱は親停留所情報(parent_station)に停留所のIDを入れて、停留所に紐付けます。
ここで押さえたいのは、時刻表そのものはstop_times.txtにしか無いことです。運行間隔を直接持つfrequencies.txtは、仕様書上は任意のファイルで、国内の経路検索で設定する必要性は低いとされています。つまりGTFS-JPのデータに「30分間隔」と書いた列はありません。間隔は、発車時刻を並べて自分で計算する数字です。
集計の定義をCLAUDE.mdに固定する
Claude Codeに「便数を集計して」とだけ頼むと、毎回少しずつ違う数え方になります。終点を含めるか、土日と平日を混ぜるかで、同じデータから別の数字が出るからです。定義は先に文章で固定し、プロジェクトのCLAUDE.mdに置きます。
# GTFS集計のルール
- 対象日は引数で受け、calendar.txtの曜日列と期間を見てから
calendar_dates.txtの例外(1=追加、2=除外)を当てる
- 便数は「その停留所から発車する便」。各便の終点は数えない
- pickup_type=1(乗車不可)の停留所は発車に数えない
- 停留所は標柱ではなく親停留所(parent_station)にまとめる
- 時刻は24:00:00以降を許す。文字列比較でなく秒に直して比べる
- 結果はCSVで出し、対象日と有効なservice_idを1行目に書く置き場所はCLAUDE.mdが向いています。理由は、Claude Codeが新しいセッションの開始時にこのファイルを読むからで、担当が替わっても同じ定義で数えられます。ノートやドキュメントのフォルダでもClaude Codeが動くことは、一般的なワークフローの案内にも書かれており、データ用のフォルダにそのまま置いて構いません。
集計スクリプトを書かせる
定義を置いたら、フィードの置き場所と目的を渡して書かせます。
feed/ にGTFS-JPのフィードがある。CLAUDE.mdの集計ルールに従い、
対象日を引数にして、停留所別の便数・始発・最終と、
路線・方向別の便数・最大間隔・平均間隔を出すPythonを書いて。
標準ライブラリだけで書き、まず20261021で動かして。書かせるときは、実行前に計画を見せる設定が使えます。計画モード(claude --permission-mode plan)は、編集の前に方針を確認する流れで、集計の定義とずれていないかを見る場面に向きます。
出来上がるスクリプトの一例が次のものです。標準ライブラリだけで動き、合成データで実行して出力を確かめています。
import csv, sys, datetime as dt
from collections import defaultdict
feed, ymd = sys.argv[1], sys.argv[2]
def rd(n):
with open(f"{feed}/{n}", encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def sec(t): # 24:00:00 以降を許す
h, m, s = map(int, t.split(":")); return h*3600+m*60+s
def hhmm(s): return f"{s//3600:02d}:{s%3600//60:02d}"
d = dt.datetime.strptime(ymd, "%Y%m%d").date()
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday",
"saturday", "sunday"]
dow = DAYS[d.weekday()]
active = {c["service_id"] for c in rd("calendar.txt")
if c[dow]=="1" and c["start_date"] <= ymd <= c["end_date"]}
for e in rd("calendar_dates.txt"):
if e["date"] == ymd:
if e["exception_type"] == "1":
active.add(e["service_id"])
else:
active.discard(e["service_id"])
trips = {t["trip_id"]: t for t in rd("trips.txt") if t["service_id"] in active}
stops = {s["stop_id"]: s for s in rd("stops.txt")}
routes = {r["route_id"]: r for r in rd("routes.txt")}
by_trip = defaultdict(list)
for r in rd("stop_times.txt"):
if r["trip_id"] in trips: by_trip[r["trip_id"]].append(r)
def group(sid): # 標柱 -> 親停留所
s = stops[sid]; return s["parent_station"] or sid
dep = defaultdict(list) # 停留所別の発車時刻
origin = defaultdict(list) # (路線,方向) の始発発車時刻
for tid, rows in by_trip.items():
rows.sort(key=lambda r: int(r["stop_sequence"]))
key = (trips[tid]["route_id"], trips[tid]["direction_id"])
origin[key].append(sec(rows[0]["departure_time"]))
for r in rows[:-1]: # 終点は発車しない
if r["pickup_type"] != "1":
dep[group(r["stop_id"])].append(sec(r["departure_time"]))
print(f"## {ymd} 有効な service_id: {sorted(active)}")
print("\n停留所,名称,便数,始発,最終")
for g, v in sorted(dep.items()):
print(f"{g},{stops[g]['stop_name']},{len(v)},{hhmm(min(v))},{hhmm(max(v))}")
print("\n路線,方向,便数,始発,最終,最大間隔,平均間隔(分)")
for (rid, di), v in sorted(origin.items()):
v.sort(); gaps = [(b-a)//60 for a, b in zip(v, v[1:])]
name = routes[rid]["route_long_name"]
mx = max(gaps) if gaps else "-"
avg = round(sum(gaps) / len(gaps)) if gaps else "-"
print(f"{name},{di},{len(v)},{hhmm(v[0])},{hhmm(v[-1])},{mx},{avg}")読み込む列はGTFSの基本項目だけです。stop_times.txtのarrival_timeとdeparture_timeは、仕様上HH:MM:SS形式で、日付をまたぐ便では24:00:00より大きい値を取ります。stop_sequenceは便の中で増えていく値で、連番である必要はありません。そのためスクリプトでは並べ替えてから先頭と末尾を取ります。
出力の読み方
合成データは、町営バスの2路線と4停留所です。町役場前だけ、上り用と下り用の2本の標柱が親停留所にまとまっています。水曜(2026-10-21)で動かした結果は次のとおりです。
python3 gtfs_summary.py feed 20261021## 20261021 有効な service_id: ['WD']
停留所,名称,便数,始発,最終
S1,町役場前,11,06:30,25:00
S2,駅前,16,06:42,25:12
S3,病院,8,07:30,20:00
路線,方向,便数,始発,最終,最大間隔,平均間隔(分)
駅前線,0,11,06:30,25:00,210,111
駅前線,1,5,07:30,20:00,225,188
山の上線,0,3,08:00,17:00,300,270この表から現況編に書ける文が3つ取れます。
- 駅前は駅前線の上りと下りが通り、平日は1日16便が発車する
- 駅前線は始発が6時30分、最終が25時00分(翌日1時)発
- 山の上線は平均270分に1本で、最大の空きは300分
見落としやすいのは、山の上停留所(S4)が停留所別の表に出ていない点です。この停留所は山の上線の終点で、発車する便が無いため0便になり、行ごと消えます。「終点は数えない」という定義の帰結ですが、表を読む人には「山の上にバスは来ない」と見えかねません。着便数も別の列で出すか、注記を入れる必要があります。
数字がずれる4つの場面
24時以降の時刻を文字列で比べる
駅前線の最終便は25時00分発です。ダイヤ上は前日の運行に含まれ、実際は翌日の午前1時です。仕様は、運行日(サービスデイ)が24:00:00を超えうるとしています。たとえば金曜の8時から土曜の2時まで走るサービスは、同じ運行日の中で8:00:00から26:00:00と書けます。
落とし穴
24時超えの時刻で例外が出る、または順序が狂う
時刻をdatetime.timeで読むと、25:00:00は不正な値として例外になります。文字列で比べる方法も安全ではありません。仕様ではH:MM:SSの書き方も認められているので、9:00:00が18:00:00より後ろに並ぶことがあります。秒に直して比べ、表に出すときだけ25:00または「翌1:00」に整えます。
平日と土日を混ぜて数える
同じフィードに平日用と休日用の便が入っています。運行日の指定を忘れると、この例では町役場前の標柱の発車が16件になります(平日11件と休日5件の合計)。現況編に書く数字は、平日・土曜・日曜祝日の運行日ごとに分けて出します。
祝日や学校休業日の例外を見ない
GTFS-JPでは、曜日ごとの基本パターンをservice_idにし、祝日などで平日ダイヤが休日ダイヤになる日はcalendar_dates.txtで当日の運行ダイヤが変わる旨を設定するのが基本とされています。exception_typeは、1がその日の運行の追加、2が除外です。
例のデータには、2026年11月3日(火)が祝日で、平日ダイヤを除外し休日ダイヤを追加する行を入れてあります。この日付で動かすと、有効なservice_idはHDだけになり、町役場前は5便に変わります。曜日だけで判断するスクリプトだと、この日を平日の11便と数えます。
仕様書には、継続的に日付を設定するのが難しい場合に、平日・土曜・日曜・祝日などをあらかじめ定めた8種のservice_idで運行日を表す方法も書かれています。このタイプのフィードは、service_idの名前から運行日の種類を読む必要があり、calendar.txtだけでは日付が決まりません。集計の前に、service_idの一覧を見ておくと安全です。
標柱と停留所の数え方、要予約の便
停留所は、代表点のみ、標柱のみ、親子設定の3つの作り方があります。親子設定でなければ、parent_stationは空で、上り・下りが別の停留所として数えられます。親にまとめるかどうかで、停留所別の便数の見え方が変わります。現況編の「停留所数」と「バス停の数」が食い違う原因もここにあります。
予約が必要な便は、pickup_typeが2(電話予約)や3(運転手と調整)で表されます。0または空が通常の乗車、1が乗車不可です。デマンド区間を含む路線では、集計に含める条件をCLAUDE.mdに足してください。デマンド区間を含む路線は、定時の便と分けて書くと誤解が減ります。
Claude Codeに検算させる
集計結果は、別の方法で数え直すと信頼できます。検算の例として、次の3点を元ファイルから直接数えます。
awk -F, 'NR>1 && $2=="WD"' feed/trips.txt | wc -l
grep -c ",S1_1," feed/stop_times.txt
awk -F, '$3 ~ /^2[4-9]:/' feed/stop_times.txt結果は19、16、そして25時台の3行(T11という1便の3停留所分)です。平日の便は、駅前線の上り11と下り5、山の上線3で合計19。町役場前の標柱S1_1の通過は、平日11と休日5で16。25時台は1便分だけで、スクリプトが出した最終の25:00と一致します。
この種の確認は、Claude Codeに「別の方法で数え直して、食い違いがあれば原因を示して」と頼むのが効きます。一般的なワークフローの案内には、Claudeを非対話で呼び、標準入力と標準出力をほかのコマンドと同じように繋ぐ使い方も書かれています。たとえばgit log --oneline -20 | claude -p "..."の形です。集計結果のCSVをclaude -pに渡して、定義と矛盾する行が無いかを見せる運用もできます。ただし、数字そのものの正しさは、手元の元データで数えた値との突き合わせで担保します。
現況編の表に整える
最終的な表は、読む人が計画に使う形にします。たとえば次のような列です。
| 路線 | 方向 | 平日便数 | 土曜 | 日曜祝日 | 始発 | 最終 | 平均間隔 |
|---|---|---|---|---|---|---|---|
| 駅前線 | 方向上り | 平日便数11 | 土曜5 | 日曜祝日5 | 始発6:30 | 最終翌1:00 | 平均間隔111分 |
上の行の「土曜」「日曜祝日」は例の合成データの値で、HDという1つの運行日に土日が入っているため同数です。実際のデータでは、平日・土曜・日曜祝日で別々の対象日を選んで3回動かし、結果を並べます。最終便の「翌1:00」のような言い換えは、元の25:00が見える状態で残すと、後から検算しやすくなります。
地域の人口や施設の分布と重ねたい場合は、停留所の位置情報をそのまま使えます。e-Statの地域メッシュから年齢別の人口を出す方法は地域メッシュ統計で商圏人口を比べる手順にあり、停留所から一定距離内の人口を数える使い方と相性が良い手順です。公開用の形式に整えるなら、自治体オープンデータの標準データセット整形と同じく、変換と検査を分けて組む考え方が使えます。
まとめ
GTFS-JPの集計で効くのは、スクリプトの出来よりも定義の固定です。対象日の決め方、終点の扱い、24時超えの時刻、標柱と停留所のまとめ方をCLAUDE.mdに書いておけば、Claude Codeは同じ数え方で何度でも出し直せます。そのうえで、grepやawkで数え直した値と突き合わせれば、現況編に載せる数字として扱えます。