ホーム・エネルギー・マネジメントシステム¶
ホーム・エネルギー・マネジメント・システム (HEMS) は、家庭のエネルギー源と使用量を監視、制御、最適化するように設計されています。家庭が複数のエネルギー供給源(送電網からの電力、太陽光発電、家庭用蓄電池、電気自動車 (EV)、バックアップ発電機など)を利用できる場合、HEMS では、コストの最小化や環境負荷の低減などを目的とし、どのエネルギー源からどの量のエネルギーを供給するかを最適に決定することが求められます。
HEMS は、種々のエネルギー源の時間変動特性を考慮します。例えば、
- 系統電力:電力のリアルタイム価格 (RTP) (JPY/kWh)
- 家庭用蓄電池:蓄電池容量 (kWh) および最大入出力 (kW)
- 電気自動車:蓄電池容量 (kWh)、最大入力 (kW)、利用開始時刻
- 太陽光発電:天候(日照時間)による供給量の変動
本サンプルプログラムでは、Fixstars Amplify を用いて、上記のエネルギー源からなるエネルギーミックスを最適化します。今回の最適化では、エネルギーコストを最小化することを目的としています。しかし、必要に応じて CO2 排出量や他のエネルギー源を削減するといった目的を追加することも可能です。
問題設定¶
エネルギーミックスの最適化を行うには、以下のような情報を取得・予測する必要があります。
-
需要
- 予測された総需要の時間変化 $\mathbf{d}$ (kW)
-
系統電力
- RTPにおける予測された電力価格の時間的変動 $\mathbf{p}$ (JPY/kWh)
-
家庭用蓄電池
- 蓄電池の容量 $c_{bs}$ (kWh)
- 蓄電池の最大入出力 $s_{bs, max}$ (kW)
-
EV
- EV充電可能時間 $\mathbf{b}$ (
bool
): 時刻 $t$ においてEVが家庭に駐車されているかどうか(充電可能かどうか)。 - EVバッテリーの容量 $c_{be}$ (kWh)
- EVバッテリーの最大充電入力 $s_{be, max}$ (kW)
- EV充電可能時間 $\mathbf{b}$ (
-
太陽光発電
- 予測日照時間(時間)の時間的変動 $\mathbf{h}$
- ソーラーパネルの効率
- 太陽光放射照度(kW/m^2)
- パネル面積(m^2)
2日間 (num_slots
で48の時間スロットに分割) の需要に対してエネルギーミックスを最適化します。
import numpy as np
num_days = 2
num_slots = num_days * 24
# 需要
# 2日間の需要予測 (kW)
demands = np.array([
#[0am, 1am, 2am, 3am, 4am, 5am, 6am, 7am, 8am, 9am, 10am, 11am,
#12pm, 13pm, 14pm, 15pm, 16pm, 17pm, 18pm, 19pm, 20pm, 21pm, 22pm, 23pm]
[1.28, 0.82, 1.96, 0.44, 0.88, 1.82, 5.70, 7.40, 2.64, 2.72, 1.52, 2.10, # Day 1
1.20, 1.26, 4.00, 1.24, 2.36, 5.90, 5.92, 6.82, 8.86, 4.78, 1.08, 1.40],
[1.56, 1.02, 0.40, 1.26, 1.72, 1.72, 4.52, 7.32, 5.66, 1.26, 3.68, 3.20, # Day 2
3.54, 3.96, 1.28, 3.94, 2.98, 8.26, 5.5 , 6.46, 4.76, 6.32, 1.98, 0.58],
]) # fmt: skip
# 系統電力
# 2日間の RTP に基づく電力価格予測 (JPY/kWh)
grid_electricity_price = np.array([
[24.0, 22.4, 22.4, 20.8, 20.8, 22.4, 27.2, 33.6, 40.0, 44.8, 48.0, 51.2, # Day 1
54.4, 57.6, 59.2, 62.4, 64.0, 67.2, 65.6, 60.8, 56.0, 51.2, 46.4, 40.0],
[25.6, 24. , 24. , 22.4, 22.4, 24. , 28.8, 35.2, 41.6, 46.4, 49.6, 52.8, # Day 2
56.0, 59.2, 60.8, 64.0, 65.6, 68.8, 67.2, 62.4, 57.6, 52.8, 48.0, 43.2]
]) # fmt: skip
# 家庭用蓄電池
battery_storage_capacity = 40 # 最大容量 (kWh)
battery_storage_max_input_output = 20 # 最大入出力 (kW)
# EV バッテリー
battery_ev_capacity = 50 # 最大容量 (kWh)
battery_ev_max_input = 50 # 最大充電入力 (kW)
# EV 充電可能な時間帯の予測(時間スロット t に EV が家に駐車してある(充電可能)かどうか).
ev_parked_at_home = np.array([
[True, True, True, True, True, True, True, False, False, False, False, False, # Day 1
False, False, False, False, False, False, True, True, True, True, True, True, ],
[True, True, True, True, True, True, True, False, False, False, False, False, # Day 2
False, False, False, False, False, False, True, True, True, True, True, True, ],
]) # fmt: skip
# 太陽光発電
solar_panel_efficiency = 0.2 # 太陽光パネル効率
solar_irradiance = 0.2 # 太陽放射照度 (kW/m^2)
panel_area = 20 # パネル面積 (m^2)
# 2日間の日照時間予測 (hours)
sunshine_hours = np.array([
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.10, 0.30, 0.50, 0.70, 0.90, # Day 1
1.00, 1.00, 0.90, 0.70, 0.50, 0.30, 0.10, 0.00, 0.00, 0.00, 0.00, 0.00],
[0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.20, 0.40, 0.60, 0.80, 1.00, # Day 2
0.90, 0.80, 0.70, 0.50, 0.30, 0.10, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
]) # hours # fmt: skip
# 太陽光発電による最大供給可能量
solar_power_max_supply = (
solar_panel_efficiency * solar_irradiance * panel_area * sunshine_hours
) # kW
assert len(np.ravel(demands)) == num_slots
assert len(np.ravel(grid_electricity_price)) == num_slots
assert len(np.ravel(ev_parked_at_home)) == num_slots
assert len(np.ravel(sunshine_hours)) == num_slots
関数 plot
を定義し、定義した問題設定をプロットしてみましょう。この関数は、後で得られる解のプロットにも使用されます。
import plotly.graph_objects as go
from plotly.subplots import make_subplots
def plot(inp: dict[str, dict[str, np.ndarray]], show_ev_parked: bool = True):
# 24時間ごとに目安の縦線を描画
def daily_bar(fig: go.Figure) -> None:
for i in range(num_days + 1):
fig.add_vline(x=i * 24, line_dash="dot", line_color="#555555")
def get_ev_usage_period(bools: list | np.ndarray) -> tuple[list, list]:
start: list[float] = []
end: list[float] = []
if bools[0]:
start.append(0)
for i in range(len(bools) - 1):
if not bools[i] and bools[i + 1]:
start.append(i + 0.5)
elif bools[i] and not bools[i + 1]:
end.append(i + 0.5)
if bools[-1]:
end.append(len(bools) - 1)
assert len(start) == len(end), f"length does not match {start=}, {end=}"
return start, end
# EV利用中(充電不可)の時間帯に影を描画
def ev_usage(bools: list | np.ndarray, fig: go.Figure) -> None:
start, end = get_ev_usage_period(bools)
for s, e in zip(start, end):
fig.add_vrect(
x0=s,
x1=e,
fillcolor="black",
opacity=0.1,
annotation_text="EV利用中",
annotation_position="top left",
)
num_plots = len(inp.keys())
fig = make_subplots(rows=num_plots, cols=1)
for i in range(num_plots):
label, data = list(inp.items())[i]
for k, v in data.items():
fig.add_trace(
go.Scatter(
x=list(range(len(v))),
y=v,
mode="lines",
name=f"{k}",
),
row=1 + i,
col=1,
)
fig.update_yaxes(title_text=label, row=1 + i, col=1)
daily_bar(fig)
if show_ev_parked:
ev_usage([not elem for elem in np.ravel(ev_parked_at_home)], fig)
fig.update_xaxes(title_text="hours", row=num_plots, col=1)
fig.show()
# 問題設定をプロット
plot(
{
"需要 (kW)": {"総需要": np.ravel(demands)},
"JPY/kWh": {"系統電力価格": np.ravel(grid_electricity_price)},
"供給 (kW)": {"太陽光発電供給可能量": np.ravel(solar_power_max_supply)},
},
)
定式化¶
決定変数¶
本サンプルプログラムでは以下の決定変数を考慮します。一部の決定変数については、予め値が固定されています。
-
系統電力:
- $\mathbf{q}_{ge}$ (
q_ge
):num_slots
の大きさの整数変数配列。 値は、値域が (0, 30) の系統電力供給 (kW) に相当します。
- $\mathbf{q}_{ge}$ (
-
家庭用蓄電池:
- $\mathbf{q}_{bs}$ (
q_bs
):num_slots
のサイズを持つ整数変数配列。 値は家庭用蓄電池の充電残量 (kWh) に対応します。 最適化期間の開始時と終了時には、バッテリー残量は 50% になる様に決定変数の値を固定します。
- $\mathbf{q}_{bs}$ (
-
EV バッテリー:
- $\mathbf{q}_{be}$ (
q_be
):num_slots
のサイズを持つ整数変数配列。EV バッテリーの残量 (kWh) に対応する値です。 最適化期間の開始時と終了時には、バッテリーは 50% 充電されている必要があります。 また、予定された EV 使用前にバッテリーは完全に充電されている必要があります。
- $\mathbf{q}_{be}$ (
-
太陽光発電:
- $\mathbf{q}_{sp}$ (
q_sp
):num_slots
のサイズを持つバイナリ変数配列。値は太陽光発電の使用の有無に対応します。
- $\mathbf{q}_{sp}$ (
import amplify
gen = amplify.VariableGenerator()
# 系統電力
# q_ge は系統電力供給: 0, 1, .., 30 (kW)
q_ge = gen.array("Integer", shape=num_slots, bounds=(0, 30))
# 家庭用蓄電池
# q_bs は充電残量: 0, 1, 2, ..., battery_storage_capacity (kWh)
q_bs = gen.array("Integer", shape=num_slots, bounds=(0, battery_storage_capacity))
# 境界条件 (最適化期間の開始・終了時に 50% の充電残量)
q_bs[0] = int(0.5 * battery_storage_capacity)
q_bs[-1] = int(0.5 * battery_storage_capacity)
# EV バッテリー
# q_be は充電残量: 0, 1, 2, ..., battery_ev_capacity (kWh)
q_be = gen.array("Integer", shape=num_slots, bounds=(0, battery_ev_capacity))
# pre-assign remaining batery charge to 10% if EV is not at home.
q_be = amplify.PolyArray(
[q_be[i] if np.ravel(ev_parked_at_home)[i] else 0 for i in range(num_slots)]
)
# 境界条件 (最適化期間の開始・終了時に 50% の充電残量)
q_be[0] = int(0.5 * battery_ev_capacity)
q_be[-1] = int(0.5 * battery_ev_capacity)
# EV 利用開始時には満充電されている
for i in range(len(q_be) - 1):
if np.ravel(ev_parked_at_home)[i] and not np.ravel(ev_parked_at_home)[i + 1]:
q_be[i] = battery_ev_capacity
# 太陽光発電
q_sp = gen.array("Binary", shape=num_slots)
供給量¶
各エネルギー源からの時間スロット $t$ における供給量は、以下のように定式化します。
-
系統電力 (kW):
$$s_{ge, t}=q_{ge, t}$$ -
家庭用蓄電池 (kW):
バッテリーの入出力 (kW) は、時間スロット幅が 1 時間に相当することを前提に、$t$ と $t+1$ の間の残量 (kWh) の差分を取ることで算出されます。
$$s_{bs, t} = -(q_{bs, t+1} - q_{bs,t})$$ -
EV バッテリー (kW):
EV バッテリーからの出力は、家庭用蓄電池の場合と同様に求めることができます。 EV が使用中 (ev_parked_at_home
がFalse
) の場合は、家庭用電力に対する寄与がないため、$b_{be, t+1}$ による追加の乗算により、EV バッテリーに関連する入出力をゼロとします。
$$s_{be, t} = -(q_{be, t+1} - q_{be,t})\cdot b_{be, t+1}$$ -
太陽光発電 (kW):
太陽光発電利用するかしないかのバイナリ決定変数 $s_{sp, max, t}$ を乗算することで、出力を得ます。
$$s_{sp, t} = q_{sp, t} \cdot s_{sp, max, t}$$
バッテリー放電時には正の供給量、充電時には負の供給量が得られることに注意してください。
supply_ge = q_ge # kW
supply_bs = -(q_bs.roll(-1) - q_bs) # kW, 時間スロット幅が1時間であることを仮定
supply_be = -(
(q_be.roll(-1) - q_be) * np.roll(np.ravel(ev_parked_at_home), -1)
) # kW, 時間スロット幅が1時間であることを仮定
supply_sp = q_sp * np.ravel(solar_power_max_supply) # kW
制約条件¶
次のような制約条件を考慮します。
-
需要・供給:
任意の時間スロット $t$ における総供給量 (kW) は、総需要量 (kW) 以上でなければなりません。$$d_t \le s_{ge,t} + s_{bs, t} + s_{be, t} + s_{sp, t}$$
右辺は一般に非整数であることに注意してください。したがって、本サンプルプログラムでは、ペナルティ生成アルゴリズムとして
Relaxation
を選択します。詳細はドキュメントを参照してください。 -
家庭用蓄電池:
家庭用蓄電池への入出力には制限があり、$s_{bs, max}$ (kW) 以下である必要があります。$$s_{bs,t}^2 \le s_{bs,max}^2$$
右辺は2次式であることに注意してください。つまり、この不等式制約は Amplify SDK 内部のモデル変換の過程で 4 次式の定式化になる可能性があります。したがって、本サンプルプログラムでは、ペナルティ生成アルゴリズムとして
Relaxation
を選択します。詳細はドキュメントを参照してください。 -
EV バッテリー:
EV バッテリーは EV の走行のみに利用され、家庭への電力供給には寄与しません。したがって、EVバッテリーの家庭への電力供給量は常にゼロまたはマイナス(充電)である必要があります。また、任意の時間スロット $t$ において、充電入力は $s_{be, max} $を超えることは有りません。
constraint_demand = amplify.ConstraintList(
[
amplify.greater_equal(
(supply_ge + supply_bs + supply_be + supply_sp)[t],
np.ravel(demands)[t],
penalty_formulation="Relaxation",
)
for t in range(num_slots)
]
)
constraint_bs = amplify.ConstraintList(
[
amplify.less_equal(
supply_bs[t] * supply_bs[t],
battery_storage_max_input_output * battery_storage_max_input_output,
penalty_formulation="Relaxation",
)
for t in range(num_slots)
]
)
# EV バッテリーは家庭電力需要に対して正の供給は行わない(充電のみ)。
constraint_be = amplify.ConstraintList(
[amplify.clamp(supply_be[t], (-battery_ev_max_input, 0)) for t in range(num_slots)]
)
ソフト制約¶
一般的に、家庭用蓄電池の充電と放電の切り替えは最小限に抑えたいと考えるでしょう。このような要求は通常の制約条件を使用して実装できますが、ここでは「ソフト」制約に基づいて考慮します。ソフト制約は、条件が違反された場合にペナルティが追加される目的関数の一部です。ただし、制約が違反された解であっても、実行可能解と見なされます。最適化ではできるだけペナルティを加算されないような解を得ることができます。
本サンプルプログラムでは、家庭用蓄電池の充電または放電をできるだけ 3 時間程度維持することを目指します。このため、目的関数の一部として以下のペナルティが追加します。
${\rm Minimize}\:f_{soft} = -\sum s_{bs, t} s_{bs,t + 1} -\sum s_{bs, t} s_{bs,t + 2} -\sum s_{bs, t} s_{bs,t + 3}$
このソフト制約により、最適化ではできるだけ $(s_{bs, t}, s_{bs,t + 1})$、$(s_{bs, t}, s_{bs,t + 2})$、および $(s_{bs, t}, s_{bs,t + 3})$ の符号(充電か、放電か)が同じで、より大きな $|s_{bs}|$ を得るような解が期待されます。
soft_constraint = (
-(supply_bs * supply_bs.roll(-1))[:-1].sum() # 1時間
- (supply_bs * supply_bs.roll(-2))[:-2].sum() # 2時間
- (supply_bs * supply_bs.roll(-3))[:-3].sum() # 3時間
)
目的関数¶
最後に、目的関数を構築します。目的関数は 2 つあります(上記のソフト制約も、厳密には目的関数です)。
- 価格
- ${\rm Minimize}\:f_{price} = \sum s_{ge,t} \cdot p_t$
- 需要と供給の差
- ${\rm Minimize} \:f_{diff} = \sum (s_{ge,t} + s_{bs,t} + s_{be,t} + s_{sp,t} - d_t)^2$
objective_price = (supply_ge * np.ravel(grid_electricity_price)).sum()
supply_all = supply_ge + supply_bs + supply_be + supply_sp
objective_diff = (
(np.ravel(demands) - supply_all) * (np.ravel(demands) - supply_all)
).sum()
求解の実行¶
それでは、ハード制約、ソフト制約、目的関数を考慮した最適化問題を解いてみましょう。 考慮すべき項数が比較的多いので、目的関数や制約条件に適切な重み付けを行うのは簡単ではありません。これをより簡単にするために、以下の 2 段階で最適化を実行します。
ステップ 1. スケーリング係数を得るための求解¶
最初のステップでは、ハード制約のみを使用して最適化を行います。目的関数の値 ($f_{diff}$, $f_{price}$ および $f_{soft}$) を求め、それらをスケーリング係数として、ステップ 2 における実際の求解で使用することが目的です。
ここで、すべての目的関数 objective_diff
、objective_price
、soft_constraint
は
amplify.Poly
クラスのインスタンスであり、amplify.Poly
の evaluate
メソッドを使用して、
得られた最善解 result.best.values
に対応する目的値を計算します(詳細は ドキュメント
を参照)。
from datetime import timedelta
def retriable_solve(
model: amplify.Model, client: amplify.FixstarsClient
) -> amplify.Result:
"""ハード制約を全て満たす解が得られない場合は、5回まで求解をリトライする solve 関数"""
for _ in range(5):
result = amplify.solve(model, client)
if len(result) > 0:
break
print("retrying amplify.solve...")
if len(result) == 0:
raise RuntimeError("no feasible solution found")
return result
client = amplify.FixstarsClient()
# client.token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # ローカル環境等で使用する場合は、Fixstars Amplify AE のアクセストークンを入力してください。
# スケーリング係数を求めるための制約問題
client.parameters.timeout = timedelta(milliseconds=5000)
client.parameters.outputs.duplicate = True # 同じ目的関数値の解を複数取得するオプション(目的関数を考慮しないステップ1では、“最適”解が複数個あるため)
model = amplify.Model(constraint_demand + constraint_bs + constraint_be)
result = retriable_solve(model, client)
# 得られた制約を満たす全ての解に対して目的関数値を計算し、その最大値を対応する目的関数のスケーリング係数とします。
num_sols = len(result.solutions)
scaling_diff = np.array(
[objective_diff.evaluate(result.solutions[i].values) for i in range(num_sols)]
).max()
scaling_price = np.array(
[objective_price.evaluate(result.solutions[i].values) for i in range(num_sols)]
).max()
scaling_soft = np.abs(
np.array(
[soft_constraint.evaluate(result.solutions[i].values) for i in range(num_sols)]
)
).max()
print(f"{scaling_diff=:.10e}")
print(f"{scaling_price=:.10e}")
print(f"{scaling_soft=:.10e}")
ステップ 2. 実際の求解¶
ステップ 2 では、まずステップ 1 で得られたスケーリング係数で $f_{diff}$, $f_{price}$ および $f_{soft}$ を除算します。
この操作により、すべての目的関数項は、ハード制約に対するペナルティ値 (=1) に比較的近い値を得ることが期待されます。
目的関数に追加の重みを上乗せすることも可能なので、優先する目的関数を制御することができます。以下では、$f_{diff}$、$f_{price}$、$f_{soft}$ にそれぞれ
1000
、50
、0.1
の重みをスケーリング後に考慮しています。
# 全目的関数及び制約条件に基づく最適化問題
client.parameters.timeout = timedelta(milliseconds=10000)
model = amplify.Model(
1000 * objective_diff / scaling_diff
+ 50 * objective_price / scaling_price
+ 0.1 * soft_constraint / scaling_soft,
constraint_demand + constraint_bs + constraint_be,
)
result = retriable_solve(model, client)
print(f"objective_diff: {objective_diff.evaluate(result.best.values):.2e}")
print(f"objective_price: {objective_price.evaluate(result.best.values):.2e}")
print(f"soft_constraint: {soft_constraint.evaluate(result.best.values):.2e}")
求解結果の可視化¶
先に定義した関数 plot
を使用して、最適化されたエネルギーミックスをプロットしてみましょう。
plot(
{
"需要・供給 (kW)": {
"総需要": np.ravel(demands),
"総供給": (supply_ge + supply_bs + supply_be + supply_sp).evaluate(
result.best.values
),
"系統電力": supply_ge.evaluate(result.best.values),
"家庭用蓄電池": supply_bs.evaluate(result.best.values),
"EVバッテリー": supply_be.evaluate(result.best.values),
"太陽光発電": supply_sp.evaluate(result.best.values),
},
"充電残量 (kWh)": {
"家庭用蓄電池": q_bs.evaluate(result.best.values),
"EVバッテリー": q_be.evaluate(result.best.values),
},
},
)