naohiro701 commited on
Commit
9bf5e9c
·
verified ·
1 Parent(s): 018bd0f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +390 -246
app.py CHANGED
@@ -1,49 +1,141 @@
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
@@ -53,63 +145,82 @@ def var_costs_jpy_per_mwh(usd_jpy, lng_usd_per_mmbtu, coal_usd_per_ton, oil_usd_
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),
@@ -118,220 +229,241 @@ def generate_fleet(regions, rng, counts_cfg, caps_cfg, hr_cfg, min_output_cfg, r
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),
@@ -340,7 +472,7 @@ with st.sidebar:
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),
@@ -350,63 +482,75 @@ with st.sidebar:
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")
 
 
 
1
+ # app_market_sim_full.py
2
  # -*- coding: utf-8 -*-
3
+ """
4
+ Simultaneous market clearing for Japan (10 areas) with a randomized generation fleet.
5
+ - Energy + upward reserve co-optimization (linear program).
6
+ - Unit-level random capacities/heat-rates/minimum outputs by fuel.
7
+ - Robust timestamp parsing (format='mixed') for data.json time axis.
8
+ - LMP = dual of energy-balance per area/hour (JPY/MWh).
9
+ - Reserve price = dual of reserve requirement per area/hour (JPY/MW).
10
+ - Includes "shed" (load shed, penalized by VOLL) and "spill" (over-generation dump with tiny penalty).
11
+
12
+ How to run:
13
+ streamlit run app_market_sim_full.py
14
+
15
+ Data requirement (data.json):
16
+ {
17
+ "solar": {"x": [...timestamps...], "y": [...cf...]},
18
+ "onshore_wind": {"x": [...], "y": [...]},
19
+ "offshore_wind": {"x": [...], "y": [...]},
20
+ "river": {"x": [...], "y": [...]},
21
+ "demand": {"x": [...], "y": [...]} # demand hourly capacity factor
22
+ }
23
+ """
24
+
25
  import json
26
+ from io import StringIO
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
  import pulp
31
  import plotly.express as px
32
  import plotly.graph_objs as go
33
+ import streamlit as st
34
+
35
 
36
  # -----------------------------
37
+ # Time-series loader (robust)
38
  # -----------------------------
39
  def load_timeseries():
40
  """
41
+ Robustly load hourly time-series from data.json and parse timestamps.
42
+
43
+ What this does
44
+ --------------
45
+ - Strip stray whitespace/newlines from timestamp strings in 'x'.
46
+ - Parse timestamps with format='mixed' (ISO8601/tz/offset tolerated).
47
+ - Coerce parse failures to NaT and drop those rows consistently.
48
+ - Return tidy DataFrame indexed by 'Time'.
49
+
50
+ Returns
51
+ -------
52
+ pandas.DataFrame
53
+ Index: Time (naive, Asia/Tokyo converted if tz present)
54
+ Columns:
55
+ 'solar hourly capacity factor',
56
+ 'onshore_wind hourly capacity factor',
57
+ 'offshore_wind hourly capacity factor',
58
+ 'river hourly capacity factor',
59
+ 'demand hourly capacity factor'
60
+ plus any extra series present.
61
  """
62
  with open("data.json", "r", encoding="utf-8") as f_local:
63
  data_local = json.load(f_local)
64
  if not data_local:
65
+ raise ValueError("data.json is empty")
66
+
67
+ # master timeline from first series
68
+ first_key_local = next(iter(data_local))
69
+ raw_times_local = data_local[first_key_local].get("x", [])
70
+ if not raw_times_local:
71
+ raise ValueError("data.json has no 'x' timeline")
72
+
73
+ cleaned_times = []
74
+ for v_local in raw_times_local:
75
+ if isinstance(v_local, str):
76
+ s_local = (
77
+ v_local.replace("\r", "")
78
+ .replace("\n", "")
79
+ .replace("\t", " ")
80
+ .replace("\u3000", " ")
81
+ .strip()
82
+ )
83
+ cleaned_times.append(s_local if s_local != "" else np.nan)
84
+ else:
85
+ cleaned_times.append(v_local)
86
+
87
+ time_parsed = pd.to_datetime(
88
+ cleaned_times, format="mixed", errors="coerce", utc=True
89
+ )
90
+ # Convert to JST and drop tz-awareness; fallbacks for naive data
91
+ try:
92
+ time_parsed = time_parsed.tz_convert("Asia/Tokyo").tz_localize(None)
93
+ except Exception:
94
+ try:
95
+ time_parsed = time_parsed.tz_localize(None)
96
+ except Exception:
97
+ pass
98
+
99
+ n_rows = len(cleaned_times)
100
+ df_out = pd.DataFrame(index=np.arange(n_rows))
101
+ df_out["Time"] = time_parsed
102
+
103
+ # load all Y series to columns, length-align then drop NaT rows
104
  for k_local, v_local in data_local.items():
105
  if isinstance(v_local, dict) and "y" in v_local:
106
+ ser = pd.Series(v_local["y"]).reindex(range(n_rows))
107
+ ser = pd.to_numeric(ser, errors="coerce") # CFs or weights
108
+ name = f"{k_local} hourly capacity factor"
109
+ df_out[name] = ser
110
+
111
+ mask = df_out["Time"].notna()
112
+ df_out = df_out.loc[mask].copy()
113
+ for c_local in df_out.columns:
114
+ if c_local != "Time":
115
+ df_out[c_local] = df_out[c_local].fillna(0.0)
116
+
117
+ df_out = df_out.sort_values("Time").drop_duplicates(subset=["Time"]).set_index("Time")
118
+ return df_out
119
+
120
 
121
  # -----------------------------
122
+ # Fuel price helpers
123
  # -----------------------------
124
+ def fuel_prices_jpy_per_gj(usd_jpy, lng_usd_per_mmbtu, coal_usd_per_ton, oil_usd_per_bbl):
 
 
125
  """
126
+ Compute fuel prices in JPY/GJ for LNG, coal, oil.
127
+
128
+ Parameters
129
+ ----------
130
+ usd_jpy : float
131
+ lng_usd_per_mmbtu : float
132
+ coal_usd_per_ton : float
133
+ oil_usd_per_bbl : float
134
+
135
+ Returns
136
+ -------
137
+ dict
138
+ {'lng': JPY/GJ, 'coal': JPY/GJ, 'oil': JPY/GJ}
139
  """
140
  mmbtu_to_gj = 1.055056
141
  bbl_to_gj = 6.12
 
145
  coal_usd_per_gj = coal_usd_per_ton / coal_gj_per_ton
146
  oil_usd_per_gj = oil_usd_per_bbl / bbl_to_gj
147
 
148
+ return {
149
+ "lng": float(gas_usd_per_gj * usd_jpy),
150
+ "coal": float(coal_usd_per_gj * usd_jpy),
151
+ "oil": float(oil_usd_per_gj * usd_jpy),
152
+ }
153
 
 
154
 
155
  # -----------------------------
156
  # Random fleet generator
157
  # -----------------------------
158
+ def generate_fleet(regions_list, rng_obj, counts_cfg, caps_cfg, hr_cfg, min_frac_cfg, ren_caps_cfg):
159
  """
160
  Generate a unit-level fleet with realistic ranges.
161
 
162
+ Parameters
163
+ ----------
164
+ regions_list : list[str]
165
+ rng_obj : numpy.random.Generator
166
+ counts_cfg : dict[(region,str)->int]
167
+ Number of units per fuel/tech for each region.
168
+ caps_cfg : dict[str->(float,float)]
169
+ Capacity range [MW/unit] for thermal/nuclear, e.g. {'lng':(200,900),...}
170
+ hr_cfg : dict[str->(float,float)]
171
+ Heat-rate range [GJ/MWh] per fuel for random draw (nuclear ignored).
172
+ min_frac_cfg : dict[str->(float,float)]
173
+ Minimum output fraction range per fuel (e.g., nuclear (0.6,0.9)).
174
+ ren_caps_cfg : dict[str->(float,float)]
175
+ Capacity range for renewables per unit.
176
+
177
  Returns
178
  -------
179
+ pandas.DataFrame
180
+ Columns: ['unit_id','region','tech','fuel','cap_MW','hr_GJ_per_MWh','min_frac','is_renew','cf_key']
181
  """
182
+ rows = []
183
+ uid = 0
184
 
185
  # Thermal & Nuclear
186
+ for r_loc in regions_list:
187
+ for fuel in ["lng", "coal", "oil", "nuclear"]:
188
+ n_units = int(counts_cfg[(r_loc, fuel)])
189
+ cap_lo, cap_hi = caps_cfg[fuel]
190
+ hr_lo, hr_hi = hr_cfg.get(fuel, (np.nan, np.nan))
191
+ min_lo, min_hi = min_frac_cfg[fuel]
192
  for _ in range(n_units):
193
+ cap = rng_obj.uniform(cap_lo, cap_hi)
194
+ hr = rng_obj.uniform(hr_lo, hr_hi) if fuel != "nuclear" else np.nan
195
+ min_frac = rng_obj.uniform(min_lo, min_hi)
196
+ rows.append({
197
+ "unit_id": f"U{uid}",
198
+ "region": r_loc,
199
+ "tech": fuel,
200
+ "fuel": fuel,
201
  "cap_MW": float(cap),
202
+ "hr_GJ_per_MWh": (float(hr) if fuel != "nuclear" else np.nan),
203
  "min_frac": float(min_frac),
204
  "is_renew": False,
205
  "cf_key": None
206
  })
207
+ uid += 1
208
+
209
+ # Renewables
210
+ for r_loc in regions_list:
211
+ for ren_key, cf_key in [
212
+ ("solar", "solar"),
213
+ ("onshore_wind", "onshore_wind"),
214
+ ("offshore_wind", "offshore_wind"),
215
+ ("river", "river")
216
+ ]:
217
+ n_units = int(counts_cfg[(r_loc, ren_key)])
218
+ cap_lo, cap_hi = ren_caps_cfg[ren_key]
219
  for _ in range(n_units):
220
+ cap = rng_obj.uniform(cap_lo, cap_hi)
221
+ rows.append({
222
+ "unit_id": f"U{uid}",
223
+ "region": r_loc,
224
  "tech": ren_key,
225
  "fuel": None,
226
  "cap_MW": float(cap),
 
229
  "is_renew": True,
230
  "cf_key": f"{cf_key} hourly capacity factor"
231
  })
232
+ uid += 1
233
+
234
+ return pd.DataFrame(rows)
235
 
 
 
236
 
237
  # -----------------------------
238
+ # Simultaneous market clearing
239
  # -----------------------------
240
+ def clear_market(df_units, ts_df_slice, regions_list, demand_shares_map, voll_jpy_per_mwh,
241
+ reserve_ratio, fuel_price_jpy_per_gj_map, nuclear_varcost_jpy_per_mwh,
242
+ spill_penalty=1e-6):
243
  """
244
  Co-optimize energy and upward reserve simultaneously for each region and hour.
245
 
246
+ Decision variables
247
+ ------------------
248
+ g[u,t] : Generation (MW)
249
+ r[u,t] : Upward reserve (MW), eligible only for non-renew units
250
+ shed[r,t] : Load shedding (MW), penalized by VOLL
251
+ spill[r,t] : Over-generation dump (MW), tiny penalty to keep feasibility with must-run
252
+
253
+ Constraints
254
+ -----------
255
+ Energy balance (per region,t):
256
+ sum_u g[u,t] + shed[r,t] == demand[r,t] + spill[r,t]
257
+ Reserve requirement (per region,t):
258
+ sum_u r[u,t] >= reserve_ratio * demand[r,t]
259
+ Implemented as -sum r <= -req (so dual >= 0)
260
+ Unit limits:
261
+ Thermal/Nuclear: min_frac*cap <= g <= cap, 0 <= r <= cap - g
262
+ Renewables: 0 <= g <= CF*cap, r == 0
263
+
264
+ Objective
265
+ ---------
266
+ Minimize sum( var_cost_unit * g + VOLL * shed + spill_penalty * spill )
267
 
268
  Returns
269
  -------
270
+ dict
271
+ {
272
+ 'lmp_df': DataFrame [Time x regions] LMP (JPY/MWh),
273
+ 'res_price_df': DataFrame [Time x regions] Reserve price (JPY/MW),
274
+ 'dispatch_df': long DataFrame with unit-level g and r,
275
+ }
276
  """
277
  times = ts_df_slice.index
278
  T = len(times)
279
 
280
+ # Regional demand (relative scale from demand CF)
281
  base_cf = ts_df_slice["demand hourly capacity factor"].values
282
+ base_profile = base_cf / max(base_cf.max(), 1e-9) # normalize peak=1
283
+ national_demand = base_profile # MW proxy scale
284
+ demand = {r: national_demand * float(demand_shares_map[r]) for r in regions_list}
285
+
286
+ # Precompute per-unit variable costs (JPY/MWh) using unit heat-rates
287
+ vc_unit = {}
288
+ for u in df_units.index:
289
+ row_u = df_units.loc[u]
290
+ if row_u["is_renew"]:
291
+ vc_unit[u] = 0.0
292
+ else:
293
+ if row_u["fuel"] == "nuclear":
294
+ vc_unit[u] = float(nuclear_varcost_jpy_per_mwh)
295
+ else:
296
+ p_gj = float(fuel_price_jpy_per_gj_map[row_u["fuel"]]) # JPY/GJ
297
+ hr = float(row_u["hr_GJ_per_MWh"])
298
+ vc_unit[u] = p_gj * hr # JPY/MWh
299
+
300
+ # Model
301
  mdl = pulp.LpProblem("CoOptim_Energy_Reserve", pulp.LpMinimize)
302
 
303
+ # Indices
304
+ units_by_region = {r: df_units[df_units["region"] == r].index.tolist() for r in regions_list}
305
 
306
  # Variables
307
+ g = pulp.LpVariable.dicts("g", ((int(u), int(t)) for u in df_units.index for t in range(T)),
308
+ lowBound=0, cat="Continuous")
309
+ r_up = pulp.LpVariable.dicts("r", ((int(u), int(t)) for u in df_units.index for t in range(T)),
310
+ lowBound=0, cat="Continuous")
311
+ shed = pulp.LpVariable.dicts("shed", ((r, int(t)) for r in regions_list for t in range(T)),
312
+ lowBound=0, cat="Continuous")
313
+ spill = pulp.LpVariable.dicts("spill", ((r, int(t)) for r in regions_list for t in range(T)),
314
+ lowBound=0, cat="Continuous")
315
 
316
  # Objective
317
+ obj_terms = []
318
  for u in df_units.index:
319
+ c_u = float(vc_unit[u])
 
 
 
 
 
 
 
320
  for t in range(T):
321
+ obj_terms.append(c_u * g[(int(u), int(t))])
322
+ for r in regions_list:
 
323
  for t in range(T):
324
+ obj_terms.append(voll_jpy_per_mwh * shed[(r, int(t))])
325
+ obj_terms.append(spill_penalty * spill[(r, int(t))])
326
+ mdl += pulp.lpSum(obj_terms)
327
 
328
  # Constraints
329
  # Unit limits
330
  for u in df_units.index:
331
+ row_u = df_units.loc[u]
332
+ cap_u = float(row_u["cap_MW"])
333
+ min_frac_u = float(row_u["min_frac"])
334
  for t in range(T):
335
+ if row_u["is_renew"]:
336
+ cf_col = row_u["cf_key"]
 
337
  cf_val = float(ts_df_slice.iloc[t][cf_col])
338
+ mdl += g[(int(u), int(t))] <= cap_u * cf_val, f"RenCap_{u}_{t}"
339
+ mdl += r_up[(int(u), int(t))] == 0.0, f"RenNoReserve_{u}_{t}"
340
  else:
341
+ mdl += g[(int(u), int(t))] <= cap_u, f"Cap_{u}_{t}"
342
+ mdl += g[(int(u), int(t))] >= min_frac_u * cap_u, f"MinOut_{u}_{t}"
343
+ mdl += r_up[(int(u), int(t))] <= cap_u - g[(int(u), int(t))], f"ReserveHeadroom_{u}_{t}"
344
+
345
+ # Energy balance & Reserve requirement (keep names to read duals)
346
+ energy_cons_names = {}
347
+ reserve_cons_names = {}
348
+ for r in regions_list:
 
349
  units_r = units_by_region[r]
350
  for t in range(T):
 
351
  cname = f"EnergyBal_{r}_{t}"
352
+ mdl += (
353
+ pulp.lpSum([g[(int(u), int(t))] for u in units_r]) + shed[(r, int(t))]
354
+ == float(demand[r][t]) + spill[(r, int(t))]
355
+ ), cname
356
+ energy_cons_names[(r, t)] = cname
357
 
 
358
  req = reserve_ratio * float(demand[r][t])
359
  rname = f"ReserveReq_{r}_{t}"
360
+ # -sum r <= -req (dual >= 0 at optimum)
361
+ mdl += (-pulp.lpSum([r_up[(int(u), int(t))] for u in units_r]) <= -req), rname
362
+ reserve_cons_names[(r, t)] = rname
363
 
364
  # Solve
365
  solver = pulp.PULP_CBC_CMD(msg=False)
366
  mdl.solve(solver)
367
 
368
+ # Extract prices
369
+ lmp_mat = np.zeros((T, len(regions_list)))
370
+ res_mat = np.zeros((T, len(regions_list)))
371
+ for j, r in enumerate(regions_list):
372
  for t in range(T):
373
+ lmp_mat[t, j] = mdl.constraints[energy_cons_names[(r, t)]].pi # JPY/MWh
374
+ res_mat[t, j] = mdl.constraints[reserve_cons_names[(r, t)]].pi # JPY/MW
375
 
376
+ lmp_df = pd.DataFrame(lmp_mat, index=ts_df_slice.index, columns=regions_list)
377
+ res_price_df = pd.DataFrame(res_mat, index=ts_df_slice.index, columns=regions_list)
378
 
379
+ # Dispatch long table
380
  disp_rows = []
381
  for u in df_units.index:
382
+ row_u = df_units.loc[u]
383
  for t in range(T):
384
  disp_rows.append({
385
  "Time": ts_df_slice.index[t],
386
+ "unit_id": row_u["unit_id"],
387
+ "region": row_u["region"],
388
+ "tech": row_u["tech"],
389
+ "g_MW": g[(int(u), int(t))].value(),
390
+ "r_up_MW": r_up[(int(u), int(t))].value()
391
  })
392
  dispatch_df = pd.DataFrame(disp_rows)
393
 
394
  return {"lmp_df": lmp_df, "res_price_df": res_price_df, "dispatch_df": dispatch_df}
395
 
396
+
397
  # -----------------------------
398
+ # Streamlit App
399
  # -----------------------------
400
  st.set_page_config(page_title="Simultaneous Market (JP-10) — Random Fleet", layout="wide")
401
+ st.title("同時市場シミュレーション(日本10エリア・乱数フリート・完全版)")
 
 
 
402
 
403
+ # Load time-series (robust to mixed formats)
404
  ts_df = load_timeseries()
405
 
406
+ # Regions
407
+ regions = ["Hokkaido", "Tohoku", "Tokyo", "Chubu", "Hokuriku",
408
+ "Kansai", "Chugoku", "Shikoku", "Kyushu", "Okinawa"]
409
+
410
  with st.sidebar:
411
  st.header("乱数と時間範囲")
412
+ seed_val = st.number_input("Random seed", value=42, step=1)
413
+ max_hours = min(168, len(ts_df))
414
+ hours_val = st.slider("Hours to simulate", min_value=24, max_value=max_hours, value=min(24, max_hours), step=24)
415
+ start_idx = st.slider("Start index", min_value=0, max_value=max(0, len(ts_df) - hours_val), value=0, step=1)
416
+ ts_slice = ts_df.iloc[start_idx:start_idx + hours_val]
417
 
418
  st.header("需要配分(∑=1.0)")
419
+ default_share_df = pd.DataFrame({
420
+ "region": regions,
421
+ "share": [0.03, 0.09, 0.32, 0.14, 0.03, 0.17, 0.07, 0.03, 0.11, 0.01]
422
  })
423
+ share_df = st.data_editor(default_share_df, num_rows="fixed", use_container_width=True)
424
+ share_sum = float(share_df["share"].sum())
425
+ if abs(share_sum - 1.0) > 1e-9:
426
+ st.warning(f"需要配分の合計が {share_sum:.3f}、1.0 に正規化します。")
427
+ demand_shares = {row["region"]: float(row["share"]) / share_sum for _, row in share_df.iterrows()}
428
 
429
+ st.header("燃料価格・為替(可変費への影響)")
430
  usd_jpy = st.number_input("USD/JPY", value=148.21, step=0.5)
431
  lng_px = st.number_input("LNG (USD/MMBtu)", value=11.27, step=0.1)
432
  coal_px = st.number_input("Coal (USD/ton)", value=130.0, step=1.0)
433
  oil_px = st.number_input("Oil (USD/bbl)", value=80.0, step=1.0)
434
+ price_gj = fuel_prices_jpy_per_gj(usd_jpy, lng_px, coal_px, oil_px)
435
+ st.caption("燃料価格(JPY/GJ)")
436
+ st.write({k: round(v, 1) for k, v in price_gj.items()})
437
+
438
+ st.header("熱率レンジ(GJ/MWh:乱数生成に使用)")
439
+ hr_gas_min = st.number_input("Gas CCGT min", value=6.2, step=0.1)
440
+ hr_gas_max = st.number_input("Gas CCGT max", value=6.9, step=0.1)
441
+ hr_coal_min = st.number_input("Coal min", value=7.8, step=0.1)
442
+ hr_coal_max = st.number_input("Coal max", value=9.0, step=0.1)
443
+ hr_oil_min = st.number_input("Oil min", value=8.8, step=0.1)
444
+ hr_oil_max = st.number_input("Oil max", value=10.0, step=0.1)
445
+ nuc_var_jpy_mwh = st.number_input("Nuclear variable cost (JPY/MWh)", value=2300.0, step=100.0)
 
 
 
446
 
447
  st.header("ユニット数(各エリア)")
448
  units_df = pd.DataFrame({
449
+ "region": regions,
450
+ "lng_units": [6, 10, 25, 10, 4, 20, 8, 4, 10, 2],
451
+ "coal_units": [3, 6, 10, 6, 3, 10, 5, 2, 7, 1],
452
+ "oil_units": [2, 3, 6, 3, 2, 5, 3, 2, 3, 1],
453
+ "nuc_units": [0, 2, 4, 2, 1, 3, 1, 1, 2, 0],
454
+ "solar_units":[20, 30, 60, 30, 12, 40, 20, 12, 30, 8],
455
+ "on_units": [10, 15, 25, 15, 6, 20, 10, 6, 15, 4],
456
+ "off_units": [2, 3, 6, 3, 1, 4, 2, 1, 3, 0],
457
+ "river_units":[5, 8, 12, 8, 4, 12, 6, 3, 8, 2]
458
  })
459
  units_df = st.data_editor(units_df, use_container_width=True)
460
 
461
  st.header("容量レンジ [MW/ユニット]")
462
  cap_bounds = {
463
+ "lng": (200.0, 900.0),
464
+ "coal": (300.0, 1000.0),
465
+ "oil": (100.0, 700.0),
466
+ "nuclear": (500.0, 1400.0)
467
  }
468
  ren_bounds = {
469
  "solar": (10.0, 200.0),
 
472
  "river": (10.0, 200.0)
473
  }
474
 
475
+ st.header("最低出力(比率レンジ, 乱数生成に使用)")
476
  minout_cfg = {
477
  "lng": (0.0, 0.2),
478
  "coal": (0.2, 0.6),
 
482
 
483
  st.header("市場パラメータ")
484
  reserve_ratio = st.slider("一次予備率(需要比)", min_value=0.0, max_value=0.20, value=0.03, step=0.01)
485
+ voll_val = st.number_input("VOLL(JPY/MWh)", value=300000.0, step=10000.0)
486
 
487
+ # Build counts config dict
488
  counts_cfg = {}
489
  for _, row in units_df.iterrows():
490
+ rname = row["region"]
491
+ counts_cfg[(rname, "lng")] = int(row["lng_units"])
492
+ counts_cfg[(rname, "coal")] = int(row["coal_units"])
493
+ counts_cfg[(rname, "oil")] = int(row["oil_units"])
494
+ counts_cfg[(rname, "nuclear")] = int(row["nuc_units"])
495
+ counts_cfg[(rname, "solar")] = int(row["solar_units"])
496
+ counts_cfg[(rname, "onshore_wind")] = int(row["on_units"])
497
+ counts_cfg[(rname, "offshore_wind")] = int(row["off_units"])
498
+ counts_cfg[(rname, "river")] = int(row["river_units"])
499
+
500
+ # Heat-rate bounds map
501
+ hr_bounds = {
502
+ "lng": (hr_gas_min, hr_gas_max),
503
+ "coal": (hr_coal_min, hr_coal_max),
504
+ "oil": (hr_oil_min, hr_oil_max),
505
+ "nuclear": (np.nan, np.nan)
506
+ }
507
 
508
  if st.button("シミュレーション実行(同時市場クリアリング)"):
509
+ rng = np.random.default_rng(int(seed_val))
510
+ fleet_df = generate_fleet(regions, rng, counts_cfg, cap_bounds, hr_bounds, minout_cfg, ren_bounds)
511
 
512
  st.subheader("生成フリート(ユニット一覧)")
513
+ st.dataframe(fleet_df)
514
+
515
+ res = clear_market(
516
+ df_units=fleet_df,
517
+ ts_df_slice=ts_slice,
518
+ regions_list=regions,
519
+ demand_shares_map=demand_shares,
520
+ voll_jpy_per_mwh=voll_val,
521
+ reserve_ratio=float(reserve_ratio),
522
+ fuel_price_jpy_per_gj_map=price_gj,
523
+ nuclear_varcost_jpy_per_mwh=nuc_var_jpy_mwh,
524
+ spill_penalty=1e-6
525
+ )
526
 
 
 
 
 
 
 
 
 
527
  st.subheader("LMP(JPY/MWh)")
528
+ st.plotly_chart(px.line(res["lmp_df"], x=res["lmp_df"].index, y=res["lmp_df"].columns,
529
+ title="Area LMP (JPY/MWh)"), use_container_width=True)
530
 
531
  st.subheader("予備力価格(JPY/MW)")
532
+ st.plotly_chart(px.line(res["res_price_df"], x=res["res_price_df"].index, y=res["res_price_df"].columns,
533
+ title="Area Reserve Price (JPY/MW)"), use_container_width=True)
534
+
535
+ # National dispatch stack (sum by tech)
536
+ disp = res["dispatch_df"] # already includes tech
537
+ stack = disp.groupby(["Time", "tech"], as_index=False)["g_MW"].sum()
538
+ fig_stack = go.Figure()
539
+ for tech_name in ["solar", "onshore_wind", "offshore_wind", "river", "nuclear", "coal", "lng", "oil"]:
540
+ if tech_name in stack["tech"].unique():
541
+ sub = stack[stack["tech"] == tech_name]
542
+ fig_stack.add_trace(go.Scatter(x=sub["Time"], y=sub["g_MW"], mode="lines",
543
+ stackgroup="one", name=tech_name))
544
+ fig_stack.update_layout(title="全国発電スタック(合算)", yaxis_title="MW")
545
+ st.plotly_chart(fig_stack, use_container_width=True)
546
 
547
  # Downloads
548
+ csv_buf_units = StringIO()
549
+ fleet_df.to_csv(csv_buf_units, index=False, encoding="utf-8")
550
+ st.download_button("ユニット一覧CSVダウンロード", data=csv_buf_units.getvalue(),
551
+ file_name="fleet_units.csv", mime="text/csv")
552
+
553
+ csv_buf_disp = StringIO()
554
+ res["dispatch_df"].to_csv(csv_buf_disp, index=False, encoding="utf-8")
555
+ st.download_button("ディスパッチ結果CSVダウンロード", data=csv_buf_disp.getvalue(),
556
+ file_name="dispatch.csv", mime="text/csv")