はじめに
EVは1日のうち22時間以上、ガレージで眠っています。日産リーフ40kWh、テスラModel Y 75kWh――スマホの1万倍以上のエネルギーを抱えた電池が、ただ駐車場にいるだけ。これを 動く蓄電池 として電力網に接続し直すのが V2G(Vehicle to Grid) です。電力価格が高い時間に放電し、安い時間に再充電する。1台あたり年間6〜8万円の収益が現実的に見える、と海外の先行事例は言います。
V2Gの肝は AIによる秒単位の最適化 です。価格、SOC(充電残量)、ユーザーの利用予定、バッテリー劣化――これらを束ねて充放電スケジュールを決める部分は、本質的には 混合整数線形計画(MILP) の問題に落とし込めます。
本記事では、note記事のテーマを開発者目線で落とし込み、PythonでV2Gの充放電スケジューラを最小ひな形として構築する具体的な手順 を解説します。
参考にした解説記事:「駐車中の電気自動車」が、街の電力を支える日——V2Gが起こす1兆円規模のエネルギー革命 | AI Robotics Quantum Lab
ゴールは以下のとおりです。
- 1台のEVについて、24時間の電力価格データから最適な充放電スケジュール をMILPで解く
- ユーザーの利用予定(朝の出発SOC・夜の帰宅SOC) を制約に入れる
- 複数台を束ねるアグリゲーターの最適化に拡張する
- バッテリー劣化コスト を加えて長期視点の最適化に近づける
- 最後にStreamlitで触れる簡易ダッシュボードを作る
対象読者は、Pythonと最適化ライブラリ(PuLPなど)に触れたことがある、エネルギー・モビリティに興味のあるエンジニアです。
1. 全体像 ― 何を作るのか
[電力市場価格 (30分 or 1時間刻み)] [EVの利用予定]
│ │
└────────► 最適化エンジン ◄───┘
(PuLP / MILP)
│
▼
時刻ごとの 充電/放電/待機 と 期待収益
問題定義(1台版):
- 変数:時刻 t における充電電力
p_ch[t]、放電電力p_dis[t]、SOCs[t] - 目的:
Σ (price[t] * p_dis[t] - price[t] * p_ch[t]) - 劣化コスト - 制約:SOCバランス、容量上下限、ピン留め時刻のSOC、充放電同時禁止
これだけで、「いつ充電して、いつ放電するか」 が解として返ってきます。
2. 環境構築
2.1 Pythonと仮想環境
mkdir v2g-lab && cd v2g-lab
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
2.2 必要ライブラリ
pip install pulp numpy pandas matplotlib
pip install streamlit # 可視化UI
pip install requests # 電力価格データ取得(オプション)
pulp は内部でCBCソルバーを使います。MILPは商用ソルバ(Gurobi / CPLEX)に切り替えるとさらに高速ですが、本記事はCBCでも十分です。
2.3 プロジェクト構成
v2g-lab/
├── data/
│ └── price_24h.csv # 価格カーブ
├── src/
│ ├── price.py # 価格データ読込
│ ├── single_ev.py # 1台版MILP
│ ├── fleet.py # アグリゲーター版
│ └── degrade.py # 劣化コスト
├── app.py # Streamlitダッシュボード
└── outputs/
3. 電力価格データを用意する
実運用ではJEPX(日本卸電力取引所)のスポット市場価格や、需給調整市場の値を取り込みますが、まずはCSVから:
# data/price_24h.csv
hour,price_jpy_per_kwh
0,10
1,9
2,8
3,8
4,9
5,11
6,15
7,22
8,28
9,18
10,14
11,13
12,15
13,16
14,17
15,19
16,24
17,32
18,35
19,28
20,22
21,18
22,14
23,11
夕方17〜19時に高騰し、深夜が安い、という典型的な形です。
# src/price.py
import pandas as pd
def load_price(path: str = "data/price_24h.csv") -> pd.Series:
df = pd.read_csv(path)
return df.set_index("hour")["price_jpy_per_kwh"]
4. 1台版:基本MILPで解く
4.1 モデル定義
# src/single_ev.py
from dataclasses import dataclass
import pulp
import pandas as pd
@dataclass
class EVConfig:
capacity_kwh: float = 60.0 # バッテリー容量
soc_min: float = 0.20 # 最低SOC比率(劣化保護+緊急走行用)
soc_max: float = 0.95 # 上限SOC比率
p_max_kw: float = 7.0 # 充放電双方の最大出力
eta_ch: float = 0.95 # 充電効率
eta_dis: float = 0.95 # 放電効率
soc_init: float = 0.50 # 開始時刻のSOC
soc_target_at_hour: tuple[int, float] = (7, 0.80) # 朝7時に80%必須
def schedule_one_ev(price: pd.Series, cfg: EVConfig) -> pd.DataFrame:
T = list(price.index)
prob = pulp.LpProblem("V2G_one_ev", pulp.LpMaximize)
# 変数
p_ch = {t: pulp.LpVariable(f"pch_{t}", lowBound=0, upBound=cfg.p_max_kw) for t in T}
p_dis = {t: pulp.LpVariable(f"pdis_{t}", lowBound=0, upBound=cfg.p_max_kw) for t in T}
soc = {t: pulp.LpVariable(f"soc_{t}",
lowBound=cfg.soc_min * cfg.capacity_kwh,
upBound=cfg.soc_max * cfg.capacity_kwh) for t in T}
# 充電 or 放電 排他フラグ(MILP化)
z = {t: pulp.LpVariable(f"z_{t}", cat="Binary") for t in T}
# 目的関数: 売電-買電
prob += pulp.lpSum(price[t] * (p_dis[t] - p_ch[t]) for t in T)
# SOC初期値
soc_prev = cfg.soc_init * cfg.capacity_kwh
for t in T:
prob += soc[t] == soc_prev + cfg.eta_ch * p_ch[t] - p_dis[t] / cfg.eta_dis
# 充放電排他
prob += p_ch[t] <= cfg.p_max_kw * z[t]
prob += p_dis[t] <= cfg.p_max_kw * (1 - z[t])
soc_prev = soc[t]
# 朝のSOC制約
target_h, target_ratio = cfg.soc_target_at_hour
if target_h in soc:
prob += soc[target_h] >= target_ratio * cfg.capacity_kwh
# 解く
prob.solve(pulp.PULP_CBC_CMD(msg=False))
rows = []
for t in T:
rows.append({
"hour": t,
"price": float(price[t]),
"charge_kw": pulp.value(p_ch[t]),
"discharge_kw": pulp.value(p_dis[t]),
"soc_kwh": pulp.value(soc[t]),
})
df = pd.DataFrame(rows).set_index("hour")
df["revenue_jpy"] = df["price"] * (df["discharge_kw"] - df["charge_kw"])
return df
4.2 実行と結果
from src.price import load_price
from src.single_ev import EVConfig, schedule_one_ev
price = load_price()
df = schedule_one_ev(price, EVConfig())
print(df.round(2))
print(f"Net revenue: {df['revenue_jpy'].sum():.0f} JPY / day")
夕方の高値時間に放電し、深夜に充電する解が出ます。1日数百円の利益=年6〜8万円のオーダーが、この最小モデルで再現できます。
5. バッテリー劣化コストを入れる
「容量フェード(容量低下)」の代表的な経験式は、サイクル数に対して劣化が進む形です。簡略化して、実効的なkWhあたり劣化コスト をペナルティに加えるだけでも実用的です。
# src/degrade.py
def degradation_cost_jpy(throughput_kwh: float,
battery_cost_jpy_per_kwh: float = 35000,
total_cycles: int = 4000) -> float:
"""総スループットからバッテリー寿命消費分を金額換算"""
capacity = 60.0
energy_lifetime_kwh = total_cycles * capacity
return throughput_kwh * (battery_cost_jpy_per_kwh * capacity) / energy_lifetime_kwh
schedule_one_ev の目的関数に - λ * (Σ p_ch + Σ p_dis) という形でペナルティを追加すると、「儲かる時間帯だけにキレイに動く」スケジュールに収束します。
6. アグリゲーター(複数台)への拡張
100台規模のフリート全体で、電力市場入札量の上限 や 同時放電の系統制約 を満たしつつ最適化するモデルです。
# src/fleet.py
import pulp
import pandas as pd
from src.single_ev import EVConfig
def schedule_fleet(price: pd.Series,
evs: list[EVConfig],
bid_max_kw: float = 200.0) -> pd.DataFrame:
T = list(price.index)
prob = pulp.LpProblem("V2G_fleet", pulp.LpMaximize)
p_ch = {(i, t): pulp.LpVariable(f"pch_{i}_{t}", 0, evs[i].p_max_kw)
for i in range(len(evs)) for t in T}
p_dis = {(i, t): pulp.LpVariable(f"pdis_{i}_{t}", 0, evs[i].p_max_kw)
for i in range(len(evs)) for t in T}
soc = {(i, t): pulp.LpVariable(f"soc_{i}_{t}",
evs[i].soc_min * evs[i].capacity_kwh,
evs[i].soc_max * evs[i].capacity_kwh)
for i in range(len(evs)) for t in T}
prob += pulp.lpSum(price[t] * (p_dis[i, t] - p_ch[i, t])
for i in range(len(evs)) for t in T)
for i, cfg in enumerate(evs):
prev = cfg.soc_init * cfg.capacity_kwh
for t in T:
prob += soc[i, t] == prev + cfg.eta_ch * p_ch[i, t] - p_dis[i, t] / cfg.eta_dis
prev = soc[i, t]
target_h, ratio = cfg.soc_target_at_hour
if target_h in T:
prob += soc[i, target_h] >= ratio * cfg.capacity_kwh
# 系統側の上限(全EVの合計放電 / 合計充電)
for t in T:
prob += pulp.lpSum(p_dis[i, t] for i in range(len(evs))) <= bid_max_kw
prob += pulp.lpSum(p_ch[i, t] for i in range(len(evs))) <= bid_max_kw
prob.solve(pulp.PULP_CBC_CMD(msg=False))
rows = []
for t in T:
ch = sum(pulp.value(p_ch[i, t]) for i in range(len(evs)))
dis = sum(pulp.value(p_dis[i, t]) for i in range(len(evs)))
rows.append({"hour": t, "price": float(price[t]),
"charge_kw_total": ch, "discharge_kw_total": dis})
return pd.DataFrame(rows).set_index("hour")
100台分の EVConfig を作って渡せば、Nuvve 的なアグリゲーター運用 が手元で再現できます。「1台ずつバラバラに最適化」した結果より総収益が大きいことを確認するのが最初のテストです。
7. 結果を可視化する
import matplotlib.pyplot as plt
def plot(df, save="outputs/schedule.png"):
fig, ax1 = plt.subplots(figsize=(8, 4))
ax1.plot(df.index, df["price"], color="black", label="Price [JPY/kWh]")
ax1.set_xlabel("hour")
ax1.set_ylabel("Price [JPY/kWh]")
ax2 = ax1.twinx()
ax2.bar(df.index, df["discharge_kw"], color="tab:orange", alpha=0.5, label="Discharge")
ax2.bar(df.index, -df["charge_kw"], color="tab:blue", alpha=0.5, label="Charge")
ax2.set_ylabel("Power [kW]")
ax2.legend(loc="upper left")
ax1.legend(loc="upper right")
plt.title("V2G schedule")
plt.savefig(save, dpi=150, bbox_inches="tight")
「価格カーブのピークで放電、深夜で充電」が一目で見える図が得られます。
8. Streamlitで触れるダッシュボードに仕上げる
# app.py
import streamlit as st
import pandas as pd
from src.price import load_price
from src.single_ev import EVConfig, schedule_one_ev
st.title("V2G スケジューラ – Demo")
cap = st.slider("Battery capacity [kWh]", 20, 100, 60, step=5)
pmax = st.slider("Max power [kW]", 3, 22, 7)
init = st.slider("Initial SOC [%]", 10, 95, 50) / 100
target_hour = st.slider("Required-by hour", 5, 9, 7)
target_soc = st.slider("Required SOC at that hour [%]", 50, 95, 80) / 100
price = load_price()
cfg = EVConfig(capacity_kwh=cap, p_max_kw=pmax, soc_init=init,
soc_target_at_hour=(target_hour, target_soc))
df = schedule_one_ev(price, cfg)
st.line_chart(df[["price"]])
st.bar_chart(df[["charge_kw", "discharge_kw"]])
st.metric("Net revenue [JPY/day]", f"{df['revenue_jpy'].sum():.0f}")
streamlit run app.py
スライダーで「容量・初期SOC・朝のSOC要求」を変えると、収益とスケジュールがリアルタイムで再計算されるダッシュボードが完成します。
9. 実運用に向けた次の一手
-
不確実性への対応:価格・利用予定はそもそも不確実です。確率的計画(SP)/ロバスト最適化/強化学習 に拡張するのが本筋です。
stable-baselines3で価格カーブを観測する強化学習エージェントを書いてみるのも面白いです。 - OCPP連携:充電器との通信規格 OCPP 1.6 / 2.0.1 のシミュレータ(StationSim 等)と組み合わせ、最適化結果を実機制御指令に落とすところまでを試せます。
- ISO 15118:EV ↔ 充電器の双方向制御の標準。V2Gを本格運用するならここを押さえる必要があります。
- VPPアグリゲーション:実際の市場参入では、入札制約・ペナルティ・需給調整市場の三次調整力⇄スポット市場の組み合わせなど、規制設計を考慮したMILPに拡張します。
- 電池劣化のRainflow解析:サイクル数だけでなく、SOCレンジごとの応力をRainflowで数えると、より現実的な劣化コストになります。
10. 法令・運用上の注意
- 日本ではV2Gの本格運用には 電気事業法 や 計量法(売電量の計量精度)に関わる議論が必要。
- 充放電器(双方向) は機器認証が必要で、住宅側では低圧連系の手続きが入ります。
- 保険:バッテリー劣化や系統事故時の責任分担は、契約上のキー論点です。
- ユーザー体験:朝に必要なSOCを切らない保証(信頼の担保)が、V2Gサービスの普及最大のレバーです。
- 系統運用者(一般送配電事業者)との 接続検討 は早めに進めるべき項目です。
11. 拡張アイデア
- 太陽光+蓄電池+EV をひとつのMILPで束ねた家庭エネルギー最適化(HEMS)
- 集合住宅向けの 共有駐車場V2G :電気代が「全戸でマイナス」になる物件
- DR(デマンドレスポンス)市場 との連携:当日30分前市場まで対応
- 強化学習で オンライン適応 :価格予測誤差にロバストなポリシー
- オートチャージ&オートビリング をブロックチェーン決済で実装するPoC
まとめ
- V2Gの本質は「電力の方向を双方向にする」ことと、秒単位の最適化 にある
- 本記事のひな形で、価格カーブ取得 → 1台MILP → アグリゲーター → 可視化 → ダッシュボード までを最短ルートで体験できる
- 実運用にはOCPP / ISO 15118 / 電気事業法への対応や、不確実性への強化学習的アプローチが控えるが、設計の初手はノートPCで回せる
- EVが「乗り物」から 資産(キャッシュフローを生むもの) に変わる未来を、エンジニアの腕で前倒しできる時代になっている
まずはサンプル価格カーブで1台MILPを解いて、「夕方放電・深夜充電」のスケジュールが返ってくる感触をつかんでみてください。1兆円規模のエネルギー革命 の入口は、案外シンプルなコードの中にあります。