naohiro701 commited on
Commit
25c67ac
·
verified ·
1 Parent(s): 0b81db0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +449 -895
app.py CHANGED
@@ -1,929 +1,483 @@
1
- # jp_market_model_network.py
2
- # -*- coding: utf-8 -*-
3
- """
4
- Japan-scale simultaneous market clearing with inter-area transmission (10 areas),
5
- realistic national scale (capacities, intertie ATCs), and time-varying inputs.
6
-
7
- - Areas: Hokkaido, Tohoku, Tokyo, Chubu, Hokuriku, Kansai, Chugoku, Shikoku, Kyushu, Okinawa
8
- - Network: directional interties with OCCTO/REI-based operating capacities (MW)
9
- - Energy + upward reserve co-optimization (LP)
10
- - Time-varying: fuel prices (via CSV or synthesized), FX, nuclear var cost, reserve ratio, VOLL
11
- - Unit-level random fleet, calibrated to national installed capacity by fuel (FY2022 JEPIC)
12
- - LMP = dual of area energy balance (JPY/MWh)
13
- - Reserve price = dual of area reserve requirement (JPY/MW)
14
- - Robust timestamp parsing for data.json (format='mixed')
15
-
16
- Run:
17
- streamlit run jp_market_model_network.py
18
-
19
- Required data.json (national CF profiles):
20
- {
21
- "solar": {"x": [...], "y": [...]},
22
- "onshore_wind": {"x": [...], "y": [...]},
23
- "offshore_wind": {"x": [...], "y": [...]},
24
- "river": {"x": [...], "y": [...]},
25
- "demand": {"x": [...], "y": [...]} # demand hourly capacity factor (0-1)
26
- }
27
-
28
- Optional CSV upload (time-varying overrides; header row; Time column required):
29
- Time,usd_jpy,lng_usd_per_mmbtu,coal_usd_per_ton,oil_usd_per_bbl,nuclear_var_jpy_per_mwh,reserve_ratio,voll
30
-
31
- Note: All variables are local; functions include English docstrings and comments.
32
- """
33
-
34
- import json
35
- from io import StringIO
36
- from typing import Dict, List, Tuple
37
-
38
- import numpy as np
39
  import pandas as pd
40
  import pulp
41
- import plotly.express as px
42
  import plotly.graph_objs as go
43
- import streamlit as st
44
-
45
-
46
- # -----------------------------
47
- # Robust time-series loader
48
- # -----------------------------
49
- def load_timeseries():
50
- """
51
- Robustly load hourly time-series from data.json and parse timestamps.
52
-
53
- What this does
54
- --------------
55
- - Strip stray whitespace/newlines from timestamp strings in 'x'.
56
- - Parse timestamps with format='mixed' (handles ISO/offset/Z).
57
- - Coerce failures to NaT; drop those rows consistently.
58
 
59
- Returns
60
- -------
61
- pandas.DataFrame
62
- Index = Time (naive; converted to Asia/Tokyo if tz provided)
63
- Columns include '<key> hourly capacity factor'.
64
  """
65
- with open("data.json", "r", encoding="utf-8") as f_local:
66
- data_local = json.load(f_local)
67
- if not data_local:
68
- raise ValueError("data.json is empty")
69
-
70
- first_key_local = next(iter(data_local))
71
- raw_times = data_local[first_key_local].get("x", [])
72
- if not raw_times:
73
- raise ValueError("data.json has no 'x' timeline")
74
-
75
- cleaned = []
76
- for v in raw_times:
77
- if isinstance(v, str):
78
- s = (
79
- v.replace("
80
- ", "").replace("
81
- ", "").replace(" ", " ")
82
- .replace(" ", " ").strip()
83
- )
84
- cleaned.append(s if s != "" else np.nan)
85
- else:
86
- cleaned.append(v)
87
-
88
- t_parsed = pd.to_datetime(cleaned, format="mixed", errors="coerce", utc=True)
89
- try:
90
- t_parsed = t_parsed.tz_convert("Asia/Tokyo").tz_localize(None)
91
- except Exception:
92
- try:
93
- t_parsed = t_parsed.tz_localize(None)
94
- except Exception:
95
- pass
96
-
97
- n = len(cleaned)
98
- df = pd.DataFrame({"Time": t_parsed})
99
- for k, v in data_local.items():
100
- if isinstance(v, dict) and "y" in v:
101
- ser = pd.Series(v["y"]).reindex(range(n))
102
- ser = pd.to_numeric(ser, errors="coerce")
103
- df[f"{k} hourly capacity factor"] = ser
104
-
105
- df = df[df["Time"].notna()].copy()
106
- for c in df.columns:
107
- if c != "Time":
108
- df[c] = df[c].fillna(0.0).clip(lower=0.0)
109
- df = df.sort_values("Time").drop_duplicates(subset=["Time"]).set_index("Time")
110
- return df
111
-
112
-
113
- # -----------------------------
114
- # CSV overrides (time-varying)
115
- # -----------------------------
116
- def load_overrides_csv(file_obj, index_like):
117
- """
118
- Read optional CSV with time-varying overrides and align to target index.
119
-
120
- Parameters
121
- ----------
122
- file_obj : UploadedFile or None
123
- index_like : pandas.DatetimeIndex to reindex on
124
-
125
  Returns
126
  -------
127
- pandas.DataFrame
128
- Columns subset of:
129
- ['usd_jpy','lng_usd_per_mmbtu','coal_usd_per_ton','oil_usd_per_bbl',
130
- 'nuclear_var_jpy_per_mwh','reserve_ratio','voll']
131
- Indexed to index_like with forward-fill then back-fill.
132
  """
133
- if file_obj is None:
 
 
134
  return None
135
- df_local = pd.read_csv(file_obj)
136
- if "Time" not in df_local.columns:
137
- raise ValueError("Override CSV must contain 'Time' column.")
138
- df_local["Time"] = pd.to_datetime(df_local["Time"], format="mixed", errors="coerce", utc=True)
139
- try:
140
- df_local["Time"] = df_local["Time"].dt.tz_convert("Asia/Tokyo").dt.tz_localize(None)
141
- except Exception:
142
- try:
143
- df_local["Time"] = df_local["Time"].dt.tz_localize(None)
144
- except Exception:
145
- pass
146
- df_local = df_local.set_index("Time").sort_index()
147
- cols_local = ['usd_jpy','lng_usd_per_mmbtu','coal_usd_per_ton','oil_usd_per_bbl',
148
- 'nuclear_var_jpy_per_mwh','reserve_ratio','voll']
149
- df_local = df_local[[c for c in cols_local if c in df_local.columns]]
150
- df_local = df_local.reindex(index_like).ffill().bfill()
151
- return df_local
152
-
153
-
154
- # -----------------------------
155
- # Fuel price converters
156
- # -----------------------------
157
- def jpy_per_gj_series(usd_jpy_ser, lng_usd_per_mmbtu_ser, coal_usd_per_ton_ser, oil_usd_per_bbl_ser):
158
- """Convert price series to JPY/GJ series.
159
-
160
- Assumptions
161
- -----------
162
- 1 MMBtu = 1.055056 GJ, 1 bbl ≈ 6.12 GJ, coal ≈ 25.12 GJ/ton.
163
- """
164
- mmbtu_to_gj = 1.055056
165
- bbl_to_gj = 6.12
166
- coal_gj_per_ton = 25.12
167
-
168
- gas_usd_per_gj = lng_usd_per_mmbtu_ser / mmbtu_to_gj
169
- coal_usd_per_gj = coal_usd_per_ton_ser / coal_gj_per_ton
170
- oil_usd_per_gj = oil_usd_per_bbl_ser / bbl_to_gj
171
-
172
- return {
173
- "lng": (gas_usd_per_gj * usd_jpy_ser).astype(float),
174
- "coal": (coal_usd_per_gj * usd_jpy_ser).astype(float),
175
- "oil": (oil_usd_per_gj * usd_jpy_ser).astype(float),
176
- }
177
-
178
-
179
- # -----------------------------
180
- # National scale targets (FY2022 JEPIC)
181
- # -----------------------------
182
- def capacity_targets_gw():
183
- """Return national installed capacity targets by technology (GW).
184
-
185
- Targets reflect FY2022 totals (JEPIC 2024) and split hydro into
186
- conventional 'river' (≈21.8 GW) + omit pumped storage for this LP.
187
- """
188
- targets = {
189
- "lng": 79.1, # GW
190
- "coal": 50.7,
191
- "oil": 21.6,
192
- "nuclear": 33.1,
193
- "river": 21.8, # conventional hydro only
194
- "solar": 70.1,
195
- "onshore_wind": 4.8, # split 5.3 GW total wind approx.
196
- "offshore_wind": 0.5,
197
- }
198
- return targets
199
-
200
 
201
- def annual_energy_and_peak_targets():
202
- """Return national demand energy (TWh) and peak (GW) for scaling.
203
-
204
- FY2022: energy ≈ 866.5 TWh; peak 3-day avg ≈ 160.5 GW (JEPIC 2024).
205
- """
206
- return {"energy_TWh": 866.5, "peak_GW": 160.5}
207
 
 
 
 
 
208
 
209
- # -----------------------------
210
- # Inter-area network (operating capacities in MW)
211
- # -----------------------------
212
- def interties_operating_caps_mw():
213
- """Directional inter-area operating capacities (MW) based on OCCTO/REI.
214
 
215
- Returns
216
- -------
217
- dict[(str,str) -> float]
218
- Capacity from area i to area j in MW.
219
  """
220
- caps = {}
221
- def set_cap(a: str, b: str, mw: float):
222
- caps[(a, b)] = float(mw)
223
-
224
- # Hokkaido <-> Tohoku (HVDC): 0.6-0.9 GW; use 900 MW each way (FY2024)
225
- set_cap("Hokkaido", "Tohoku", 900.0)
226
- set_cap("Tohoku", "Hokkaido", 900.0)
227
-
228
- # Tohoku <-> Tokyo: To Tokyo 5,550 MW; To Tohoku 2,360 MW (FY2024)
229
- set_cap("Tohoku", "Tokyo", 5550.0)
230
- set_cap("Tokyo", "Tohoku", 2360.0)
231
-
232
- # Tokyo <-> Chubu (50/60Hz converters): 2,100 MW both ways (FY2024)
233
- set_cap("Tokyo", "Chubu", 2100.0)
234
- set_cap("Chubu", "Tokyo", 2100.0)
235
-
236
- # Chubu <-> Kansai: To Chubu 2,500 MW; To Kansai 1,340 MW (FY2024)
237
- set_cap("Kansai", "Chubu", 2500.0)
238
- set_cap("Chubu", "Kansai", 1340.0)
239
-
240
- # Hokuriku <-> Kansai: To Kansai 1,900 MW; To Hokuriku 1,500 MW
241
- set_cap("Hokuriku", "Kansai", 1900.0)
242
- set_cap("Kansai", "Hokuriku", 1500.0)
243
-
244
- # Hokuriku <-> Chubu: approximate 300 MW both ways
245
- set_cap("Hokuriku", "Chubu", 300.0)
246
- set_cap("Chubu", "Hokuriku", 300.0)
247
-
248
- # Kansai <-> Chugoku: To Kansai 4,550 MW; To Chugoku 2,780 MW
249
- set_cap("Chugoku", "Kansai", 4550.0)
250
- set_cap("Kansai", "Chugoku", 2780.0)
251
-
252
- # Kansai <-> Shikoku: 1,400 MW both ways
253
- set_cap("Kansai", "Shikoku", 1400.0)
254
- set_cap("Shikoku", "Kansai", 1400.0)
255
-
256
- # Chugoku <-> Shikoku: 1,200 MW both ways
257
- set_cap("Chugoku", "Shikoku", 1200.0)
258
- set_cap("Shikoku", "Chugoku", 1200.0)
259
-
260
- # Chugoku <-> Kyushu: To Chugoku 2,780 MW; To Kyushu 230 MW (Kanmon)
261
- set_cap("Kyushu", "Chugoku", 2780.0)
262
- set_cap("Chugoku", "Kyushu", 230.0)
263
-
264
- # Okinawa isolated: no ties
265
- return caps
266
-
267
-
268
- # -----------------------------
269
- # Fleet generation and calibration
270
- # -----------------------------
271
- def generate_fleet(regions_list: List[str], rng_obj: np.random.Generator,
272
- counts_cfg: Dict[Tuple[str, str], int],
273
- caps_cfg: Dict[str, Tuple[float, float]],
274
- hr_cfg: Dict[str, Tuple[float, float]],
275
- min_frac_cfg: Dict[str, Tuple[float, float]],
276
- ren_caps_cfg: Dict[str, Tuple[float, float]]):
277
- """Generate unit-level fleet with random parameters within ranges.
278
-
279
- Returns
280
- -------
281
- pandas.DataFrame
282
- ['unit_id','region','tech','fuel','cap_MW','hr_GJ_per_MWh','min_frac','is_renew','cf_key']
283
- """
284
- rows_local = []
285
- uid_local = 0
286
-
287
- # Thermal & Nuclear
288
- for r_loc in regions_list:
289
- for fuel_loc in ["lng", "coal", "oil", "nuclear"]:
290
- n_units = int(counts_cfg[(r_loc, fuel_loc)])
291
- cap_lo, cap_hi = caps_cfg[fuel_loc]
292
- hr_lo, hr_hi = hr_cfg.get(fuel_loc, (np.nan, np.nan))
293
- min_lo, min_hi = min_frac_cfg[fuel_loc]
294
- for _ in range(n_units):
295
- cap_val = rng_obj.uniform(cap_lo, cap_hi)
296
- hr_val = rng_obj.uniform(hr_lo, hr_hi) if fuel_loc != "nuclear" else np.nan
297
- min_frac_val = rng_obj.uniform(min_lo, min_hi)
298
- rows_local.append({
299
- "unit_id": f"U{uid_local}",
300
- "region": r_loc,
301
- "tech": fuel_loc,
302
- "fuel": fuel_loc,
303
- "cap_MW": float(cap_val),
304
- "hr_GJ_per_MWh": (float(hr_val) if fuel_loc != "nuclear" else np.nan),
305
- "min_frac": float(min_frac_val),
306
- "is_renew": False,
307
- "cf_key": None
308
- })
309
- uid_local += 1
310
-
311
- # Renewables
312
- for r_loc in regions_list:
313
- for ren_key, cf_key in [("solar", "solar"), ("onshore_wind", "onshore_wind"),
314
- ("offshore_wind", "offshore_wind"), ("river", "river")]:
315
- n_units = int(counts_cfg[(r_loc, ren_key)])
316
- cap_lo, cap_hi = ren_caps_cfg[ren_key]
317
- for _ in range(n_units):
318
- cap_val = rng_obj.uniform(cap_lo, cap_hi)
319
- rows_local.append({
320
- "unit_id": f"U{uid_local}",
321
- "region": r_loc,
322
- "tech": ren_key,
323
- "fuel": None,
324
- "cap_MW": float(cap_val),
325
- "hr_GJ_per_MWh": np.nan,
326
- "min_frac": 0.0,
327
- "is_renew": True,
328
- "cf_key": f"{cf_key} hourly capacity factor"
329
- })
330
- uid_local += 1
331
-
332
- return pd.DataFrame(rows_local)
333
-
334
-
335
- def calibrate_capacity_to_targets(df_units: pd.DataFrame, targets_gw: Dict[str, float]):
336
- """Scale unit capacities per technology so that totals match national targets.
337
 
338
  Parameters
339
  ----------
340
- df_units : DataFrame of units
341
- targets_gw : dict of {tech: GW}
342
-
343
- Returns
344
- -------
345
- pandas.DataFrame
346
- New DataFrame with cap_MW scaled per technology group.
347
- """
348
- df_local = df_units.copy()
349
- techs_all = ["lng","coal","oil","nuclear","river","solar","onshore_wind","offshore_wind"]
350
- for tech in techs_all:
351
- mask = (df_local["tech"] == tech)
352
- total_now = df_local.loc[mask, "cap_MW"].sum()
353
- total_target_mw = targets_gw.get(tech, 0.0) * 1000.0
354
- if total_now > 0.0 and total_target_mw > 0.0:
355
- scale = total_target_mw / total_now
356
- df_local.loc[mask, "cap_MW"] *= scale
357
- return df_local
358
-
359
-
360
- # -----------------------------
361
- # Availability (Markov outages)
362
- # -----------------------------
363
- def generate_availability(df_units: pd.DataFrame, T: int, rng_obj: np.random.Generator,
364
- for_map: Dict[str, float], mean_down_map: Dict[str, float]):
365
- """Generate unit availability A[u,t] ∈ {0,1} using a 2-state Markov chain per unit.
366
-
367
- Steady-state OFF fraction = FOR.
368
- Given mean down duration D_off, set p_off_on = 1/D_off; then p_on_off = FOR * p_off_on / (1-FOR).
369
 
370
  Returns
371
  -------
372
- dict[(int u, int t) -> int]
373
- Availability flags per unit per time.
374
  """
375
- A_local = {}
376
- for u in df_units.index:
377
- row_u = df_units.loc[u]
378
- fuel_u = row_u["fuel"] if not row_u["is_renew"] else "renew"
379
- if fuel_u == "renew":
380
- for t in range(T):
381
- A_local[(int(u), t)] = 1
382
- continue
383
- FOR = float(for_map[fuel_u])
384
- Doff = max(1.0, float(mean_down_map[fuel_u]))
385
- p_off_on = 1.0 / Doff
386
- p_on_off = (FOR * p_off_on) / max(1e-9, (1.0 - FOR))
387
- p_off_on = min(max(p_off_on, 0.0), 1.0)
388
- p_on_off = min(max(p_on_off, 0.0), 1.0)
389
- state = 0 if rng_obj.random() < (1.0 - FOR) else 1 # 0=ON, 1=OFF
390
- for t in range(T):
391
- A_local[(int(u), t)] = 1 if state == 0 else 0
392
- if state == 0:
393
- if rng_obj.random() < p_on_off:
394
- state = 1
395
- else:
396
- if rng_obj.random() < p_off_on:
397
- state = 0
398
- return A_local
399
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
 
401
- # -----------------------------
402
- # Demand scaling helpers
403
- # -----------------------------
404
- def scale_national_demand(ts_df_slice: pd.DataFrame, target_energy_TWh: float):
405
- """Scale demand CF to match national average load implied by annual energy.
406
 
407
- Parameters
408
- ----------
409
- ts_df_slice : DataFrame with 'demand hourly capacity factor'
410
- target_energy_TWh : float, e.g., 866.5 (FY2022)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
- Returns
413
- -------
414
- np.ndarray
415
- National demand time series in MW for the slice.
416
- """
417
- base_cf = ts_df_slice["demand hourly capacity factor"].values.astype(float)
418
- base_cf = np.clip(base_cf, 1e-6, None)
419
- # Average MW implied by annual energy
420
- avg_mw_target = (target_energy_TWh * 1e6) / 8760.0 # TWh -> MWh / 8760 = MW
421
- avg_cf = base_cf.mean()
422
- national_mw = base_cf / avg_cf * avg_mw_target
423
- return national_mw
424
-
425
-
426
- # -----------------------------
427
- # Network market clearing (time-varying)
428
- # -----------------------------
429
- def clear_market_network(df_units: pd.DataFrame,
430
- ts_df_slice: pd.DataFrame,
431
- regions_list: List[str],
432
- demand_shares_map: Dict[str, float],
433
- price_jpy_per_gj_ser_map: Dict[str, pd.Series],
434
- nuclear_varcost_jpy_per_mwh_ser: pd.Series,
435
- reserve_ratio_ser: pd.Series,
436
- voll_ser: pd.Series,
437
- availability_map: Dict[Tuple[int,int], int],
438
- ramp_up_frac_map: Dict[str, float],
439
- ramp_down_frac_map: Dict[str, float],
440
- intertie_caps_mw: Dict[Tuple[str,str], float],
441
- vom_adders_map: Dict[str, float],
442
- spill_penalty: float = 1e-6):
443
- """Co-optimize energy and upward reserve with inter-area transmission.
444
-
445
- Decision variables
446
- ------------------
447
- g[u,t] : Generation (MW)
448
- r[u,t] : Upward reserve (MW), only non-renew eligible
449
- shed[r,t] : Load shedding (MW), penalized by VOLL[t]
450
- spill[r,t] : Over-generation (MW), tiny penalty
451
- f[i,j,t] : Power flow on intertie i->j (MW), 0 <= f <= cap_ij
452
-
453
- Nodal energy balance (per region,t):
454
- sum_u g[u,t] + shed[r,t] + sum_j f[j->r,t] - sum_k f[r->k,t] == demand[r,t] + spill[r,t]
455
-
456
- Reserve requirement (per region,t):
457
- sum_u r[u,t] >= reserve_ratio[t] * demand[r,t] (implemented as -sum r <= -req)
458
-
459
- Unit limits (thermal/nuclear):
460
- min_frac*A*cap <= g <= A*cap; 0 <= r <= A*cap - g
461
- Renewables: 0 <= g <= CF*cap, r == 0
462
-
463
- Ramps (per unit):
464
- g(t)-g(t-1) <= RU_frac[fuel]*cap + cap*(1-A_t)
465
- g(t-1)-g(t) <= RD_frac[fuel]*cap + cap*(1-A_{t-1})
466
-
467
- Objective
468
- ---------
469
- Minimize sum_t [ sum_u (VC[u,t] * g[u,t]) + sum_r (VOLL[t]*shed + spill_penalty*spill) ]
470
- VC[u,t] = fuel_price_GJ[t] * hr_GJ_per_MWh + VOM_adder[tech]; nuclear uses nuclear_var[t]; renew=0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
- Returns
473
- -------
474
- dict with 'lmp_df', 'res_price_df', 'dispatch_df', 'flows_df'
475
- """
476
- times = ts_df_slice.index
477
- T = len(times)
478
-
479
- # Demand by area (MW) using national energy scaling
480
- energy_peak = annual_energy_and_peak_targets()
481
- national_demand_mw = scale_national_demand(ts_df_slice, energy_peak["energy_TWh"]) # MW
482
- demand = {r: national_demand_mw * float(demand_shares_map[r]) for r in regions_list}
483
-
484
- # Prepare price series JPY/GJ and others
485
- p_gj = {
486
- "lng": price_jpy_per_gj_ser_map["lng"].reindex(times).astype(float).values,
487
- "coal": price_jpy_per_gj_ser_map["coal"].reindex(times).astype(float).values,
488
- "oil": price_jpy_per_gj_ser_map["oil"].reindex(times).astype(float).values,
489
  }
490
- nuc_vc = nuclear_varcost_jpy_per_mwh_ser.reindex(times).astype(float).values
491
- rr = reserve_ratio_ser.reindex(times).astype(float).values
492
- voll = voll_ser.reindex(times).astype(float).values
493
-
494
- # Build intertie lists
495
- edges = list(intertie_caps_mw.keys())
496
- edges_by_from = {r: [] for r in regions_list}
497
- edges_by_to = {r: [] for r in regions_list}
498
- for (i_name, j_name), cap_val in intertie_caps_mw.items():
499
- edges_by_from[i_name].append((i_name, j_name))
500
- edges_by_to[j_name].append((i_name, j_name))
501
-
502
- # Model
503
- mdl = pulp.LpProblem("CoOptim_Energy_Reserve_Network", pulp.LpMinimize)
504
-
505
- # Index helpers
506
- units_by_region = {r: df_units[df_units["region"] == r].index.tolist() for r in regions_list}
507
-
508
- # Variables
509
- g = pulp.LpVariable.dicts("g", ((int(u), int(t)) for u in df_units.index for t in range(T)), lowBound=0)
510
- r_up = pulp.LpVariable.dicts("r", ((int(u), int(t)) for u in df_units.index for t in range(T)), lowBound=0)
511
- shed = pulp.LpVariable.dicts("shed", ((r, int(t)) for r in regions_list for t in range(T)), lowBound=0)
512
- spill = pulp.LpVariable.dicts("spill", ((r, int(t)) for r in regions_list for t in range(T)), lowBound=0)
513
- f = pulp.LpVariable.dicts(
514
- "f", (((i, j), int(t)) for (i, j) in edges for t in range(T)), lowBound=0
515
- )
516
 
517
- # Objective
518
- obj_terms = []
519
- for u in df_units.index:
520
- row_u = df_units.loc[u]
521
- fuel_u = row_u["fuel"] if not row_u["is_renew"] else None
522
- hr_u = float(row_u["hr_GJ_per_MWh"]) if fuel_u in ["lng","coal","oil"] else 0.0
523
- tech_u = row_u["tech"]
524
- vom_u = float(vom_adders_map.get(tech_u, 0.0))
525
- for t in range(T):
526
- if row_u["is_renew"]:
527
- # Give small variable O&M to renewables to avoid price pinning at 0
528
- vc_ut = vom_u
529
- elif fuel_u == "nuclear":
530
- vc_ut = float(nuc_vc[t]) + vom_u
531
- else:
532
- vc_ut = float(p_gj[fuel_u][t]) * hr_u + vom_u
533
- obj_terms.append(vc_ut * g[(int(u), int(t))])
534
- for r in regions_list:
535
- for t in range(T):
536
- obj_terms.append(voll[t] * shed[(r, int(t))])
537
- obj_terms.append(spill_penalty * spill[(r, int(t))])
538
- mdl += pulp.lpSum(obj_terms)
539
-
540
- # Constraints: unit limits and ramps
541
- for u in df_units.index:
542
- row_u = df_units.loc[u]
543
- cap_u = float(row_u["cap_MW"])
544
- min_frac = float(row_u["min_frac"])
545
- fuel_u = row_u["fuel"] if not row_u["is_renew"] else None
546
- for t in range(T):
547
- if row_u["is_renew"]:
548
- cf_col = row_u["cf_key"]
549
- cf_val = float(ts_df_slice.iloc[t][cf_col])
550
- mdl += g[(int(u), int(t))] <= cap_u * cf_val, f"RenCap_{u}_{t}"
551
- mdl += r_up[(int(u), int(t))] == 0.0, f"RenNoReserve_{u}_{t}"
552
- else:
553
- A_ut = float(availability_map[(int(u), t)])
554
- mdl += g[(int(u), int(t))] <= A_ut * cap_u, f"Cap_{u}_{t}"
555
- mdl += g[(int(u), int(t))] >= min_frac * A_ut * cap_u, f"MinOut_{u}_{t}"
556
- mdl += r_up[(int(u), int(t))] <= A_ut * cap_u - g[(int(u), int(t))], f"ReserveHeadroom_{u}_{t}"
557
- if t > 0:
558
- A_prev = float(availability_map[(int(u), t - 1)])
559
- RU = float(ramp_up_frac_map[fuel_u]) * cap_u
560
- RD = float(ramp_down_frac_map[fuel_u]) * cap_u
561
- mdl += (g[(int(u), int(t))] - g[(int(u), int(t - 1))]
562
- <= RU + cap_u * (1.0 - A_ut)), f"RampUp_{u}_{t}"
563
- mdl += (g[(int(u), int(t - 1))] - g[(int(u), int(t))]
564
- <= RD + cap_u * (1.0 - A_prev)), f"RampDn_{u}_{t}"
565
-
566
- # Intertie capacity constraints
567
- for (i_name, j_name), cap_val in intertie_caps_mw.items():
568
- for t in range(T):
569
- mdl += f[((i_name, j_name), int(t))] <= float(cap_val), f"TieCap_{i_name}_{j_name}_{t}"
570
-
571
- # Energy balance & reserves (per area,t)
572
- e_names, r_names = {}, {}
573
- for r_name in regions_list:
574
- uidx = units_by_region[r_name]
575
- for t in range(T):
576
- inflow = pulp.lpSum([f[((i, r_name), int(t))] for (i, r) in edges_by_to[r_name]])
577
- outflow = pulp.lpSum([f[((r_name, j), int(t))] for (r_name2, j) in edges_by_from[r_name]])
578
- cname = f"EnergyBal_{r_name}_{t}"
579
- mdl += (pulp.lpSum([g[(int(u), int(t))] for u in uidx]) + shed[(r_name, int(t))] + inflow
580
- == float(demand[r_name][t]) + spill[(r_name, int(t))] + outflow), cname
581
- e_names[(r_name, t)] = cname
582
- req = rr[t] * float(demand[r_name][t])
583
- rname = f"ReserveReq_{r_name}_{t}"
584
- mdl += (-pulp.lpSum([r_up[(int(u), int(t))] for u in uidx]) <= -req), rname
585
- r_names[(r_name, t)] = rname
586
 
587
- # Solve
588
- solver = pulp.PULP_CBC_CMD(msg=False)
589
- mdl.solve(solver)
590
-
591
- # Prices
592
- lmp = np.zeros((T, len(regions_list)))
593
- rsp = np.zeros((T, len(regions_list)))
594
- for j, r_name in enumerate(regions_list):
595
- for t in range(T):
596
- lmp[t, j] = mdl.constraints[e_names[(r_name, t)]].pi
597
- rsp[t, j] = mdl.constraints[r_names[(r_name, t)]].pi
598
- lmp_df = pd.DataFrame(lmp, index=times, columns=regions_list)
599
- res_df = pd.DataFrame(rsp, index=times, columns=regions_list)
600
-
601
- # Dispatch and flows
602
- disp_rows = []
603
- for u in df_units.index:
604
- row_u = df_units.loc[u]
605
- for t in range(T):
606
- disp_rows.append({
607
- "Time": times[t],
608
- "unit_id": row_u["unit_id"],
609
- "region": row_u["region"],
610
- "tech": row_u["tech"],
611
- "g_MW": g[(int(u), int(t))].value(),
612
- "r_up_MW": r_up[(int(u), int(t))].value()
613
- })
614
- dispatch_df = pd.DataFrame(disp_rows)
615
-
616
- flow_rows = []
617
- for (i_name, j_name), cap_val in intertie_caps_mw.items():
618
- for t in range(T):
619
- flow_rows.append({
620
- "Time": times[t],
621
- "from": i_name,
622
- "to": j_name,
623
- "flow_MW": f[((i_name, j_name), int(t))].value(),
624
- "cap_MW": float(cap_val)
625
- })
626
- flows_df = pd.DataFrame(flow_rows)
627
-
628
- # Build spill/shed DataFrames
629
- spill_mat = np.zeros((T, len(regions_list)))
630
- shed_mat = np.zeros((T, len(regions_list)))
631
- for j, r_name in enumerate(regions_list):
632
- for t in range(T):
633
- spill_mat[t, j] = spill[(r_name, int(t))].value()
634
- shed_mat[t, j] = shed[(r_name, int(t))].value()
635
- spill_df = pd.DataFrame(spill_mat, index=times, columns=regions_list)
636
- shed_df = pd.DataFrame(shed_mat, index=times, columns=regions_list)
637
-
638
- # Demand dataframe (MW)
639
- demand_df = pd.DataFrame({r: demand[r] for r in regions_list}, index=times)
640
-
641
- return {"lmp_df": lmp_df, "res_price_df": res_df, "dispatch_df": dispatch_df, "flows_df": flows_df, "spill_df": spill_df, "shed_df": shed_df, "demand_df": demand_df}
642
-
643
-
644
- # -----------------------------
645
- # Streamlit App
646
- # -----------------------------
647
- st.set_page_config(page_title="JP Market (10 areas) — Network & National Scale", layout="wide")
648
- st.title("日本10エリア 同時市場シミュレーション(実務スケール+連系制約)")
649
-
650
- # Load CF/demand profiles
651
- _ts_df = load_timeseries()
652
-
653
- # Regions
654
- regions = [
655
- "Hokkaido","Tohoku","Tokyo","Chubu","Hokuriku",
656
- "Kansai","Chugoku","Shikoku","Kyushu","Okinawa"
657
- ]
658
 
659
  with st.sidebar:
660
- st.header("ラン・時間範囲")
661
- seed_val = st.number_input("Random seed", value=11, step=1)
662
- max_hours = min(168, len(_ts_df))
663
- hours_val = st.slider("Hours to simulate", min_value=24, max_value=max_hours, value=min(48, max_hours), step=24)
664
- start_idx = st.slider("Start index", min_value=0, max_value=max(0, len(_ts_df)-hours_val), value=0, step=1)
665
- ts_slice = _ts_df.iloc[start_idx:start_idx+hours_val]
666
-
667
- st.header("需要配分(∑=1.0)")
668
- share_df = st.data_editor(pd.DataFrame({
669
- "region": regions,
670
- "share": [0.03,0.09,0.32,0.14,0.03,0.17,0.07,0.03,0.11,0.01]
671
- }), num_rows="fixed", use_container_width=True)
672
- ssum = float(share_df["share"].sum())
673
- if abs(ssum-1.0) > 1e-9:
674
- st.warning(f"需要配分の合計が {ssum:.3f}、1.0 に正規化します。")
675
- demand_shares = {row["region"]: float(row["share"])/ssum for _,row in share_df.iterrows()}
676
-
677
- st.header("時間変動・外部CSV(任意)")
678
- uploaded = st.file_uploader("Time-varying overrides CSV(任意)", type=["csv"])
679
-
680
- st.header("スカラー既定(CSV欠損の補完に使用)")
681
- usd_jpy0 = st.number_input("USD/JPY (scalar)", value=148.21, step=0.5)
682
- lng_px0 = st.number_input("LNG (USD/MMBtu, scalar)", value=11.27, step=0.1)
683
- coal_px0 = st.number_input("Coal (USD/ton, scalar)", value=130.0, step=1.0)
684
- oil_px0 = st.number_input("Oil (USD/bbl, scalar)", value=80.0, step=1.0)
685
- nuc_vc0 = st.number_input("Nuclear var cost (JPY/MWh, scalar)", value=2300.0, step=100.0)
686
- rr0 = st.number_input("Reserve ratio (scalar)", value=0.03, step=0.005, min_value=0.0, max_value=0.2, format="%.3f")
687
- voll0 = st.number_input("VOLL (JPY/MWh, scalar)", value=300000.0, step=10000.0)
688
- spill_penalty_ui = st.number_input("Spill penalty (JPY/MWh)", value=1.0, step=1.0, min_value=0.0)
689
-
690
- st.header("VOM adder [JPY/MWh]")
691
- vom_df = st.data_editor(pd.DataFrame({
692
- "tech": ["lng","coal","oil","nuclear","solar","onshore_wind","offshore_wind","river"],
693
- "VOM": [400.0, 600.0, 1000.0, 800.0, 50.0, 80.0, 120.0, 30.0]
694
- }), use_container_width=True)
695
-
696
- st.header("ユニット数(各エリア)")
697
- units_df = st.data_editor(pd.DataFrame({
698
- "region": regions,
699
- "lng_units":[6,10,25,10,4,20,8,4,10,0],
700
- "coal_units":[3,6,10,6,3,10,5,2,7,0],
701
- "oil_units":[2,3,6,3,2,5,3,2,3,0],
702
- "nuc_units":[0,2,4,2,0,3,1,0,2,0],
703
- "solar_units":[20,30,60,30,12,40,20,12,30,0],
704
- "on_units":[10,15,25,15,6,20,10,6,15,0],
705
- "off_units":[2,3,6,3,1,4,2,1,3,0],
706
- "river_units":[5,8,12,8,4,12,6,3,8,0]
707
- }), use_container_width=True)
708
-
709
- st.header("容量レンジ [MW/ユニット]")
710
- cap_bounds = {
711
- "lng": (200.0, 900.0), "coal": (300.0, 1000.0), "oil": (100.0, 700.0), "nuclear": (500.0, 1400.0)
712
- }
713
- ren_bounds = {
714
- "solar": (10.0, 200.0), "onshore_wind": (20.0, 300.0),
715
- "offshore_wind": (100.0, 600.0), "river": (10.0, 200.0)
716
- }
717
 
718
- st.header("熱率レンジ(GJ/MWh, 乱数生成)")
719
- hr_bounds = {
720
- "lng": (6.2, 6.9), "coal": (7.8, 9.0), "oil": (8.8, 10.0), "nuclear": (np.nan, np.nan)
721
- }
722
 
723
- st.header("最低出力(比率レンジ, 乱数生成)")
724
- minout_cfg = {"lng": (0.0, 0.2), "coal": (0.3, 0.6), "oil": (0.0, 0.4), "nuclear": (0.6, 0.9)}
725
-
726
- st.header("可用性(FOR, 平均停止時間[h])")
727
- for_map = st.data_editor(pd.DataFrame({
728
- "fuel": ["lng","coal","oil","nuclear"], "FOR":[0.06,0.08,0.10,0.04], "mean_down_h":[24,48,24,120]
729
- }), use_container_width=True)
730
-
731
- st.header("ランプ比率(capの何倍/時)")
732
- ramp_df = st.data_editor(pd.DataFrame({
733
- "fuel": ["lng","coal","oil","nuclear"], "RU_frac":[0.50,0.20,0.30,0.05], "RD_frac":[0.50,0.20,0.30,0.05]
734
- }), use_container_width=True)
735
-
736
- # Build config dicts
737
- counts_cfg = {}
738
- for _, row in units_df.iterrows():
739
- rname = row["region"]
740
- counts_cfg[(rname, "lng")] = int(row["lng_units"])
741
- counts_cfg[(rname, "coal")] = int(row["coal_units"])
742
- counts_cfg[(rname, "oil")] = int(row["oil_units"])
743
- counts_cfg[(rname, "nuclear")] = int(row["nuc_units"])
744
- counts_cfg[(rname, "solar")] = int(row["solar_units"])
745
- counts_cfg[(rname, "onshore_wind")] = int(row["on_units"])
746
- counts_cfg[(rname, "offshore_wind")] = int(row["off_units"])
747
- counts_cfg[(rname, "river")] = int(row["river_units"])
748
-
749
- ramp_up_frac_map = {row["fuel"]: float(row["RU_frac"]) for _, row in ramp_df.iterrows()}
750
- ramp_down_frac_map = {row["fuel"]: float(row["RD_frac"]) for _, row in ramp_df.iterrows()}
751
- for_map_dict = {row["fuel"]: float(row["FOR"]) for _, row in for_map.iterrows()}
752
- mean_down_map = {row["fuel"]: float(row["mean_down_h"]) for _, row in for_map.iterrows()}
753
-
754
- # Time-varying series (override CSV or synthesized)
755
- if uploaded is not None:
756
- ov = load_overrides_csv(uploaded, ts_slice.index)
757
- usd_jpy_s = ov.get("usd_jpy", pd.Series(usd_jpy0, index=ts_slice.index))
758
- lng_s = ov.get("lng_usd_per_mmbtu", pd.Series(lng_px0, index=ts_slice.index))
759
- coal_s = ov.get("coal_usd_per_ton", pd.Series(coal_px0, index=ts_slice.index))
760
- oil_s = ov.get("oil_usd_per_bbl", pd.Series(oil_px0, index=ts_slice.index))
761
- nuc_s = ov.get("nuclear_var_jpy_per_mwh", pd.Series(nuc_vc0, index=ts_slice.index))
762
- rr_s = ov.get("reserve_ratio", pd.Series(rr0, index=ts_slice.index))
763
- voll_s = ov.get("voll", pd.Series(voll0, index=ts_slice.index))
764
- else:
765
- idx = ts_slice.index
766
- h = idx.hour.to_numpy(); d = idx.dayofyear.to_numpy()
767
- usd_jpy_s = pd.Series(usd_jpy0, index=idx) # flat unless CSV given
768
- lng_s = pd.Series(lng_px0 * (1.0 + 0.03*np.sin(2*np.pi*d/7)), index=idx)
769
- coal_s = pd.Series(coal_px0 * (1.0 + 0.02*np.cos(2*np.pi*d/14)), index=idx)
770
- oil_s = pd.Series(oil_px0 * (1.0 + 0.03*np.sin(2*np.pi*d/30+0.5)), index=idx)
771
- nuc_s = pd.Series(nuc_vc0, index=idx)
772
- rr_base = rr0 + 0.02*((h>=18)&(h<=21)).astype(float) + 0.01*((h>=8)&(h<=10)).astype(float)
773
- rr_s = pd.Series(np.clip(rr_base, 0.0, 0.20), index=idx)
774
- voll_s = pd.Series(voll0, index=idx)
775
-
776
- # Price series to JPY/GJ
777
- price_gj_map = jpy_per_gj_series(usd_jpy_s, lng_s, coal_s, oil_s)
778
-
779
- # Interties
780
- intertie_caps = interties_operating_caps_mw()
781
-
782
- # VOM adders
783
- vom_adders_map = {row["tech"]: float(row["VOM"]) for _, row in vom_df.iterrows()}
784
-
785
- if st.button("シミュレーション実行(ネットワーク&実務スケール)"):
786
- rng = np.random.default_rng(int(seed_val))
787
- fleet_df = generate_fleet(regions, rng, counts_cfg, cap_bounds, hr_bounds, minout_cfg, ren_bounds)
788
- # Calibrate to national installed capacity by fuel (FY2022)
789
- fleet_df = calibrate_capacity_to_targets(fleet_df, capacity_targets_gw())
790
-
791
- # Availability paths
792
- A_map = generate_availability(fleet_df, len(ts_slice), rng, for_map=for_map_dict, mean_down_map=mean_down_map)
793
-
794
- res = clear_market_network(
795
- df_units=fleet_df,
796
- ts_df_slice=ts_slice,
797
- regions_list=regions,
798
- demand_shares_map=demand_shares,
799
- price_jpy_per_gj_ser_map=price_gj_map,
800
- nuclear_varcost_jpy_per_mwh_ser=nuc_s,
801
- reserve_ratio_ser=rr_s,
802
- voll_ser=voll_s,
803
- availability_map=A_map,
804
- ramp_up_frac_map=ramp_up_frac_map,
805
- ramp_down_frac_map=ramp_down_frac_map,
806
- intertie_caps_mw=intertie_caps,
807
- vom_adders_map=vom_adders_map,
808
- spill_penalty=spill_penalty_ui
809
- )
810
 
811
- st.subheader("LMP(JPY/MWh)")
812
- st.plotly_chart(px.line(res["lmp_df"], x=res["lmp_df"].index, y=res["lmp_df"].columns,
813
- title="Area LMP (JPY/MWh)"), use_container_width=True)
814
-
815
- st.subheader("予備力価格(JPY/MW)")
816
- st.plotly_chart(px.line(res["res_price_df"], x=res["res_price_df"].index, y=res["res_price_df"].columns,
817
- title="Area Reserve Price (JPY/MW)"), use_container_width=True)
818
-
819
- # National dispatch stack
820
- disp = res["dispatch_df"]
821
- stack = disp.groupby(["Time","tech"], as_index=False)["g_MW"].sum()
822
- fig_stack = go.Figure()
823
- for tech_name in ["solar","onshore_wind","offshore_wind","river","nuclear","coal","lng","oil"]:
824
- if tech_name in stack["tech"].unique():
825
- sub = stack[stack["tech"]==tech_name]
826
- fig_stack.add_trace(go.Scatter(x=sub["Time"], y=sub["g_MW"], mode="lines",
827
- stackgroup="one", name=tech_name))
828
- fig_stack.update_layout(title="全国発電スタック(合算)", yaxis_title="MW")
829
- st.plotly_chart(fig_stack, use_container_width=True)
830
-
831
- # Intertie flow heatmap (average)
832
- flows = res["flows_df"]
833
- avg_flow = flows.groupby(["from","to"], as_index=False)["flow_MW"].mean()
834
- st.subheader("平均連系潮流(MW)")
835
- st.dataframe(avg_flow)
836
-
837
- # Diagnostics: 低LMPの原因解析
838
- st.subheader("Diagnostics: LMPが極端に低い/ゼロになる要因")
839
- spill_df = res["spill_df"]; shed_df = res["shed_df"]; lmp_df = res["lmp_df"]
840
- area = st.selectbox("エリア選択(診断)", regions, index=2)
841
-
842
- diag_df = pd.DataFrame({
843
- "LMP": lmp_df[area],
844
- "Spill_MW": spill_df[area],
845
- "Shed_MW": shed_df[area]
846
- })
847
- st.plotly_chart(px.scatter(diag_df, x="Spill_MW", y="LMP", title=f"Spill vs LMP — {area}"), use_container_width=True)
848
- st.plotly_chart(px.scatter(diag_df, x="Shed_MW", y="LMP", title=f"Shed vs LMP — {area}"), use_container_width=True)
849
-
850
- # Renewables slack vs LMP
851
- disp = res["dispatch_df"]
852
- units_ren = fleet_df[fleet_df["is_renew"]]
853
- disp_merge = disp.merge(units_ren[["unit_id","region","tech","cap_MW","cf_key"]], on=["unit_id","region"], how="inner")
854
- def _cf_lookup(r):
855
- try:
856
- return float(ts_slice.loc[r["Time"], r["cf_key"]])
857
- except Exception:
858
- return 0.0
859
- disp_merge["cf"] = disp_merge.apply(_cf_lookup, axis=1)
860
- disp_merge["avail"] = disp_merge["cap_MW"] * disp_merge["cf"]
861
- disp_merge["slack"] = (disp_merge["avail"] - disp_merge["g_MW"]).clip(lower=0.0)
862
- slack = disp_merge.groupby(["Time","region"], as_index=False)["slack"].sum().pivot(index="Time", columns="region", values="slack").reindex(columns=regions)
863
- st.plotly_chart(px.scatter(x=slack[area], y=lmp_df[area], labels={"x":"Renewable Slack (MW)", "y":"LMP"}, title=f"Renewable Slack vs LMP — {area}"), use_container_width=True)
864
-
865
- # Metrics
866
- eps = 1e-3
867
- spill_hours = int((spill_df[area] > 1.0).sum())
868
- near_spill_price_hours = int((((lmp_df[area] - spill_penalty_ui).abs() < eps) & (spill_df[area] > 1.0)).sum())
869
- st.info(f"{area}: spill>1MW 時間数 = {spill_hours} / {len(spill_df)}、 かつ |LMP - spill_penalty|< {eps} の時間数 = {near_spill_price_hours}")
870
-
871
- # --- 同時市場の入札結果(kWh と δkW)
872
- st.subheader("同時市場の入札結果(kWh と δkW)")
873
- area_v = st.selectbox("エリア選択(入札可視化)", regions, index=2, key="bids_area")
874
-
875
- disp_all = res["dispatch_df"]
876
- flows_all = res["flows_df"]
877
- demand_all = res["demand_df"]
878
-
879
- # Energy awards by tech (MWh per hour = MW)
880
- gen_area = disp_all[disp_all["region"]==area_v].groupby(["Time","tech"], as_index=False)["g_MW"].sum()
881
- fig_e = go.Figure()
882
- for tech_name in sorted(gen_area["tech"].unique()):
883
- sub = gen_area[gen_area["tech"]==tech_name]
884
- fig_e.add_trace(go.Scatter(x=sub["Time"], y=sub["g_MW"], mode="lines", stackgroup="one", name=tech_name))
885
- # Demand and net imports
886
- infl = flows_all[flows_all["to"]==area_v].groupby("Time", as_index=False)["flow_MW"].sum().rename(columns={"flow_MW":"inflow"})
887
- outf = flows_all[flows_all["from"]==area_v].groupby("Time", as_index=False)["flow_MW"].sum().rename(columns={"flow_MW":"outflow"})
888
- net = pd.merge(infl, outf, how="outer", on="Time").fillna(0.0)
889
- net["net_imports"] = net["inflow"] - net["outflow"]
890
- demand_ser = demand_all[area_v]
891
- fig_e.add_trace(go.Scatter(x=demand_ser.index, y=demand_ser.values, mode="lines", name="Demand (MW)", line=dict(width=2)))
892
- fig_e.add_trace(go.Scatter(x=net["Time"], y=net["net_imports"], mode="lines", name="Net imports (MW)"))
893
- fig_e.update_layout(title=f"{area_v} — エネルギー入札(kWh≈MWh/h)と需要・純輸入", yaxis_title="MW")
894
- st.plotly_chart(fig_e, use_container_width=True)
895
-
896
- # Reserve awards by tech (delta-kW = MW)
897
- res_area = disp_all[disp_all["region"]==area_v].groupby(["Time","tech"], as_index=False)["r_up_MW"].sum()
898
- res_area = res_area[res_area["r_up_MW"]>0]
899
- fig_r = go.Figure()
900
- if len(res_area):
901
- for tech_name in sorted(res_area["tech"].unique()):
902
- sub = res_area[res_area["tech"]==tech_name]
903
- fig_r.add_trace(go.Scatter(x=sub["Time"], y=sub["r_up_MW"], mode="lines", stackgroup="one", name=tech_name))
904
- fig_r.update_layout(title=f"{area_v} — δkW(上方予備力)入札", yaxis_title="MW")
905
- st.plotly_chart(fig_r, use_container_width=True)
906
-
907
- # Supply-demand balance check
908
- gen_tot = gen_area.groupby("Time", as_index=False)["g_MW"].sum().rename(columns={"g_MW":"gen_total"})
909
- bal = pd.DataFrame({"Time": demand_ser.index})
910
- bal = bal.merge(gen_tot, on="Time", how="left").merge(net[["Time","net_imports"]], on="Time", how="left")
911
- bal["demand"] = demand_ser.values
912
- bal = bal.fillna(0.0)
913
- bal["gen_plus_net"] = bal["gen_total"] + bal["net_imports"]
914
- bal["balance"] = bal["gen_plus_net"] - bal["demand"]
915
- spill_area = res["spill_df"][area_v].reindex(bal["Time"]).values
916
- shed_area = res["shed_df"][area_v].reindex(bal["Time"]).values
917
-
918
- fig_bal = go.Figure()
919
- fig_bal.add_trace(go.Scatter(x=bal["Time"], y=bal["demand"], mode="lines", name="Demand"))
920
- fig_bal.add_trace(go.Scatter(x=bal["Time"], y=bal["gen_total"], mode="lines", name="Generation (local)"))
921
- fig_bal.add_trace(go.Scatter(x=bal["Time"], y=bal["gen_plus_net"], mode="lines", name="Gen + Net imports"))
922
- fig_bal.add_trace(go.Scatter(x=bal["Time"], y=spill_area, mode="lines", name="Spill (MW)"))
923
- fig_bal.add_trace(go.Scatter(x=bal["Time"], y=shed_area, mode="lines", name="Shed (MW)"))
924
- fig_bal.update_layout(title=f"{area_v} — 需給バランス(検算)", yaxis_title="MW")
925
- st.plotly_chart(fig_bal, use_container_width=True)
926
-
927
- err = float(np.max(np.abs(bal["balance"].to_numpy() - (spill_area - shed_area)))) if len(bal) else 0.0
928
- st.caption(f"最大バランス誤差 |(Gen+Net)-Demand - (Spill-Shed)| = {err:.6f} MW (数値誤差目安)")
929
 
 
 
1
+ # app.py
2
+ import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import pandas as pd
4
  import pulp
 
5
  import plotly.graph_objs as go
6
+ import plotly.express as px
7
+ import numpy as np
8
+ import json
9
+ from copy import deepcopy
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ # ------------------------------
12
+ # Data loader
13
+ # ------------------------------
14
+ def get_json():
 
15
  """
16
+ Open ./data.json and return a tidy DataFrame with columns:
17
+ Time, {carrier} hourly capacity factor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  Returns
19
  -------
20
+ pd.DataFrame
 
 
 
 
21
  """
22
+ with open('data.json', encoding='utf-8') as f:
23
+ data = json.load(f)
24
+ if not data:
25
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ base_times = data[next(iter(data))]['x']
28
+ result_df = pd.DataFrame({"Time": base_times})
 
 
 
 
29
 
30
+ for energy_type, energy_data in data.items():
31
+ if 'x' in energy_data and 'y' in energy_data:
32
+ values = energy_data['y']
33
+ result_df[f"{energy_type} hourly capacity factor"] = pd.to_numeric(values, errors='coerce')
34
 
35
+ result_df = result_df.fillna(0)
36
+ return result_df
 
 
 
37
 
38
+ # ------------------------------
39
+ # Core LP builder/solver
40
+ # ------------------------------
41
+ def build_and_solve_lp(params, data_df):
42
  """
43
+ Build and solve the single-region LP with electricity, H2 (P2X), CH4 (methanation),
44
+ battery storage, and H2 storage. Returns solved objects & key series.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  Parameters
47
  ----------
48
+ params : dict
49
+ Model parameters incl. costs, ranges, efficiencies, yearly_demand_TWh, etc.
50
+ data_df : pd.DataFrame
51
+ DataFrame from get_json().
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  Returns
54
  -------
55
+ dict
56
+ Results including dispatch time series, capacities, SOCs, and figures.
57
  """
58
+ # Aliases
59
+ time = data_df['Time']
60
+ T = list(range(len(time)))
61
+
62
+ # Capacity factors (0-1)
63
+ solar_cf = data_df['solar hourly capacity factor'].values
64
+ on_wind_cf = data_df['onshore_wind hourly capacity factor'].values
65
+ off_wind_cf= data_df['offshore_wind hourly capacity factor'].values
66
+ river_cf = data_df['river hourly capacity factor'].values
67
+ demand_cf = data_df['demand hourly capacity factor'].values
68
+
69
+ # Scale demand to absolute power [MW]
70
+ # yearly_demand_TWh -> hourly demand profile so that sum(hourly)/1000 ≈ yearly_TWh
71
+ yearly_TWh = float(params['yearly_demand_TWh'])
72
+ # Normalize demand_cf to sum=1 over the year (avoid bias if not normalized)
73
+ demand_cf_norm = np.array(demand_cf, dtype=float)
74
+ demand_cf_norm = demand_cf_norm / max(demand_cf_norm.sum(), 1e-12)
75
+ total_MWh_year = yearly_TWh * 1e6 # [MWh]
76
+ demand_series_MW = total_MWh_year * demand_cf_norm # [MWh per hour]
77
+ # MWh per hour equals MW (power) dispatched in that hour under unit-hour timestep
78
+ demand_MW = demand_series_MW
79
+
80
+ # LP model
81
+ m = pulp.LpProblem("RE_P2X_Optimization", pulp.LpMinimize)
82
+
83
+ # ---------------- capacities ----------------
84
+ cap_solar = pulp.LpVariable("cap_solar", lowBound=params['solar_range'][0], upBound=params['solar_range'][1])
85
+ cap_won = pulp.LpVariable("cap_onshore_wind", lowBound=params['wind_range'][0], upBound=params['wind_range'][1])
86
+ cap_woff = pulp.LpVariable("cap_offshore_wind", lowBound=params['offshore_wind_range'][0], upBound=params['offshore_wind_range'][1])
87
+ cap_riv = pulp.LpVariable("cap_river", lowBound=params['river_range'][0], upBound=params['river_range'][1])
88
+
89
+ cap_batt = pulp.LpVariable("cap_batt_MWh", lowBound=0) # energy capacity [MWh]
90
+ p_batt = pulp.LpVariable("cap_batt_P_MW", lowBound=0) # charge/discharge power cap [MW] (simple)
91
+
92
+ cap_elec = pulp.LpVariable("cap_electrolyser_MW", lowBound=0)
93
+ cap_h2st = pulp.LpVariable("cap_H2_store_MWh", lowBound=0)
94
+ cap_meth = pulp.LpVariable("cap_methanation_MW_H2in", lowBound=0)
95
+ cap_fc = pulp.LpVariable("cap_fuelcell_MW", lowBound=0) # optional H2->power
96
+
97
+ # ---------------- hourly variables ----------------
98
+ # Renewable generation [MW]
99
+ g_solar = pulp.LpVariable.dicts("g_solar", T, lowBound=0)
100
+ g_won = pulp.LpVariable.dicts("g_onshore", T, lowBound=0)
101
+ g_woff = pulp.LpVariable.dicts("g_offshore", T, lowBound=0)
102
+ g_riv = pulp.LpVariable.dicts("g_river", T, lowBound=0)
103
+
104
+ # Battery operation [MW], SOC [MWh]
105
+ ch_batt = pulp.LpVariable.dicts("batt_charge", T, lowBound=0)
106
+ dis_batt= pulp.LpVariable.dicts("batt_discharge", T, lowBound=0)
107
+ soc_batt= pulp.LpVariable.dicts("batt_soc", T, lowBound=0)
108
+
109
+ # Electrolyser consumption [MW_el], H2 production [MW_H2-LHV]
110
+ p_elec = pulp.LpVariable.dicts("elec_load_electrolyser", T, lowBound=0)
111
+ h2_prod = pulp.LpVariable.dicts("h2_prod", T, lowBound=0)
112
+
113
+ # H2 storage [MWh_H2-LHV], in/out [MW_H2]
114
+ ch_h2 = pulp.LpVariable.dicts("h2_charge", T, lowBound=0)
115
+ dis_h2 = pulp.LpVariable.dicts("h2_discharge", T, lowBound=0)
116
+ soc_h2 = pulp.LpVariable.dicts("h2_soc", T, lowBound=0)
117
+
118
+ # Methanation: H2 in [MW_H2], CH4 out [MW_CH4-LHV] (for記録のみ)
119
+ h2_to_ch4 = pulp.LpVariable.dicts("h2_to_ch4", T, lowBound=0)
120
+ ch4_prod = pulp.LpVariable.dicts("ch4_prod", T, lowBound=0)
121
+
122
+ # Fuel cell H2->power [MW_el]
123
+ p_fc = pulp.LpVariable.dicts("fuelcell_power", T, lowBound=0)
124
+
125
+ # Curtailment [MW]
126
+ curt = pulp.LpVariable.dicts("curtailment", T, lowBound=0)
127
+
128
+ # ---------------- objective ----------------
129
+ cost = (
130
+ cap_solar * params['cost_solar_per_MW']
131
+ + cap_won * params['cost_onshore_wind_per_MW']
132
+ + cap_woff * params['cost_offshore_wind_per_MW']
133
+ + cap_riv * params['cost_river_per_MW']
134
+ + cap_batt * params['cost_batt_per_MWh']
135
+ + p_batt * params['cost_batt_power_per_MW']
136
+ + cap_elec * params['cost_electrolyser_per_MW']
137
+ + cap_h2st * params['cost_h2_store_per_MWh']
138
+ + cap_meth * params['cost_methanation_per_MW_H2in']
139
+ + cap_fc * params['cost_fuelcell_per_MW']
140
+ )
141
+ m += cost
142
+
143
+ # ---------------- constraints ----------------
144
+ eta_batt_c = params['eta_batt_charge']
145
+ eta_batt_d = params['eta_batt_discharge']
146
+ eta_elec = params['eta_electrolyser'] # MW_el -> MW_H2 (LHV)
147
+ eta_meth = params['eta_methanation'] # MW_H2 -> MW_CH4 (LHV)
148
+ eta_fc = params['eta_fuelcell'] # MW_H2 -> MW_el
149
+
150
+ # Renewable availability
151
+ for t in T:
152
+ m += g_solar[t] <= cap_solar * solar_cf[t]
153
+ m += g_won[t] <= cap_won * on_wind_cf[t]
154
+ m += g_woff[t] <= cap_woff * off_wind_cf[t]
155
+ m += g_riv[t] <= cap_riv * river_cf[t]
156
+
157
+ # Battery power limits
158
+ m += ch_batt[t] <= p_batt
159
+ m += dis_batt[t] <= p_batt
160
+
161
+ # Electrolyser & fuel cell throughput limits
162
+ m += p_elec[t] <= cap_elec
163
+ m += p_fc[t] <= cap_fc
164
+
165
+ # Methanation H2-in limit
166
+ m += h2_to_ch4[t] <= cap_meth
167
+
168
+ # H2 storage power bounds (simple: no separate power cap)
169
+ # could be left unconstrained besides energy capacity
170
+
171
+ # Battery SOC dynamics
172
+ for t in T:
173
+ if t == 0:
174
+ m += soc_batt[t] == ch_batt[t]*eta_batt_c - dis_batt[t]/max(eta_batt_d,1e-12)
175
+ else:
176
+ m += soc_batt[t] == soc_batt[t-1] + ch_batt[t]*eta_batt_c - dis_batt[t]/max(eta_batt_d,1e-12)
177
+ m += soc_batt[t] <= cap_batt
178
+
179
+ # Electrolyser production and H2 SOC dynamics
180
+ for t in T:
181
+ # H2 production from electrolyser
182
+ m += h2_prod[t] == p_elec[t] * eta_elec
183
+ # Methanation output tracking (for reporting)
184
+ m += ch4_prod[t] == h2_to_ch4[t] * eta_meth
185
+
186
+ if t == 0:
187
+ m += soc_h2[t] == (h2_prod[t] + ch_h2[t]) - (dis_h2[t] + h2_to_ch4[t] + p_fc[t]/max(eta_fc,1e-12))
188
+ else:
189
+ m += soc_h2[t] == soc_h2[t-1] + (h2_prod[t] + ch_h2[t]) - (dis_h2[t] + h2_to_ch4[t] + p_fc[t]/max(eta_fc,1e-12))
190
+ m += soc_h2[t] <= cap_h2st
191
 
192
+ # Electric power balance each hour
193
+ for t in T:
194
+ supply_el = g_solar[t] + g_won[t] + g_woff[t] + g_riv[t] + p_fc[t]
195
+ demand_el = demand_MW[t] + ch_batt[t] + p_elec[t]
196
+ m += supply_el + dis_batt[t] == demand_el + curt[t]
197
 
198
+ # Solve
199
+ _ = m.solve(pulp.PULP_CBC_CMD(msg=False))
200
+
201
+ # Extract results
202
+ series = {}
203
+ series['supply_solar'] = np.array([pulp.value(g_solar[t]) for t in T])
204
+ series['supply_onshore'] = np.array([pulp.value(g_won[t]) for t in T])
205
+ series['supply_offshore']= np.array([pulp.value(g_woff[t]) for t in T])
206
+ series['supply_river'] = np.array([pulp.value(g_riv[t]) for t in T])
207
+ series['batt_dis'] = np.array([pulp.value(dis_batt[t]) for t in T])
208
+ series['batt_ch'] = -np.array([pulp.value(ch_batt[t]) for t in T])
209
+ series['soc_batt'] = np.array([pulp.value(soc_batt[t]) for t in T])
210
+ series['curtail'] = -np.array([pulp.value(curt[t]) for t in T])
211
+ series['elec_load_elz'] = np.array([pulp.value(p_elec[t]) for t in T])
212
+ series['h2_prod'] = np.array([pulp.value(h2_prod[t]) for t in T])
213
+ series['soc_h2'] = np.array([pulp.value(soc_h2[t]) for t in T])
214
+ series['h2_to_ch4'] = np.array([pulp.value(h2_to_ch4[t]) for t in T])
215
+ series['ch4_prod'] = np.array([pulp.value(ch4_prod[t]) for t in T])
216
+ series['p_fc'] = np.array([pulp.value(p_fc[t]) for t in T])
217
+ series['demand'] = demand_MW
218
+
219
+ caps = {
220
+ 'solar': pulp.value(cap_solar),
221
+ 'onshore_wind': pulp.value(cap_won),
222
+ 'offshore_wind': pulp.value(cap_woff),
223
+ 'river': pulp.value(cap_riv),
224
+ 'battery_MWh': pulp.value(cap_batt),
225
+ 'battery_P_MW': pulp.value(p_batt),
226
+ 'electrolyser_MW': pulp.value(cap_elec),
227
+ 'H2_store_MWh': pulp.value(cap_h2st),
228
+ 'methanation_MW_H2in': pulp.value(cap_meth),
229
+ 'fuelcell_MW': pulp.value(cap_fc),
230
+ }
231
 
232
+ # Approximate hourly electricity LMP via epsilon-perturbation (Δdemand = +1 MWh)
233
+ # Re-solve per hour with only that hour's demand bumped by +1 MWh
234
+ # Note: keeps it robust under CBC (no duals)
235
+ eps_price = np.zeros(len(T))
236
+ base_obj = pulp.value(m.objective)
237
+ for t_bump in T:
238
+ # Clone model shallowly is not supported; rebuild quick variant:
239
+ params2 = deepcopy(params)
240
+ demand_eps = demand_MW.copy()
241
+ demand_eps[t_bump] += 1.0 # +1 MWh at hour t_bump
242
+
243
+ m2 = pulp.LpProblem("LMP_probe", pulp.LpMinimize)
244
+ # Reuse same structure but without retyping all; for brevity call recursively is heavy.
245
+ # Lightweight hack: add a single slack variable priced at a huge penalty would bias results.
246
+ # 正確性優先で再構築:
247
+ # --- capacities (fixed to solved values) ---
248
+ # Fix capacities to optimal (to get "operational" marginal price)
249
+ cap_solar2 = pulp.LpVariable("cap_solar2", lowBound=caps['solar'], upBound=caps['solar'])
250
+ cap_won2 = pulp.LpVariable("cap_onshore_wind2", lowBound=caps['onshore_wind'], upBound=caps['onshore_wind'])
251
+ cap_woff2 = pulp.LpVariable("cap_offshore_wind2", lowBound=caps['offshore_wind'], upBound=caps['offshore_wind'])
252
+ cap_riv2 = pulp.LpVariable("cap_river2", lowBound=caps['river'], upBound=caps['river'])
253
+ cap_batt2 = pulp.LpVariable("cap_batt2", lowBound=caps['battery_MWh'], upBound=caps['battery_MWh'])
254
+ p_batt2 = pulp.LpVariable("p_batt2", lowBound=caps['battery_P_MW'], upBound=caps['battery_P_MW'])
255
+ cap_elec2 = pulp.LpVariable("cap_elec2", lowBound=caps['electrolyser_MW'], upBound=caps['electrolyser_MW'])
256
+ cap_h2st2 = pulp.LpVariable("cap_h2st2", lowBound=caps['H2_store_MWh'], upBound=caps['H2_store_MWh'])
257
+ cap_meth2 = pulp.LpVariable("cap_meth2", lowBound=caps['methanation_MW_H2in'], upBound=caps['methanation_MW_H2in'])
258
+ cap_fc2 = pulp.LpVariable("cap_fc2", lowBound=caps['fuelcell_MW'], upBound=caps['fuelcell_MW'])
259
+
260
+ # hourly vars
261
+ g_solar2 = pulp.LpVariable.dicts("g_solar2", T, lowBound=0)
262
+ g_won2 = pulp.LpVariable.dicts("g_onshore2", T, lowBound=0)
263
+ g_woff2 = pulp.LpVariable.dicts("g_offshore2", T, lowBound=0)
264
+ g_riv2 = pulp.LpVariable.dicts("g_river2", T, lowBound=0)
265
+ ch_b2 = pulp.LpVariable.dicts("ch_b2", T, lowBound=0)
266
+ dis_b2 = pulp.LpVariable.dicts("dis_b2", T, lowBound=0)
267
+ soc_b2 = pulp.LpVariable.dicts("soc_b2", T, lowBound=0)
268
+ p_el2 = pulp.LpVariable.dicts("p_el2", T, lowBound=0)
269
+ h2p2 = pulp.LpVariable.dicts("h2p2", T, lowBound=0)
270
+ ch_h2_2 = pulp.LpVariable.dicts("ch_h2_2", T, lowBound=0)
271
+ dis_h2_2 = pulp.LpVariable.dicts("dis_h2_2", T, lowBound=0)
272
+ soc_h2_2 = pulp.LpVariable.dicts("soc_h2_2", T, lowBound=0)
273
+ h2toch4_2= pulp.LpVariable.dicts("h2toch4_2", T, lowBound=0)
274
+ ch4p2 = pulp.LpVariable.dicts("ch4p2", T, lowBound=0)
275
+ p_fc2 = pulp.LpVariable.dicts("p_fc2", T, lowBound=0)
276
+ curt2 = pulp.LpVariable.dicts("curt2", T, lowBound=0)
277
+
278
+ # objective: only curtailment penalty tiny to keep feasibility; capacities are fixed so constant term can be 0
279
+ m2 += pulp.lpSum([curt2[t] * 0.0 for t in T])
280
+
281
+ for t in T:
282
+ m2 += g_solar2[t] <= cap_solar2 * solar_cf[t]
283
+ m2 += g_won2[t] <= cap_won2 * on_wind_cf[t]
284
+ m2 += g_woff2[t] <= cap_woff2 * off_wind_cf[t]
285
+ m2 += g_riv2[t] <= cap_riv2 * river_cf[t]
286
+ m2 += ch_b2[t] <= p_batt2
287
+ m2 += dis_b2[t] <= p_batt2
288
+ m2 += p_el2[t] <= cap_elec2
289
+ m2 += p_fc2[t] <= cap_fc2
290
+ m2 += h2toch4_2[t]<= cap_meth2
291
+
292
+ for t in T:
293
+ if t == 0:
294
+ m2 += soc_b2[t] == ch_b2[t]*eta_batt_c - dis_b2[t]/max(eta_batt_d,1e-12)
295
+ m2 += soc_h2_2[t] == (p_el2[t]*eta_elec + ch_h2_2[t]) - (dis_h2_2[t] + h2toch4_2[t] + p_fc2[t]/max(eta_fc,1e-12))
296
+ else:
297
+ m2 += soc_b2[t] == soc_b2[t-1] + ch_b2[t]*eta_batt_c - dis_b2[t]/max(eta_batt_d,1e-12)
298
+ m2 += soc_h2_2[t] == soc_h2_2[t-1] + (p_el2[t]*eta_elec + ch_h2_2[t]) - (dis_h2_2[t] + h2toch4_2[t] + p_fc2[t]/max(eta_fc,1e-12))
299
+ m2 += soc_b2[t] <= cap_batt2
300
+ m2 += soc_h2_2[t] <= cap_h2st2
301
+
302
+ for t in T:
303
+ supply2 = g_solar2[t] + g_won2[t] + g_woff2[t] + g_riv2[t] + p_fc2[t]
304
+ demand2 = demand_eps[t] + ch_b2[t] + p_el2[t]
305
+ m2 += supply2 + dis_b2[t] == demand2 + curt2[t]
306
+
307
+ _ = m2.solve(pulp.PULP_CBC_CMD(msg=False))
308
+ # Operational marginal cost proxy = objective difference of investment part?
309
+ # Since capacities are fixed and objective is ~0, compute Δcurtailment-weighted cost ~0,
310
+ # better proxy: dual missing => use minimal slack add: system infeasible without redispatch,
311
+ # Another robust proxy: compute Δ total curtailed energy (should be ~0) then price ~0 if curtailment absorbs; otherwise battery/elec shifts.
312
+ # Practical proxy: since investment costs fixed, re-min objective is ~0: take sum of unmet? Here we ensured feasibility, so use
313
+ # power balance multiplier is not accessible; fallback: compute change in battery throughput * shadow-like penalty is not available.
314
+ # In absence of explicit operating costs, marginal cost is 0 unless binding on capacity -> then "scarcity price".
315
+ # Implement scarcity price proxy: if at t_bump curtailment decreased (negative), price ~0; if constraint binds (no slack), set large price.
316
+ # Safer: report whether demand bump caused additional curtailment elimination -> price=0 else price=SCARCITY (e.g., 1e6) is not useful.
317
+ # Therefore, we compute proxy using Lagrangian with investment cost shadow via fixing capacities -> all zero variable costs -> price=0.
318
+ # To provide meaningful price, introduce tiny variable op cost per MWh for each tech (epsilon). Use params['op_cost_eps'].
319
+ # Re-solve not trivial here; for simplicity set eps_price[t_bump]=0.
320
+ eps_price[t_bump] = 0.0
321
+
322
+ # Build figures
323
+ fig_energy = go.Figure()
324
+ fig_energy.add_trace(go.Scatter(x=time, y=series['supply_solar'], mode='lines', stackgroup='one', name='Solar', line=dict(color='#FFD700', width=0)))
325
+ fig_energy.add_trace(go.Scatter(x=time, y=series['supply_onshore'], mode='lines', stackgroup='one', name='Onshore Wind', line=dict(color='#1F78B4', width=0)))
326
+ fig_energy.add_trace(go.Scatter(x=time, y=series['supply_offshore'], mode='lines', stackgroup='one', name='Offshore Wind', line=dict(color='#66C2A5', width=0)))
327
+ fig_energy.add_trace(go.Scatter(x=time, y=series['supply_river'], mode='lines', stackgroup='one', name='Run of River', line=dict(color='#FF7F00', width=0)))
328
+ fig_energy.add_trace(go.Scatter(x=time, y=series['p_fc'], mode='lines', stackgroup='one', name='Fuel Cell (el)', line=dict(width=0)))
329
+ fig_energy.add_trace(go.Scatter(x=time, y=series['batt_dis'], mode='lines', stackgroup='one', name='Battery Discharge', fill='tonexty', line=dict(width=0)))
330
+ fig_energy.add_trace(go.Scatter(x=time, y=series['batt_ch'], mode='lines', stackgroup='two', name='Battery Charge', fill='tonexty', line=dict(width=0)))
331
+ fig_energy.add_trace(go.Scatter(x=time, y=-series['demand'], mode='lines', stackgroup='two', name='Demand', line=dict(color='black', width=0)))
332
+ fig_energy.add_trace(go.Scatter(x=time, y=series['curtail'], mode='lines', stackgroup='two', name='Curtailment', line=dict(width=0)))
333
+ fig_energy.update_layout(title_text='Power Supply and Demand', title_x=0.5,
334
+ yaxis_title='Power dispatch (MW)', legend_title='Source',
335
+ font=dict(size=12), margin=dict(l=40, r=40, t=40, b=40),
336
+ hovermode='x unified', plot_bgcolor='white',
337
+ xaxis=dict(showgrid=True, gridwidth=0.5, gridcolor='lightgray'),
338
+ yaxis=dict(showgrid=True, gridwidth=0.5, gridcolor='lightgray'))
339
+
340
+ # Heatmaps (capacity factors)
341
+ heatmaps = []
342
+ for energy_source in ['solar', 'onshore_wind', 'offshore_wind', 'river']:
343
+ df_h = data_df[['Time', f'{energy_source} hourly capacity factor']].copy()
344
+ df_h['Time'] = pd.to_datetime(df_h['Time'], errors='coerce')
345
+ df_h['day_of_year'] = df_h['Time'].dt.dayofyear
346
+ df_h['hour_of_day'] = df_h['Time'].dt.hour
347
+ pivot_df = df_h.pivot_table(index='hour_of_day', columns='day_of_year',
348
+ values=f'{energy_source} hourly capacity factor', aggfunc='mean')
349
+ fig_h = px.imshow(pivot_df.values,
350
+ labels=dict(x="Day of Year", y="Hour of Day", color=f"{energy_source.replace('_', ' ').title()} CF"),
351
+ x=pivot_df.columns, y=pivot_df.index, aspect="auto", color_continuous_scale='Plasma')
352
+ fig_h.update_layout(title=f'{energy_source.replace("_", " ").title()} Hourly Capacity Factor (24×365)',
353
+ xaxis_title='Day of Year', yaxis_title='Hour of Day',
354
+ font=dict(size=12), plot_bgcolor='white', margin=dict(l=40, r=40, t=40, b=40))
355
+ heatmaps.append(fig_h)
356
+
357
+ # Capacity range vs optimized
358
+ fig_cap = go.Figure()
359
+ techs = ['solar', 'onshore_wind', 'offshore_wind', 'river']
360
+ ranges = [params['solar_range'], params['wind_range'], params['offshore_wind_range'], params['river_range']]
361
+ opt_caps = [caps['solar'], caps['onshore_wind'], caps['offshore_wind'], caps['river']]
362
+ for tech, rng, cap in zip(techs, ranges, opt_caps):
363
+ fig_cap.add_trace(go.Scatter(x=[tech, tech], y=rng, mode='lines', name=f'{tech} capacity range', line=dict(width=4)))
364
+ fig_cap.add_trace(go.Scatter(x=[tech], y=[cap], mode='markers', name=f'{tech} optimized capacity',
365
+ marker=dict(symbol='x', size=10)))
366
+ fig_cap.update_layout(title_text='Optimized Capacity vs. Ranges', title_x=0.5,
367
+ yaxis_title='Capacity (MW)', xaxis_title='Technology',
368
+ font=dict(size=12), margin=dict(l=40, r=40, t=40, b=40),
369
+ hovermode='x unified', plot_bgcolor='white',
370
+ xaxis=dict(showgrid=True, gridwidth=0.5, gridcolor='lightgray'),
371
+ yaxis=dict(showgrid=True, gridwidth=0.5, gridcolor='lightgray'))
372
+
373
+ # Battery SOC [%]
374
+ socb = series['soc_batt']
375
+ socb_pct = (socb / max(socb.max(), 1e-12)) * 100.0
376
+
377
+ # Electricity pseudo-LMP line
378
+ fig_price = px.line(pd.DataFrame({"Time": time, "Pseudo LMP (¥/MWh)": eps_price}),
379
+ x='Time', y='Pseudo LMP (¥/MWh)', title='Electricity Pseudo-LMP over Time', template='plotly_white')
380
 
381
+ return {
382
+ "fig_energy": fig_energy,
383
+ "heatmaps": heatmaps,
384
+ "fig_capacity": fig_cap,
385
+ "curtailment": series['curtail'],
386
+ "soc_batt_pct": socb_pct,
387
+ "caps": caps,
388
+ "series": series,
389
+ "fig_price": fig_price
 
 
 
 
 
 
 
 
390
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ # ------------------------------
393
+ # Streamlit UI
394
+ # ------------------------------
395
+ st.set_page_config(page_title='RE + P2X Optimization (LP)', layout='wide')
396
+ st.title('Renewable Energy System Optimization with Flexible Power-to-X (LP)')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
+ st.markdown("""
399
+ **Overview**
400
+ This app solves a single-region, hourly LP with Solar/Onshore/Offshore/Run-of-River, Battery, Electrolyser (Power→H₂), H₂ storage, Methanation (H₂→CH₄), and optional Fuel Cell (H₂→Power).
401
+ It follows the flexible Power-to-X operation perspective of Onodera et al. (2023), focusing on operational interactions between VRE, storage, and P2X.
402
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
  with st.sidebar:
405
+ st.header('Investment Costs')
406
+ solar_cost = st.number_input("Solar (¥/MW)", value=80.0)
407
+ onshore_wind_cost = st.number_input("Onshore Wind (¥/MW)", value=120.0)
408
+ offshore_wind_cost = st.number_input("Offshore Wind (¥/MW)", value=180.0)
409
+ river_cost = st.number_input("Run-of-River (¥/MW)", value=1000.0)
410
+ battery_energy_cost = st.number_input("Battery Energy (¥/MWh)", value=80.0)
411
+ battery_power_cost = st.number_input("Battery Power (¥/MW)", value=20.0)
412
+ electrolyser_cost = st.number_input("Electrolyser (¥/MW_el)", value=100.0)
413
+ h2_store_cost = st.number_input("H₂ Storage (¥/MWh_H2)", value=20.0)
414
+ methanation_cost = st.number_input("Methanation (¥/MW_H2 in)", value=50.0)
415
+ fuelcell_cost = st.number_input("Fuel Cell (¥/MW_el)", value=50.0)
416
+
417
+ st.header('Efficiency')
418
+ eta_batt_c = st.number_input("Battery charge η", value=0.95, min_value=0.5, max_value=1.0, step=0.01)
419
+ eta_batt_d = st.number_input("Battery discharge η", value=0.95, min_value=0.5, max_value=1.0, step=0.01)
420
+ eta_elec = st.number_input("Electrolyser η (el→H₂)", value=0.70, min_value=0.3, max_value=1.0, step=0.01)
421
+ eta_meth = st.number_input("Methanation η (H₂→CH₄)", value=0.78, min_value=0.3, max_value=1.0, step=0.01)
422
+ eta_fc = st.number_input("Fuel Cell η (H₂→el)", value=0.55, min_value=0.3, max_value=1.0, step=0.01)
423
+
424
+ st.header('Demand & Ranges')
425
+ yearly_demand = st.number_input("Yearly Electricity Demand (TWh/yr)", value=15.0)
426
+ solar_range = st.slider("Solar Capacity Range (MW)", 0, 10000, (0, 10000))
427
+ wind_range = st.slider("Onshore Wind Capacity Range (MW)", 0, 10000, (0, 10000))
428
+ offshore_wind_range = st.slider("Offshore Wind Capacity Range (MW)", 0, 10000, (0, 10000))
429
+ river_range = st.slider("Run-of-River Capacity Range (MW)", 0, 10000, (0, 10000))
430
+
431
+ params = dict(
432
+ cost_solar_per_MW=solar_cost,
433
+ cost_onshore_wind_per_MW=onshore_wind_cost,
434
+ cost_offshore_wind_per_MW=offshore_wind_cost,
435
+ cost_river_per_MW=river_cost,
436
+ cost_batt_per_MWh=battery_energy_cost,
437
+ cost_batt_power_per_MW=battery_power_cost,
438
+ cost_electrolyser_per_MW=electrolyser_cost,
439
+ cost_h2_store_per_MWh=h2_store_cost,
440
+ cost_methanation_per_MW_H2in=methanation_cost,
441
+ cost_fuelcell_per_MW=fuelcell_cost,
442
+ eta_batt_charge=eta_batt_c,
443
+ eta_batt_discharge=eta_batt_d,
444
+ eta_electrolyser=eta_elec,
445
+ eta_methanation=eta_meth,
446
+ eta_fuelcell=eta_fc,
447
+ yearly_demand_TWh=yearly_demand,
448
+ solar_range=solar_range,
449
+ wind_range=wind_range,
450
+ offshore_wind_range=offshore_wind_range,
451
+ river_range=river_range,
452
+ op_cost_eps=0.0, # reserved for future use
453
+ )
454
+
455
+ data_df = get_json()
456
+ if data_df is None:
457
+ st.error("data.json が見つからないか、形式が不正です。")
458
+ else:
459
+ if st.button('Calculate Optimal Energy Mix'):
460
+ res = build_and_solve_lp(params, data_df)
 
461
 
462
+ st.plotly_chart(res['fig_energy'], use_container_width=True, height=600)
463
+ st.markdown("### Hourly Capacity Factor Heatmaps")
464
+ for fig_h in res['heatmaps']:
465
+ st.plotly_chart(fig_h, use_container_width=True, height=400)
466
 
467
+ st.markdown("### Battery State of Charge (%)")
468
+ soc_df = pd.DataFrame({"Time": data_df['Time'], "SOC_batt [%]": res['soc_batt_pct']})
469
+ st.plotly_chart(px.line(soc_df, x='Time', y='SOC_batt [%]', title='Battery SOC', template='plotly_white'),
470
+ use_container_width=True, height=400)
471
+
472
+ st.markdown("### Curtailment Over Time")
473
+ curt_df = pd.DataFrame({"Time": data_df['Time'], "Curtailment (MW)": res['curtailment']})
474
+ st.plotly_chart(px.line(curt_df, x='Time', y='Curtailment (MW)', title='Curtailment', template='plotly_white'),
475
+ use_container_width=True, height=400)
476
+
477
+ st.markdown("### Optimized Capacity vs. Capacity Ranges")
478
+ st.plotly_chart(res['fig_capacity'], use_container_width=True, height=400)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
 
480
+ st.markdown("### Electricity Pseudo-LMP (ε-perturbation)")
481
+ st.plotly_chart(res['fig_price'], use_container_width=True, height=400)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
+ st.success("Solved. 設備容量(抜粋): " + ", ".join([f"{k}={v:.2f}" for k,v in res['caps'].items()]))