naohiro701 commited on
Commit
018bd0f
·
verified ·
1 Parent(s): 7ed09e6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +359 -424
app.py CHANGED
@@ -1,477 +1,412 @@
 
 
1
  import streamlit as st
2
  import pandas as pd
3
  import numpy as np
4
  import json
 
5
  import plotly.express as px
6
  import plotly.graph_objs as go
7
- import pulp
8
 
9
  # -----------------------------
10
- # Data I/O
11
  # -----------------------------
12
  def load_timeseries():
13
  """
14
- Load hourly capacity-factor and demand CF from data.json.
15
- Returns a tidy DataFrame indexed by 'Time' with columns:
16
- - solar hourly capacity factor
17
- - onshore_wind hourly capacity factor
18
- - offshore_wind hourly capacity factor
19
- - river hourly capacity factor
20
- - demand hourly capacity factor
 
21
  """
22
- with open("data.json", "r") as f:
23
- data = json.load(f)
24
- if not data:
25
- raise ValueError("data.json is empty")
26
-
27
- base_times = data[next(iter(data))]["x"]
28
- df = pd.DataFrame({"Time": pd.to_datetime(base_times, errors="coerce")})
29
- for k, v in data.items():
30
- if "y" in v:
31
- df[f"{k} hourly capacity factor"] = pd.to_numeric(v["y"], errors="coerce")
32
- # sanitize
33
- for c in df.columns:
34
- if c != "Time":
35
- df[c] = df[c].fillna(0.0).clip(lower=0.0)
36
- return df.set_index("Time")
37
-
38
 
39
  # -----------------------------
40
- # Parameter helpers (units + conversions)
41
  # -----------------------------
42
- def compute_var_costs_jpy_per_mwh(
43
- usd_jpy_rate: float,
44
- lng_usd_per_mmbtu: float,
45
- coal_usd_per_ton: float,
46
- oil_usd_per_bbl: float,
47
- hr_gas_ccgt_gj_per_mwh: float,
48
- hr_coal_gj_per_mwh: float,
49
- hr_oil_gj_per_mwh: float,
50
- hr_nuclear_gj_per_mwh: float,
51
- coal_energy_gj_per_ton: float = 25.12, # ~6000 kcal/kg -> 25.12 GJ/t
52
- ):
53
  """
54
- Compute short-run variable cost [JPY/MWh] for thermal and nuclear from fuel prices + heat rates.
55
- All variables are local by design.
56
-
57
- Notes:
58
- - LNG price input is in USD/MMBtu (Japan CIF), convert via 1 MMBtu = 1.055056 GJ.
59
- - Coal price input is in USD/ton; convert using energy content [GJ/ton].
60
- - Oil price input is in USD/bbl; convert using 1 bbl ≈ 6.12 GJ (typical crude reference).
61
- - Nuclear: we do NOT price via hr*fuel_price (uranium path is different). Set a practical fuel+cycle variable cost baseline via UI (overrides), but we keep a fallback via hr*proxy if provided.
62
  """
63
  mmbtu_to_gj = 1.055056
64
  bbl_to_gj = 6.12
 
65
 
66
- # $/GJ
67
  gas_usd_per_gj = lng_usd_per_mmbtu / mmbtu_to_gj
68
- coal_usd_per_gj = coal_usd_per_ton / coal_energy_gj_per_ton
69
  oil_usd_per_gj = oil_usd_per_bbl / bbl_to_gj
70
 
71
- # $/MWh
72
- gas_usd_per_mwh = gas_usd_per_gj * hr_gas_ccgt_gj_per_mwh
73
- coal_usd_per_mwh = coal_usd_per_gj * hr_coal_gj_per_mwh
74
- oil_usd_per_mwh = oil_usd_per_gj * hr_oil_gj_per_mwh
75
 
76
- # Convert to JPY
77
- gas_jpy_per_mwh = gas_usd_per_mwh * usd_jpy_rate
78
- coal_jpy_per_mwh = coal_usd_per_mwh * usd_jpy_rate
79
- oil_jpy_per_mwh = oil_usd_per_mwh * usd_jpy_rate
80
 
81
- # Nuclear variable cost baseline (JPY/MWh) left to UI; here return zero placeholder (overridden later)
82
- nuclear_jpy_per_mwh = 0.0
83
-
84
- return {
85
- "lng_ccgt": gas_jpy_per_mwh,
86
- "coal": coal_jpy_per_mwh,
87
- "oil": oil_jpy_per_mwh,
88
- "nuclear": nuclear_jpy_per_mwh,
89
- }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  # -----------------------------
93
- # Core optimization
94
  # -----------------------------
95
- def optimize_capacity_and_dispatch(
96
- timeseries_df: pd.DataFrame,
97
- regions: list,
98
- region_load_shares: dict,
99
- yearly_demand_twh: float,
100
- # renewable CAPEX [JPY/MW]
101
- solar_capex_jpy_per_mw: float,
102
- onshore_capex_jpy_per_mw: float,
103
- offshore_capex_jpy_per_mw: float,
104
- river_capex_jpy_per_mw: float,
105
- # renewable capacity bounds per region [MW] (min,max)
106
- solar_bounds_mw: tuple,
107
- onshore_bounds_mw: tuple,
108
- offshore_bounds_mw: tuple,
109
- river_bounds_mw: tuple,
110
- # battery: cost per MWh energy (JPY/MWh), efficiency (0-1)
111
- battery_cost_per_mwh: float,
112
- battery_eff: float,
113
- # thermal/nuclear: max capacities by region [MW] (user-editor table)
114
- thermal_caps_mw: dict, # {(region, tech): MW}
115
- # variable costs [JPY/MWh]
116
- var_costs_jpy_per_mwh: dict, # keys: lng_ccgt, coal, oil, nuclear
117
- # nuclear variable override (JPY/MWh)
118
- nuclear_varcost_override: float,
119
- # solver msg
120
- solver_msg: bool = False,
121
- ):
122
  """
123
- Two-step solve:
124
- Step-1 (capacity expansion): choose renewable & battery capacities (MW / MWh) to minimize total (CAPEX + variable costs) with thermal/nuclear capacities fixed (upper-bounded).
125
- Step-2 (dispatch only, capacities fixed): compute LMPs from duals of each region-time balance constraint (short-run pricing).
126
-
127
- Returns figures and key outputs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  """
129
- # Prepare demand (national hourly energy -> split by shares)
130
- demand_cf = timeseries_df["demand hourly capacity factor"].values # 0-1
131
- nT = len(demand_cf)
132
-
133
- # Compute national hourly demand in MW: scale yearly_demand_twh into hourly series
134
- # Sum CF over hours -> annualized fraction of peak; we normalize such that sum(hourly_demand) = yearly_demand_twh * 1e6 MWh
135
- # Let base_peak be 1 MW and scale to meet annual energy:
136
- base_profile = demand_cf / demand_cf.max() # normalized by peak=1
137
- energy_norm = base_profile.sum() # sum over hours with peak=1
138
- # scale so that total energy equals yearly_demand_twh [TWh]
139
- total_energy_mwh = yearly_demand_twh * 1e6
140
- peak_mw_national = total_energy_mwh / energy_norm
141
- national_demand_mw = base_profile * peak_mw_national
142
-
143
- # Region-wise demand (MW)
144
- region_demand = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  for r in regions:
146
- region_demand[r] = national_demand_mw * float(region_load_shares[r])
147
-
148
- # Renewable CFs
149
- solar_cf = timeseries_df["solar hourly capacity factor"].values
150
- onshore_cf = timeseries_df["onshore_wind hourly capacity factor"].values
151
- offshore_cf = timeseries_df["offshore_wind hourly capacity factor"].values
152
- river_cf = timeseries_df["river hourly capacity factor"].values
153
-
154
- # Technologies
155
- ren_techs = ["solar", "onshore_wind", "offshore_wind", "river"]
156
- therm_techs = ["lng_ccgt", "oil", "coal", "nuclear"]
157
-
158
- capex = {
159
- "solar": solar_capex_jpy_per_mw,
160
- "onshore_wind": onshore_capex_jpy_per_mw,
161
- "offshore_wind": offshore_capex_jpy_per_mw,
162
- "river": river_capex_jpy_per_mw,
163
- }
164
- cf_map = {
165
- "solar": solar_cf,
166
- "onshore_wind": onshore_cf,
167
- "offshore_wind": offshore_cf,
168
- "river": river_cf,
169
- }
170
- ren_bounds = {
171
- "solar": solar_bounds_mw,
172
- "onshore_wind": onshore_bounds_mw,
173
- "offshore_wind": offshore_bounds_mw,
174
- "river": river_bounds_mw,
175
- }
176
- varcost = var_costs_jpy_per_mwh.copy()
177
- if nuclear_varcost_override is not None and nuclear_varcost_override > 0:
178
- varcost["nuclear"] = nuclear_varcost_override
179
-
180
- # -----------------
181
- # Step-1: capacity + dispatch (coarse) to size renewables and battery
182
- # -----------------
183
- mdl1 = pulp.LpProblem("Japan_Capacity_Expansion", pulp.LpMinimize)
184
-
185
- # Decision variables
186
- ren_cap = pulp.LpVariable.dicts(
187
- "ren_cap", ((r, g) for r in regions for g in ren_techs), lowBound=0, cat="Continuous"
188
- )
189
- # Battery per region (energy capacity MWh, charge/discharge power unconstrained except by energy window)
190
- batt_energy_cap = pulp.LpVariable.dicts("batt_e_cap", (r for r in regions), lowBound=0, cat="Continuous")
191
- ch = pulp.LpVariable.dicts("ch", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
192
- dis = pulp.LpVariable.dicts("dis", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
193
- soc = pulp.LpVariable.dicts("soc", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
194
-
195
- # Thermal/nuclear dispatch variables (capacity fixed via thermal_caps_mw)
196
- gen = pulp.LpVariable.dicts(
197
- "gen", ((r, g, t) for r in regions for g in therm_techs for t in range(nT)), lowBound=0, cat="Continuous"
198
- )
199
-
200
- # Curtailment
201
- cur = pulp.LpVariable.dicts("cur", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
202
-
203
- # Objective: CAPEX(renewables + battery energy) + variable costs(thermal/nuclear)
204
- mdl1 += (
205
- pulp.lpSum(ren_cap[(r, g)] * capex[g] for r in regions for g in ren_techs)
206
- + pulp.lpSum(batt_energy_cap[r] * battery_cost_per_mwh for r in regions)
207
- + pulp.lpSum(gen[(r, g, t)] * varcost[g] for r in regions for g in therm_techs for t in range(nT))
208
- )
209
 
210
  # Constraints
211
- for r in regions:
212
- # Renewable bounds per region (uniform min/max applied to each region)
213
- for g in ren_techs:
214
- lo, hi = ren_bounds[g]
215
- mdl1 += ren_cap[(r, g)] >= lo
216
- mdl1 += ren_cap[(r, g)] <= hi
217
-
218
- # Battery SoC and energy window
219
- for t in range(nT):
220
- ren_supply = (
221
- ren_cap[(r, "solar")] * cf_map["solar"][t]
222
- + ren_cap[(r, "onshore_wind")] * cf_map["onshore_wind"][t]
223
- + ren_cap[(r, "offshore_wind")] * cf_map["offshore_wind"][t]
224
- + ren_cap[(r, "river")] * cf_map["river"][t]
225
- )
226
-
227
- # Power balance with curtailment and storage
228
- if t == 0:
229
- mdl1 += (
230
- ren_supply
231
- + pulp.lpSum(gen[(r, g, t)] for g in therm_techs)
232
- + dis[(r, t)]
233
- == region_demand[r][t] + ch[(r, t)] + cur[(r, t)]
234
- )
235
- mdl1 += soc[(r, t)] == ch[(r, t)] * battery_eff - dis[(r, t)] / battery_eff
236
  else:
237
- mdl1 += (
238
- ren_supply
239
- + pulp.lpSum(gen[(r, g, t)] for g in therm_techs)
240
- + dis[(r, t)]
241
- == region_demand[r][t] + ch[(r, t)] + cur[(r, t)]
242
- )
243
- mdl1 += soc[(r, t)] == soc[(r, t - 1)] + ch[(r, t)] * battery_eff - dis[(r, t)] / battery_eff
244
-
245
- # Battery energy limit
246
- mdl1 += soc[(r, t)] <= batt_energy_cap[r]
247
-
248
- # Thermal capacity limits
249
- for g in therm_techs:
250
- mdl1 += gen[(r, g, t)] <= thermal_caps_mw[(r, g)]
251
-
252
- # Solve step-1
253
- solver = pulp.PULP_CBC_CMD(msg=solver_msg)
254
- mdl1.solve(solver)
255
-
256
- # Fix capacities for step-2 (short-run dispatch → LMP)
257
- ren_cap_star = {(r, g): ren_cap[(r, g)].value() for r in regions for g in ren_techs}
258
- batt_e_star = {r: batt_energy_cap[r].value() for r in regions}
259
-
260
- # -----------------
261
- # Step-2: dispatch only with capacities fixed (dual → LMP)
262
- # -----------------
263
- mdl2 = pulp.LpProblem("Japan_Dispatch_LMP", pulp.LpMinimize)
264
-
265
- ch2 = pulp.LpVariable.dicts("ch2", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
266
- dis2 = pulp.LpVariable.dicts("dis2", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
267
- soc2 = pulp.LpVariable.dicts("soc2", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
268
- gen2 = pulp.LpVariable.dicts(
269
- "gen2", ((r, g, t) for r in regions for g in therm_techs for t in range(nT)), lowBound=0, cat="Continuous"
270
- )
271
- cur2 = pulp.LpVariable.dicts("cur2", ((r, t) for r in regions for t in range(nT)), lowBound=0, cat="Continuous")
272
-
273
- # Objective: variable costs only
274
- mdl2 += pulp.lpSum(gen2[(r, g, t)] * varcost[g] for r in regions for g in therm_techs for t in range(nT))
275
-
276
- # Constraints with named balance for duals
277
- for r in regions:
278
- for t in range(nT):
279
- ren_supply = (
280
- ren_cap_star[(r, "solar")] * cf_map["solar"][t]
281
- + ren_cap_star[(r, "onshore_wind")] * cf_map["onshore_wind"][t]
282
- + ren_cap_star[(r, "offshore_wind")] * cf_map["offshore_wind"][t]
283
- + ren_cap_star[(r, "river")] * cf_map["river"][t]
284
- )
285
- cname = f"Balance_{r}_{t}"
286
- mdl2 += (
287
- ren_supply + pulp.lpSum(gen2[(r, g, t)] for g in therm_techs) + dis2[(r, t)]
288
- == region_demand[r][t] + ch2[(r, t)] + cur2[(r, t)]
289
- ), cname
290
-
291
- if t == 0:
292
- mdl2 += soc2[(r, t)] == ch2[(r, t)] * battery_eff - dis2[(r, t)] / battery_eff
293
- else:
294
- mdl2 += soc2[(r, t)] == soc2[(r, t - 1)] + ch2[(r, t)] * battery_eff - dis2[(r, t)] / battery_eff
295
-
296
- mdl2 += soc2[(r, t)] <= batt_e_star[r]
297
- for g in therm_techs:
298
- mdl2 += gen2[(r, g, t)] <= thermal_caps_mw[(r, g)]
299
-
300
- mdl2.solve(solver)
301
-
302
- # Extract LMPs from duals (JPY/MWh)
303
- lmp = {r: np.zeros(nT) for r in regions}
304
- for r in regions:
305
- for t in range(nT):
306
- cname = f"Balance_{r}_{t}"
307
- lmp[r][t] = mdl2.constraints[cname].pi if cname in mdl2.constraints else np.nan
308
-
309
- # Collect plots
310
- # 1) LMP per area
311
- lmp_df = pd.DataFrame(
312
- {"Time": timeseries_df.index}
313
- | {f"LMP {r} [JPY/MWh]": lmp[r] for r in regions}
314
- )
315
- fig_lmp = px.line(lmp_df, x="Time", y=lmp_df.columns[1:], title="Area LMP (JPY/MWh)")
316
-
317
- # 2) Example dispatch stack (national sum for visualization)
318
- # Sum generation across regions and techs at each hour (from mdl2)
319
- gen_stack = {g: np.zeros(nT) for g in therm_techs}
320
- ren_stack = {g: np.zeros(nT) for g in ren_techs}
321
- for r in regions:
322
- for t in range(nT):
323
- ren_stack["solar"][t] += ren_cap_star[(r, "solar")] * cf_map["solar"][t]
324
- ren_stack["onshore_wind"][t] += ren_cap_star[(r, "onshore_wind")] * cf_map["onshore_wind"][t]
325
- ren_stack["offshore_wind"][t] += ren_cap_star[(r, "offshore_wind")] * cf_map["offshore_wind"][t]
326
- ren_stack["river"][t] += ren_cap_star[(r, "river")] * cf_map["river"][t]
327
- for g in therm_techs:
328
- gen_stack[g][t] += gen2[(r, g, t)].value()
329
-
330
- disp_df = pd.DataFrame({"Time": timeseries_df.index})
331
- for g in ["solar", "onshore_wind", "offshore_wind", "river"]:
332
- disp_df[g] = ren_stack[g]
333
- for g in therm_techs:
334
- disp_df[g] = gen_stack[g]
335
- disp_df["demand_total"] = sum(region_demand[r] for r in regions)
336
-
337
- fig_stack = go.Figure()
338
- for col in ["solar", "onshore_wind", "offshore_wind", "river", "lng_ccgt", "coal", "oil", "nuclear"]:
339
- fig_stack.add_trace(go.Scatter(x=disp_df["Time"], y=disp_df[col], mode="lines", stackgroup="one", name=col))
340
- fig_stack.add_trace(go.Scatter(x=disp_df["Time"], y=disp_df["demand_total"], mode="lines", name="demand", line=dict(width=1)))
341
-
342
- fig_stack.update_layout(title="National Dispatch Stack (sum of 10 areas)", yaxis_title="MW")
343
-
344
- # 3) Capacity results
345
- cap_tbl = []
346
- for r in regions:
347
- for g in ren_techs:
348
- cap_tbl.append({"region": r, "tech": g, "optimal_capacity_MW": ren_cap_star[(r, g)]})
349
- cap_tbl.append({"region": r, "tech": "battery_energy", "optimal_capacity_MWh": batt_e_star[r]})
350
- cap_df = pd.DataFrame(cap_tbl)
351
-
352
- return fig_lmp, fig_stack, cap_df, lmp_df
353
-
354
 
355
  # -----------------------------
356
  # Streamlit UI
357
  # -----------------------------
358
- st.set_page_config(page_title="Japan LMP Tool (10 areas)", layout="wide")
359
- st.title("日本版 LMP ツール(10エリア・双対価格)")
360
 
361
- st.markdown(
362
- "短期限界費用(燃料費)に基づく dispatch で各エリアの LMP(需給制約の双対変数)を算出します."
363
- )
364
 
365
- # Load data
366
  ts_df = load_timeseries()
367
 
368
- # Regions (10 T&D areas)
369
- regions = ["Hokkaido","Tohoku","Tokyo","Chubu","Hokuriku","Kansai","Chugoku","Shikoku","Kyushu","Okinawa"]
370
-
371
  with st.sidebar:
372
- st.header("需要・配分")
373
- y_demand = st.number_input("年間需要(TWh/年,国全体)", value=900.0, step=10.0, min_value=10.0)
 
 
 
374
 
375
- st.caption("エリア需要の配分(合計=1.0)")
376
  default_share = pd.DataFrame({
377
- "region": regions,
378
- "share": [0.03,0.09,0.32,0.14,0.03,0.17,0.07,0.03,0.11,0.01] # 実務上の目安(編集可)
379
  })
380
  share_df = st.data_editor(default_share, num_rows="fixed", use_container_width=True)
381
- share_sum = float(share_df["share"].sum())
382
- if abs(share_sum - 1.0) > 1e-6:
383
- st.warning(f"合計が {share_sum:.3f} です.1.0 に自動正規化します.")
384
- region_shares = {row["region"]: float(row["share"]) / share_sum for _, row in share_df.iterrows()}
385
 
386
- st.header("燃料価格・為替(編集可)")
387
  usd_jpy = st.number_input("USD/JPY", value=148.21, step=0.5)
388
- lng_price = st.number_input("LNG 価格(USD/MMBtu,CIF Japan)", value=11.27, step=0.1)
389
- coal_price = st.number_input("石炭 価格(USD/ton,FOB/CIFの代表値)", value=130.0, step=1.0)
390
- oil_price = st.number_input("原油(JCC想定)価格(USD/bbl", value=80.0, step=1.0)
391
-
392
- st.header("熱率(GJ/MWh)")
393
- hr_gas = st.number_input("LNG-CCGT", value=6.6, step=0.1)
394
- hr_coal = st.number_input("石炭(USC等)", value=8.3, step=0.1)
395
- hr_oil = st.number_input("石油(効率39%想定→約9.23)", value=9.23, step=0.1)
396
- hr_nuc = st.number_input("原子力(ダミー,UI用)", value=10.3, step=0.1)
397
-
398
- vc_base = compute_var_costs_jpy_per_mwh(
399
- usd_jpy_rate=usd_jpy,
400
- lng_usd_per_mmbtu=lng_price,
401
- coal_usd_per_ton=coal_price,
402
- oil_usd_per_bbl=oil_price,
403
- hr_gas_ccgt_gj_per_mwh=hr_gas,
404
- hr_coal_gj_per_mwh=hr_coal,
405
- hr_oil_gj_per_mwh=hr_oil,
406
- hr_nuclear_gj_per_mwh=hr_nuc,
407
- )
408
- st.caption("短期限界費用(計算結果,JPY/MWh)")
409
- st.write({k: round(v, 1) for k, v in vc_base.items()})
410
-
411
- nuc_var = st.number_input("原子力の可変費(燃料+サイクル, JPY/MWh)", value=2300.0, step=100.0)
412
-
413
- st.header("CAPEX(JPY/MW)")
414
- solar_capex = st.number_input("太陽光", value=102000.0, step=1000.0)
415
- onshore_capex = st.number_input("陸上風力", value=222000.0, step=1000.0)
416
- offshore_capex = st.number_input("洋上風力(着床)", value=348000.0, step=1000.0)
417
- river_capex = st.number_input("流れ込み水力", value=620000.0, step=5000.0)
418
-
419
- st.header("バッテリー")
420
- batt_cost = st.number_input("コスト(JPY/MWh,エネルギー容量)", value=6_250_000.0, step=50_000.0)
421
- batt_eta = st.slider("往復効率", min_value=0.70, max_value=0.98, value=0.90, step=0.01)
422
-
423
- st.header("再エネ容量下限・上限(各エリア同一)")
424
- solar_bounds = st.slider("太陽光 [MW]", 0, 50000, (0, 50000))
425
- onshore_bounds = st.slider("陸上風力 [MW]", 0, 50000, (0, 50000))
426
- offshore_bounds = st.slider("洋上風力 [MW]", 0, 50000, (0, 50000))
427
- river_bounds = st.slider("流れ込み水力 [MW]", 0, 50000, (0, 50000))
428
-
429
- st.header("火力・原子力の上限制約(各エリア)")
430
- therm_df_default = pd.DataFrame({
431
- "region": regions,
432
- "lng_ccgt_MW": [3000, 9000, 25000, 9000, 2500, 18000, 6000, 2500, 9000, 1000],
433
- "oil_MW": [1000, 2000, 6000, 2000, 800, 3000, 1500, 800, 1500, 300],
434
- "coal_MW": [1500, 4000, 7000, 4000, 1500, 7000, 3000, 1000, 5000, 200],
435
- "nuclear_MW": [ 0 , 2600, 6500, 3700, 1740, 4700, 820, 890, 3570, 0 ],
436
  })
437
- therm_df = st.data_editor(therm_df_default, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
- # Build thermal capacity dict
440
- thermal_caps = {}
441
- for _, row in therm_df.iterrows():
442
  r = row["region"]
443
- thermal_caps[(r, "lng_ccgt")] = float(row["lng_ccgt_MW"])
444
- thermal_caps[(r, "oil")] = float(row["oil_MW"])
445
- thermal_caps[(r, "coal")] = float(row["coal_MW"])
446
- thermal_caps[(r, "nuclear")] = float(row["nuclear_MW"])
447
-
448
- if st.button("最適化&LMP計算"):
449
- fig_lmp, fig_stack, cap_df, lmp_df = optimize_capacity_and_dispatch(
450
- timeseries_df=ts_df,
451
- regions=regions,
452
- region_load_shares=region_shares,
453
- yearly_demand_twh=y_demand,
454
- solar_capex_jpy_per_mw=solar_capex,
455
- onshore_capex_jpy_per_mw=onshore_capex,
456
- offshore_capex_jpy_per_mw=offshore_capex,
457
- river_capex_jpy_per_mw=river_capex,
458
- solar_bounds_mw=solar_bounds,
459
- onshore_bounds_mw=onshore_bounds,
460
- offshore_bounds_mw=offshore_bounds,
461
- river_bounds_mw=river_bounds,
462
- battery_cost_per_mwh=batt_cost,
463
- battery_eff=batt_eta,
464
- thermal_caps_mw=thermal_caps,
465
- var_costs_jpy_per_mwh=vc_base,
466
- nuclear_varcost_override=nuc_var,
467
- solver_msg=False,
468
- )
469
-
470
- st.subheader("LMP(10エリア)")
471
- st.plotly_chart(fig_lmp, use_container_width=True)
472
- st.subheader("全国ディスパッチ(合算スタック)")
473
- st.plotly_chart(fig_stack, use_container_width=True)
474
- st.subheader("最適化された容量(再エネ・蓄電)")
475
- st.dataframe(cap_df)
476
- st.subheader("LMP 出力(テーブル)")
477
- st.dataframe(lmp_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app_market_sim.py
2
+ # -*- coding: utf-8 -*-
3
  import streamlit as st
4
  import pandas as pd
5
  import numpy as np
6
  import json
7
+ import pulp
8
  import plotly.express as px
9
  import plotly.graph_objs as go
10
+ from io import StringIO
11
 
12
  # -----------------------------
13
+ # Utility: load time-series
14
  # -----------------------------
15
  def load_timeseries():
16
  """
17
+ Load data.json with keys:
18
+ { "<series>": {"x": [...timestamps...], "y": [...values...] }, ... }
19
+ Required series (national, used for all areas):
20
+ - solar hourly capacity factor
21
+ - onshore_wind hourly capacity factor
22
+ - offshore_wind hourly capacity factor
23
+ - river hourly capacity factor
24
+ - demand hourly capacity factor
25
  """
26
+ with open("data.json", "r", encoding="utf-8") as f_local:
27
+ data_local = json.load(f_local)
28
+ if not data_local:
29
+ raise RuntimeError("data.json is empty")
30
+
31
+ times_local = pd.to_datetime(data_local[next(iter(data_local))]["x"])
32
+ df_local = pd.DataFrame({"Time": times_local})
33
+ for k_local, v_local in data_local.items():
34
+ if isinstance(v_local, dict) and "y" in v_local:
35
+ df_local[f"{k_local} hourly capacity factor"] = pd.to_numeric(v_local["y"], errors="coerce").fillna(0.0)
36
+ return df_local.set_index("Time")
 
 
 
 
 
37
 
38
  # -----------------------------
39
+ # Variable cost calculator
40
  # -----------------------------
41
+ def var_costs_jpy_per_mwh(usd_jpy, lng_usd_per_mmbtu, coal_usd_per_ton, oil_usd_per_bbl,
42
+ hr_gas_gj_per_mwh, hr_coal_gj_per_mwh, hr_oil_gj_per_mwh,
43
+ nuc_varcost_override_jpy_per_mwh):
 
 
 
 
 
 
 
 
44
  """
45
+ Compute JPY/MWh variable costs for thermal fuels.
46
+ 1 MMBtu = 1.055056 GJ, 1 bbl ≈ 6.12 GJ, coal energy ≈ 25.12 GJ/t
 
 
 
 
 
 
47
  """
48
  mmbtu_to_gj = 1.055056
49
  bbl_to_gj = 6.12
50
+ coal_gj_per_ton = 25.12
51
 
 
52
  gas_usd_per_gj = lng_usd_per_mmbtu / mmbtu_to_gj
53
+ coal_usd_per_gj = coal_usd_per_ton / coal_gj_per_ton
54
  oil_usd_per_gj = oil_usd_per_bbl / bbl_to_gj
55
 
56
+ gas_jpy_mwh = gas_usd_per_gj * hr_gas_gj_per_mwh * usd_jpy
57
+ coal_jpy_mwh = coal_usd_per_gj * hr_coal_gj_per_mwh * usd_jpy
58
+ oil_jpy_mwh = oil_usd_per_gj * hr_oil_gj_per_mwh * usd_jpy
59
+ nuc_jpy_mwh = nuc_varcost_override_jpy_per_mwh
60
 
61
+ return {"lng": gas_jpy_mwh, "coal": coal_jpy_mwh, "oil": oil_jpy_mwh, "nuclear": nuc_jpy_mwh}
 
 
 
62
 
63
+ # -----------------------------
64
+ # Random fleet generator
65
+ # -----------------------------
66
+ def generate_fleet(regions, rng, counts_cfg, caps_cfg, hr_cfg, min_output_cfg, ren_unit_caps):
67
+ """
68
+ Generate a unit-level fleet with realistic ranges.
 
 
 
69
 
70
+ Returns
71
+ -------
72
+ pandas.DataFrame columns:
73
+ ['unit_id','region','tech','fuel','cap_MW','hr_GJ_per_MWh','min_frac','is_renew','cf_key']
74
+ """
75
+ rows_local = []
76
+ uid_local = 0
77
+
78
+ # Thermal & Nuclear
79
+ for r_local in regions:
80
+ for fuel_local in ["lng", "coal", "oil", "nuclear"]:
81
+ n_units = counts_cfg[(r_local, fuel_local)]
82
+ cap_min, cap_max = caps_cfg[fuel_local]
83
+ hr_min, hr_max = hr_cfg[fuel_local]
84
+ min_frac_rng = min_output_cfg[fuel_local]
85
+ for _ in range(n_units):
86
+ cap = rng.uniform(cap_min, cap_max)
87
+ hr = rng.uniform(hr_min, hr_max) if fuel_local != "nuclear" else np.nan
88
+ min_frac = rng.uniform(min_frac_rng[0], min_frac_rng[1])
89
+ rows_local.append({
90
+ "unit_id": f"U{uid_local}",
91
+ "region": r_local,
92
+ "tech": fuel_local,
93
+ "fuel": fuel_local,
94
+ "cap_MW": float(cap),
95
+ "hr_GJ_per_MWh": (float(hr) if fuel_local != "nuclear" else np.nan),
96
+ "min_frac": float(min_frac),
97
+ "is_renew": False,
98
+ "cf_key": None
99
+ })
100
+ uid_local += 1
101
+
102
+ # Renewables (as unit blocks with zero var cost, output <= CF*cap)
103
+ for r_local in regions:
104
+ for ren_key, cf_key in [("solar","solar"), ("onshore_wind","onshore_wind"),
105
+ ("offshore_wind","offshore_wind"), ("river","river")]:
106
+ n_units = counts_cfg[(r_local, ren_key)]
107
+ cap_min, cap_max = ren_unit_caps[ren_key]
108
+ for _ in range(n_units):
109
+ cap = rng.uniform(cap_min, cap_max)
110
+ rows_local.append({
111
+ "unit_id": f"U{uid_local}",
112
+ "region": r_local,
113
+ "tech": ren_key,
114
+ "fuel": None,
115
+ "cap_MW": float(cap),
116
+ "hr_GJ_per_MWh": np.nan,
117
+ "min_frac": 0.0,
118
+ "is_renew": True,
119
+ "cf_key": f"{cf_key} hourly capacity factor"
120
+ })
121
+ uid_local += 1
122
+
123
+ df_units = pd.DataFrame(rows_local)
124
+ return df_units
125
 
126
  # -----------------------------
127
+ # Simultaneous market clearing (energy + reserve)
128
  # -----------------------------
129
+ def clear_market(df_units, ts_df_slice, regions, demand_shares, voll_jpy_per_mwh,
130
+ reserve_ratio, varcost_map, battery_eff=0.9):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  """
132
+ Co-optimize energy and upward reserve simultaneously for each region and hour.
133
+
134
+ Decision variables:
135
+ g[u,t] Generation (MW)
136
+ r[u,t] Upward reserve (MW), eligible only for non-renew units
137
+ shed[r,t] Load shedding (MW), penalized by VOLL
138
+ Constraints:
139
+ - Energy balance (per region, per hour): sum(g) + shed == demand
140
+ - Reserve requirement: sum(r) >= reserve_ratio * demand
141
+ - Unit limits: min_frac*cap <= g <= cap (thermal/nuclear), g <= CF*cap (renew)
142
+ 0 <= r <= cap - g (renew not eligible for r)
143
+ Objective:
144
+ Minimize sum(c_u * g + VOLL * shed)
145
+
146
+ Returns
147
+ -------
148
+ dict with:
149
+ lmp_df: DataFrame [Time x regions] LMP (JPY/MWh)
150
+ res_price_df: DataFrame [Time x regions] Reserve price (JPY/MW)
151
+ dispatch_df: long table with g[u,t]
152
  """
153
+ times = ts_df_slice.index
154
+ T = len(times)
155
+
156
+ # Build regional demand
157
+ base_cf = ts_df_slice["demand hourly capacity factor"].values
158
+ # Normalize to peak=1 and scale so sum(base)=sum(base) => here we just use proportional shares
159
+ base_profile = base_cf / base_cf.max()
160
+ # Set national energy (MWh) equal to sum(base_profile) [MW] so demand is in MW consistently
161
+ # We scale to an arbitrary national peak of 1 MW-equivalent then allocate shares; absolute levels cancel in prices if varcost scale holds.
162
+ national_demand = base_profile # MW proxy
163
+ demand = {r: national_demand * demand_shares[r] for r in regions}
164
+
165
+ # Build model
166
+ mdl = pulp.LpProblem("CoOptim_Energy_Reserve", pulp.LpMinimize)
167
+
168
+ # Index maps
169
+ units_by_region = {r: df_units[df_units["region"] == r].index.tolist() for r in regions}
170
+
171
+ # Variables
172
+ g = pulp.LpVariable.dicts("g", ((u, t) for u in df_units.index for t in range(T)), lowBound=0, cat="Continuous")
173
+ r_up = pulp.LpVariable.dicts("r", ((u, t) for u in df_units.index for t in range(T)), lowBound=0, cat="Continuous")
174
+ shed = pulp.LpVariable.dicts("shed", ((r, t) for r in regions for t in range(T)), lowBound=0, cat="Continuous")
175
+
176
+ # Objective
177
+ cost_terms = []
178
+ for u in df_units.index:
179
+ row = df_units.loc[u]
180
+ vc = 0.0
181
+ if not row["is_renew"]:
182
+ fuel = row["fuel"]
183
+ if fuel == "nuclear":
184
+ vc = varcost_map["nuclear"]
185
+ else:
186
+ vc = varcost_map[fuel]
187
+ for t in range(T):
188
+ cost_terms.append(vc * g[(u, t)])
189
+ # VOLL penalty
190
  for r in regions:
191
+ for t in range(T):
192
+ cost_terms.append(voll_jpy_per_mwh * shed[(r, t)])
193
+ mdl += pulp.lpSum(cost_terms)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  # Constraints
196
+ # Unit limits
197
+ for u in df_units.index:
198
+ row = df_units.loc[u]
199
+ cap = float(row["cap_MW"])
200
+ min_frac = float(row["min_frac"])
201
+ for t in range(T):
202
+ # Upper bound on generation
203
+ if row["is_renew"]:
204
+ cf_col = row["cf_key"]
205
+ cf_val = float(ts_df_slice.iloc[t][cf_col])
206
+ mdl += g[(u, t)] <= cap * cf_val, f"RenCap_{u}_{t}"
207
+ mdl += r_up[(u, t)] == 0.0, f"RenNoReserve_{u}_{t}"
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  else:
209
+ mdl += g[(u, t)] <= cap, f"Cap_{u}_{t}"
210
+ mdl += g[(u, t)] >= min_frac * cap, f"MinOut_{u}_{t}"
211
+ # Reserve headroom
212
+ mdl += r_up[(u, t)] <= cap - g[(u, t)], f"ReserveHeadroom_{u}_{t}"
213
+
214
+ # Energy balance & Reserve requirement
215
+ lmp_names = {} # energy balance names to read duals
216
+ res_names = {} # reserve names to read duals (use <= form for positive dual)
217
+ for ridx, r in enumerate(regions):
218
+ units_r = units_by_region[r]
219
+ for t in range(T):
220
+ # Energy balance: sum g + shed == demand[r,t]
221
+ cname = f"EnergyBal_{r}_{t}"
222
+ mdl += (pulp.lpSum([g[(u, t)] for u in units_r]) + shed[(r, t)] ==
223
+ float(demand[r][t])), cname
224
+ lmp_names[(r, t)] = cname
225
+
226
+ # Reserve: -sum r <= - req (so dual >= 0 at optimum)
227
+ req = reserve_ratio * float(demand[r][t])
228
+ rname = f"ReserveReq_{r}_{t}"
229
+ mdl += (-pulp.lpSum([r_up[(u, t)] for u in units_r]) <= -req), rname
230
+ res_names[(r, t)] = rname
231
+
232
+ # Solve
233
+ solver = pulp.PULP_CBC_CMD(msg=False)
234
+ mdl.solve(solver)
235
+
236
+ # Extract LMPs and reserve prices
237
+ lmp_mat = np.zeros((T, len(regions)))
238
+ res_mat = np.zeros((T, len(regions)))
239
+ for j, r in enumerate(regions):
240
+ for t in range(T):
241
+ lmp_mat[t, j] = mdl.constraints[lmp_names[(r, t)]].pi # JPY/MWh
242
+ res_mat[t, j] = mdl.constraints[res_names[(r, t)]].pi # JPY/MW
243
+
244
+ lmp_df = pd.DataFrame(lmp_mat, index=ts_df_slice.index, columns=regions)
245
+ res_price_df = pd.DataFrame(res_mat, index=ts_df_slice.index, columns=regions)
246
+
247
+ # Dispatch table
248
+ disp_rows = []
249
+ for u in df_units.index:
250
+ row = df_units.loc[u]
251
+ for t in range(T):
252
+ disp_rows.append({
253
+ "Time": ts_df_slice.index[t],
254
+ "unit_id": row["unit_id"],
255
+ "region": row["region"],
256
+ "tech": row["tech"],
257
+ "g_MW": g[(u, t)].value(),
258
+ "r_up_MW": r_up[(u, t)].value()
259
+ })
260
+ dispatch_df = pd.DataFrame(disp_rows)
261
+
262
+ return {"lmp_df": lmp_df, "res_price_df": res_price_df, "dispatch_df": dispatch_df}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
  # -----------------------------
265
  # Streamlit UI
266
  # -----------------------------
267
+ st.set_page_config(page_title="Simultaneous Market (JP-10) — Random Fleet", layout="wide")
268
+ st.title("同時市場シミュレーション(日本10エリア・乱数フリート)")
269
 
270
+ # Regions
271
+ REGIONS = ["Hokkaido","Tohoku","Tokyo","Chubu","Hokuriku","Kansai","Chugoku","Shikoku","Kyushu","Okinawa"]
 
272
 
273
+ # Load time-series
274
  ts_df = load_timeseries()
275
 
 
 
 
276
  with st.sidebar:
277
+ st.header("乱数と時間範囲")
278
+ seed = st.number_input("Random seed", value=42, step=1)
279
+ hours = st.slider("Hours to simulate", min_value=24, max_value=min(168, len(ts_df)), value=24, step=24)
280
+ start_idx = st.slider("Start index", min_value=0, max_value=max(0, len(ts_df)-hours), value=0, step=1)
281
+ ts_slice = ts_df.iloc[start_idx:start_idx+hours]
282
 
283
+ st.header("需要配分(∑=1.0)")
284
  default_share = pd.DataFrame({
285
+ "region": REGIONS,
286
+ "share": [0.03,0.09,0.32,0.14,0.03,0.17,0.07,0.03,0.11,0.01]
287
  })
288
  share_df = st.data_editor(default_share, num_rows="fixed", use_container_width=True)
289
+ ssum = float(share_df["share"].sum())
290
+ if abs(ssum - 1.0) > 1e-9:
291
+ st.warning(f"需要配分の合計が {ssum:.3f}1.0 に正規化します。")
292
+ demand_shares = {row["region"]: float(row["share"])/ssum for _, row in share_df.iterrows()}
293
 
294
+ st.header("燃料価格・為替(可変費)")
295
  usd_jpy = st.number_input("USD/JPY", value=148.21, step=0.5)
296
+ lng_px = st.number_input("LNG (USD/MMBtu)", value=11.27, step=0.1)
297
+ coal_px = st.number_input("Coal (USD/ton)", value=130.0, step=1.0)
298
+ oil_px = st.number_input("Oil (USD/bbl)", value=80.0, step=1.0)
299
+ # Heat-rate (GJ/MWh) ranges (for random draw, UI below sets min/max)
300
+ st.caption("熱率の代表値:Gas CCGT≈6.6, Coal≈8.3, Oil≈9.2(レンジ内で乱数生成)")
301
+ hr_gas_min = st.number_input("HR_gas_min (GJ/MWh)", value=6.2, step=0.1)
302
+ hr_gas_max = st.number_input("HR_gas_max (GJ/MWh)", value=6.9, step=0.1)
303
+ hr_coal_min = st.number_input("HR_coal_min (GJ/MWh)", value=7.8, step=0.1)
304
+ hr_coal_max = st.number_input("HR_coal_max (GJ/MWh)", value=9.0, step=0.1)
305
+ hr_oil_min = st.number_input("HR_oil_min (GJ/MWh)", value=8.8, step=0.1)
306
+ hr_oil_max = st.number_input("HR_oil_max (GJ/MWh)", value=10.0, step=0.1)
307
+ nuc_var = st.number_input("原子力の可変費(JPY/MWh)", value=2300.0, step=100.0)
308
+
309
+ vc = var_costs_jpy_per_mwh(usd_jpy, lng_px, coal_px, oil_px,
310
+ (hr_gas_min+hr_gas_max)/2, (hr_coal_min+hr_coal_max)/2, (hr_oil_min+hr_oil_max)/2,
311
+ nuc_var)
312
+ st.caption("参考:中庸熱率での短期限界費用(JPY/MWh)")
313
+ st.write({k: round(v,1) for k,v in vc.items()})
314
+
315
+ st.header("ユニット数(各エリア)")
316
+ units_df = pd.DataFrame({
317
+ "region": REGIONS,
318
+ "lng_units": [6,10,25,10,4,20,8,4,10,2],
319
+ "coal_units":[3,6,10,6,3,10,5,2,7,1],
320
+ "oil_units": [2,3,6,3,2,5,3,2,3,1],
321
+ "nuc_units": [0,2,4,2,1,3,1,1,2,0],
322
+ "solar_units":[20,30,60,30,12,40,20,12,30,8],
323
+ "on_units": [10,15,25,15,6,20,10,6,15,4],
324
+ "off_units": [2,3,6,3,1,4,2,1,3,0],
325
+ "river_units":[5,8,12,8,4,12,6,3,8,2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  })
327
+ units_df = st.data_editor(units_df, use_container_width=True)
328
+
329
+ st.header("容量レンジ [MW/ユニット]")
330
+ cap_bounds = {
331
+ "lng": (200.0, 900.0),
332
+ "coal": (300.0,1000.0),
333
+ "oil": (100.0, 700.0),
334
+ "nuclear": (500.0,1400.0)
335
+ }
336
+ ren_bounds = {
337
+ "solar": (10.0, 200.0),
338
+ "onshore_wind": (20.0, 300.0),
339
+ "offshore_wind": (100.0, 600.0),
340
+ "river": (10.0, 200.0)
341
+ }
342
+
343
+ st.header("最低出力(比率レンジ)")
344
+ minout_cfg = {
345
+ "lng": (0.0, 0.2),
346
+ "coal": (0.2, 0.6),
347
+ "oil": (0.0, 0.4),
348
+ "nuclear": (0.6, 0.9)
349
+ }
350
+
351
+ st.header("市場パラメータ")
352
+ reserve_ratio = st.slider("一次予備率(需要比)", min_value=0.0, max_value=0.20, value=0.03, step=0.01)
353
+ voll = st.number_input("VOLL(JPY/MWh)", value=300000.0, step=10000.0)
354
 
355
+ # Build counts config
356
+ counts_cfg = {}
357
+ for _, row in units_df.iterrows():
358
  r = row["region"]
359
+ counts_cfg[(r,"lng")] = int(row["lng_units"])
360
+ counts_cfg[(r,"coal")] = int(row["coal_units"])
361
+ counts_cfg[(r,"oil")] = int(row["oil_units"])
362
+ counts_cfg[(r,"nuclear")] = int(row["nuc_units"])
363
+ counts_cfg[(r,"solar")] = int(row["solar_units"])
364
+ counts_cfg[(r,"onshore_wind")] = int(row["on_units"])
365
+ counts_cfg[(r,"offshore_wind")] = int(row["off_units"])
366
+ counts_cfg[(r,"river")] = int(row["river_units"])
367
+
368
+ # Heat-rate and min-output configs
369
+ hr_cfg = {"lng": (hr_gas_min, hr_gas_max), "coal": (hr_coal_min, hr_coal_max),
370
+ "oil": (hr_oil_min, hr_oil_max), "nuclear": (np.nan, np.nan)}
371
+ min_output_cfg = minout_cfg
372
+
373
+ if st.button("シミュレーション実行(同時市���クリアリング)"):
374
+ rng = np.random.default_rng(seed)
375
+ df_units = generate_fleet(REGIONS, rng, counts_cfg, cap_bounds, hr_cfg, min_output_cfg, ren_bounds)
376
+
377
+ st.subheader("生成フリート(ユニット一覧)")
378
+ st.dataframe(df_units)
379
+
380
+ res = clear_market(df_units, ts_slice, REGIONS, demand_shares, voll,
381
+ reserve_ratio, varcost_map=var_costs_jpy_per_mwh(
382
+ usd_jpy, lng_px, coal_px, oil_px,
383
+ (hr_gas_min+hr_gas_max)/2, (hr_coal_min+hr_coal_max)/2, (hr_oil_min+hr_oil_max)/2,
384
+ nuc_var
385
+ ))
386
+
387
+ # LMP & Reserve price plots
388
+ st.subheader("LMP(JPY/MWh)")
389
+ st.plotly_chart(px.line(res["lmp_df"], x=res["lmp_df"].index, y=res["lmp_df"].columns), use_container_width=True)
390
+
391
+ st.subheader("予備力価格(JPY/MW)")
392
+ st.plotly_chart(px.line(res["res_price_df"], x=res["res_price_df"].index, y=res["res_price_df"].columns), use_container_width=True)
393
+
394
+ # Simple national dispatch stack (sum by tech)
395
+ disp = res["dispatch_df"].merge(df_units[["unit_id","tech"]], on="unit_id")
396
+ stack = disp.groupby(["Time","tech"], as_index=False)["g_MW"].sum()
397
+ fig = go.Figure()
398
+ for tech in ["solar","onshore_wind","offshore_wind","river","nuclear","coal","lng","oil"]:
399
+ if tech in stack["tech"].unique():
400
+ sub = stack[stack["tech"]==tech]
401
+ fig.add_trace(go.Scatter(x=sub["Time"], y=sub["g_MW"], mode="lines", stackgroup="one", name=tech))
402
+ fig.update_layout(title="全国発電スタック(合算)", yaxis_title="MW")
403
+ st.plotly_chart(fig, use_container_width=True)
404
+
405
+ # Downloads
406
+ csv_buf = StringIO()
407
+ df_units.to_csv(csv_buf, index=False, encoding="utf-8")
408
+ st.download_button("ユニット一覧CSVダウンロード", data=csv_buf.getvalue(), file_name="fleet_units.csv", mime="text/csv")
409
+
410
+ csv_buf2 = StringIO()
411
+ res["dispatch_df"].to_csv(csv_buf2, index=False, encoding="utf-8")
412
+ st.download_button("ディスパッチ結果CSVダウンロード", data=csv_buf2.getvalue(), file_name="dispatch.csv", mime="text/csv")