optimize.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import pandas as pd
  2. import numpy as np
  3. import numba
  4. from numpy.random import dirichlet
  5. from scipy.optimize import linprog
  6. from scipy.signal import square
  7. from statsmodels.formula.api import ols
  8. import cvxpy as cp
  9. from matplotlib import pyplot as plt
  10. import matplotlib.dates as mdates
  11. import argparse
  12. parser = argparse.ArgumentParser()
  13. parser.add_argument("--begin")
  14. parser.add_argument("--end")
  15. parser.add_argument("--flexibility", action="store_true")
  16. args = parser.parse_args()
  17. def achieved_load_factors(sources=[]):
  18. # installed capacity
  19. infra = pd.read_csv(
  20. "registre-national-installation-production-stockage-electricite-agrege.csv", sep=";")
  21. infra["Year"] = infra["dateMiseEnService"].str[-4:].astype(str)
  22. infra.dropna(subset=["Year", "puisMaxInstallee"], inplace=True)
  23. infra = infra[infra["Year"] != 'nan']
  24. infra = infra.groupby(["filiere", "Year"]).agg(
  25. pi=('puisMaxInstallee', 'sum')
  26. ).reset_index().sort_values(["filiere", "Year"])
  27. infra['pi'] = infra.groupby(
  28. 'filiere')['pi'].transform(pd.Series.cumsum)/1000
  29. infra["Year"] = infra["Year"].astype(int)
  30. infra.set_index(["filiere", "Year"], inplace=True)
  31. infra = infra.reindex(
  32. index=[
  33. (filiere, year) for filiere in set(infra.index.get_level_values(0)) for year in np.arange(2000, 2022)
  34. ]
  35. )
  36. infra = infra.sort_index(level=0).ffill().reindex(infra.index)
  37. infra.reset_index(inplace=True)
  38. infra = infra.pivot(index="Year", columns="filiere", values="pi")
  39. # production
  40. prod = pd.read_csv("eco2mix-national-cons-def.csv", sep=";")
  41. prod["time"] = pd.to_datetime(
  42. prod["Date et Heure"].str.replace(":", ""), format="%Y-%m-%dT%H%M%S%z", utc=True
  43. )
  44. prod["Year"] = prod["time"].dt.year
  45. prod = prod.merge(infra, left_on="Year", right_on="Year", how="left").sort_values("time").set_index("time")
  46. # consumption
  47. consumption = pd.read_csv("consommation-quotidienne-brute.csv", sep=";")
  48. consumption['time'] = pd.to_datetime(
  49. consumption["Date - Heure"].str.replace(":", ""), format="%Y-%m-%dT%H%M%S%z", utc=True)
  50. consumption.set_index("time", inplace=True)
  51. print(consumption["Consommation brute électricité (MW) - RTE"])
  52. for source in sources:
  53. fig, ax1 = plt.subplots()
  54. ax2 = ax1.twinx()
  55. prod[f"{source} (load factor)"] = prod[f"{source} (MW)"]/prod[source]
  56. print(prod[f"{source} (load factor)"])
  57. ax1.scatter(prod.index, prod[f"{source} (load factor)"], s=0.5, color="red")
  58. ax2.plot(consumption.index, consumption["Consommation brute électricité (MW) - RTE"], lw=0.5, color="blue")
  59. plt.show()
  60. fig.savefig(f"test_{source}.png", dpi=200)
  61. plt.clf()
  62. plt.cla()
  63. # plt.show()
  64. # achieved_load_factors(["Nucléaire", "Hydraulique"])
  65. def generate_load_curve(times, yearly_total=645*1000):
  66. # load observed load curve
  67. consumption = pd.read_csv("consommation-quotidienne-brute.csv", sep=";")
  68. consumption['time'] = pd.to_datetime(
  69. consumption["Date - Heure"].str.replace(":", ""), format="%Y-%m-%dT%H%M%S%z", utc=True)
  70. consumption.set_index("time", inplace=True)
  71. hourly = consumption.resample('1H').mean()
  72. hourly['h'] = ((hourly.index - hourly.index.min()
  73. ).total_seconds()/3600).astype(int)
  74. hourly['Y'] = hourly.index.year-hourly.index.year.min()
  75. # generate fourier components
  76. frequencies = list((1/(365.25*24))*np.arange(1, 13)) + \
  77. list((1/(24*7)) * np.arange(1, 7)) + \
  78. list((1/24) * np.arange(1, 12))
  79. components = []
  80. for i, f in enumerate(frequencies):
  81. hourly[f'c_{i+1}'] = (hourly['h']*f*2*np.pi).apply(np.cos)
  82. hourly[f's_{i+1}'] = (hourly['h']*f*2*np.pi).apply(np.sin)
  83. components += [f'c_{i+1}', f's_{i+1}']
  84. hourly.rename(
  85. columns={'Consommation brute électricité (MW) - RTE': 'conso'}, inplace=True)
  86. hourly["conso"] /= 1000
  87. # fit load curve to fourier components
  88. model = ols("conso ~ " + " + ".join(components)+" + C(Y)", data=hourly)
  89. results = model.fit()
  90. # normalize according to the desired total yearly consumption
  91. intercept = results.params[0]+results.params[1]
  92. results.params *= yearly_total/(intercept*365.25*24)
  93. # compute the load for the desired timestamps
  94. times = pd.DataFrame({'time': times}).set_index("time")
  95. times.index = pd.to_datetime(times.index, utc=True)
  96. times['h'] = ((times.index - hourly.index.min()
  97. ).total_seconds()/3600).astype(int)
  98. times['Y'] = 1
  99. for i, f in enumerate(frequencies):
  100. times[f'c_{i+1}'] = (times['h']*f*2*np.pi).apply(np.cos)
  101. times[f's_{i+1}'] = (times['h']*f*2*np.pi).apply(np.sin)
  102. curve = results.predict(times)
  103. return np.array(curve)
  104. @numba.njit
  105. def storage_iterate(dE, capacity, efficiency, n):
  106. storage = np.zeros(n)
  107. for i in np.arange(1, n):
  108. if dE[i] >= 0:
  109. dE[i] *= efficiency
  110. storage[i] = np.maximum(0, np.minimum(capacity, storage[i-1]+dE[i-1]))
  111. return storage
  112. def objective_with_storages(
  113. potential,
  114. units_per_region,
  115. dispatchable,
  116. p_load=2664*1000/(365*24),
  117. storage_capacities=5*250,
  118. storage_max_loads=5*30,
  119. storage_max_deliveries=5*30,
  120. storage_efficiencies=0.67
  121. ):
  122. power = np.einsum('ijk,ik', potential, units_per_region)
  123. power_delta = power-p_load
  124. T = len(power)
  125. available_power = np.array(power_delta)
  126. excess_power = np.maximum(0, available_power)
  127. deficit_power = np.maximum(0, -available_power)
  128. n_storages = len(storage_capacities)
  129. storage_try_load = np.zeros((n_storages, T))
  130. storage_try_delivery = np.zeros((n_storages, T))
  131. storage = np.zeros((n_storages, T))
  132. storage_impact = np.zeros((n_storages, T))
  133. dE_storage = np.zeros((n_storages, T))
  134. for i in range(n_storages):
  135. storage_try_load[i] = np.minimum(excess_power, storage_max_loads[i])
  136. storage_try_delivery[i] = np.minimum(deficit_power, storage_max_deliveries[i])
  137. dE_storage[i] = storage_try_load[i]-storage_try_delivery[i]
  138. storage[i] = storage_iterate(
  139. dE_storage[i], storage_capacities[i], storage_efficiencies[i], T
  140. )
  141. # impact of storage on the available power
  142. storage_impact[i] = -np.diff(storage[i], append=0)
  143. storage_impact[i] = np.multiply(
  144. storage_impact[i],
  145. np.where(storage_impact[i] < 0, 1/storage_efficiencies[i], 1)
  146. )
  147. available_power += storage_impact[i]
  148. excess_power = np.maximum(0, available_power)
  149. deficit_power = np.maximum(0, -available_power)
  150. gap = p_load-power-storage_impact.sum(axis=0)
  151. # optimize dispatch
  152. n_dispatchable_sources = dispatchable.shape[0]
  153. dispatch_power = cp.Variable((n_dispatchable_sources, T))
  154. constraints = [
  155. dispatch_power >= 0,
  156. cp.sum(dispatch_power, axis=0) <= np.maximum(gap, 0)
  157. ] + [
  158. dispatch_power[i,:] <= dispatchable[i,:,0].sum()
  159. for i in range(n_dispatchable_sources)
  160. ] + [
  161. cp.sum(dispatch_power[i]) <= dispatchable[i,:,1].sum()
  162. for i in range(n_dispatchable_sources)
  163. ]
  164. prob = cp.Problem(
  165. cp.Minimize(
  166. cp.sum(cp.pos(gap-cp.sum(dispatch_power, axis=0))) + cp.max(gap-cp.sum(dispatch_power, axis=0))
  167. ),
  168. constraints
  169. )
  170. prob.solve(solver=cp.ECOS, max_iters=300)
  171. dp = dispatch_power.value
  172. gap -= dp.sum(axis=0)
  173. S = np.maximum(gap, 0).mean()
  174. return S, power, gap, storage, dp
  175. def objective_with_storage_and_flexibility(
  176. potential,
  177. units_per_region,
  178. dispatchable,
  179. p_load=2664*1000/(365*24),
  180. storage_capacities=5*250,
  181. storage_max_loads=5*30,
  182. storage_max_deliveries=5*30,
  183. storage_efficiencies=0.67,
  184. flexibility_power=17,
  185. flexibility_time=8
  186. ):
  187. power = np.einsum('ijk,ik', potential, units_per_region)
  188. T = len(power)
  189. tau = flexibility_time
  190. h = cp.Variable((T, tau+1))
  191. constraints = [
  192. h >= 0,
  193. h <= 1,
  194. h[:, 0] >= 1-flexibility_power/p_load,
  195. cp.multiply(p_load, cp.sum(h, axis=1))-p_load <= flexibility_power,
  196. ] + [
  197. h[t, 0]+h[t-1, 1]+h[t-2, 2]+h[t-3, 3]+h[t-4, 4] +
  198. h[t-5, 5]+h[t-6, 6]+h[t-7, 7]+h[t-8, 8] == 1
  199. for t in np.arange(flexibility_time, T)
  200. ]
  201. prob = cp.Problem(
  202. cp.Minimize(
  203. cp.sum(cp.pos(cp.multiply(p_load, cp.sum(h, axis=1))-power))
  204. ),
  205. constraints
  206. )
  207. prob.solve(verbose=True, solver=cp.ECOS, max_iters=300)
  208. hb = np.array(h.value)
  209. p_load = p_load*np.sum(hb, axis=1)
  210. power_delta = power-p_load
  211. available_power = np.array(power_delta)
  212. excess_power = np.maximum(0, available_power)
  213. deficit_power = np.maximum(0, -available_power)
  214. n_storages = len(storage_capacities)
  215. storage_try_load = np.zeros((n_storages, T))
  216. storage_try_delivery = np.zeros((n_storages, T))
  217. storage = np.zeros((n_storages, T))
  218. storage_impact = np.zeros((n_storages, T))
  219. dE_storage = np.zeros((n_storages, T))
  220. for i in range(n_storages):
  221. storage_try_load[i] = np.minimum(excess_power, storage_max_loads[i])
  222. storage_try_delivery[i] = np.minimum(deficit_power, storage_max_deliveries[i])
  223. dE_storage[i] = storage_try_load[i]-storage_try_delivery[i]
  224. storage[i] = storage_iterate(
  225. dE_storage[i], storage_capacities[i], storage_efficiencies[i], T
  226. )
  227. # impact of storage on the available power
  228. storage_impact[i] = -np.diff(storage[i], append=0)
  229. storage_impact[i] = np.multiply(
  230. storage_impact[i],
  231. np.where(storage_impact[i] < 0, 1/storage_efficiencies[i], 1)
  232. )
  233. available_power += storage_impact[i]
  234. excess_power = np.maximum(0, available_power)
  235. deficit_power = np.maximum(0, -available_power)
  236. # power missing to meet demand
  237. gap = p_load-power-storage_impact.sum(axis=0)
  238. # optimize dispatch
  239. n_dispatchable_sources = dispatchable.shape[0]
  240. dispatch_power = cp.Variable((n_dispatchable_sources, T))
  241. constraints = [
  242. dispatch_power >= 0,
  243. cp.sum(dispatch_power, axis=0) <= np.maximum(gap, 0)
  244. ] + [
  245. dispatch_power[i,:] <= dispatchable[i,:,0].sum()
  246. for i in range(n_dispatchable_sources)
  247. ] + [
  248. cp.sum(dispatch_power[i]) <= dispatchable[i,:,1].sum()
  249. for i in range(n_dispatchable_sources)
  250. ]
  251. prob = cp.Problem(
  252. cp.Minimize(
  253. cp.sum(cp.pos(gap-cp.sum(dispatch_power, axis=0)))
  254. ),
  255. constraints
  256. )
  257. prob.solve(solver=cp.ECOS, max_iters=300)
  258. dp = dispatch_power.value
  259. gap -= dp.sum(axis=0)
  260. S = np.maximum(gap, 0).mean()
  261. return S, power, gap, storage, p_load, dp
  262. potential = pd.read_parquet("potential.parquet")
  263. potential = potential.loc['1985-01-01 00:00:00':'2015-01-01 00:00:00', :]
  264. potential.fillna(0, inplace=True)
  265. potential = potential.loc[(
  266. slice(f'{args.begin} 00:00:00', f'{args.end} 00:00:00'), 'FR'), :]
  267. load = generate_load_curve(potential.index.get_level_values(0))
  268. p = potential[["onshore", "offshore", "solar"]].to_xarray().to_array()
  269. p = np.insert(p, 3, 0.68, axis=0) # nuclear power-like
  270. # p[-1,:,0] = 0.68+0.25*np.cos(2*np.pi*np.arange(len(potential))/(365.25*24)) # nuclear seasonality ersatz
  271. dispatchable = np.zeros((3, 1, 2))
  272. # hydro power
  273. dispatchable[0][0][0] = 22
  274. dispatchable[0][0][1] = 63*1000*len(potential)/(365.25*24)
  275. # biomass
  276. dispatchable[1][0][0] = 2
  277. dispatchable[1][0][1] = 12*1000*len(potential)/(365.25*24)
  278. # thermique tradi
  279. dispatchable[2][0][0] = 0.5
  280. dispatchable[2][0][1] = 1e8
  281. n_sources = p.shape[0]
  282. n_dt = p.shape[1]
  283. n_regions = p.shape[2]
  284. # start = np.array([18, 0, 10, 61.4, 3]) # current mix
  285. # # stop = np.array([65, 40, 143, 0, 3]) # negawatt like mix
  286. # stop = np.array([74, 62, 208, 0, 3]) # RTE 100% EnR
  287. scenarios = {
  288. 'M0': np.array([74, 62, 208, 0]),
  289. 'M1': np.array([59, 45, 214, 16]),
  290. 'M23': np.array([72, 60, 125, 16]),
  291. 'N1': np.array([58, 45, 118, 16+13]),
  292. 'N2': np.array([52, 36, 90, 16+23]),
  293. 'N03': np.array([43, 22, 70, 24+27]),
  294. }
  295. BATTERY_CAPACITY=4 # 4 hour capacity
  296. STEP_CAPACITY=24
  297. P2G_CAPACITY=24*7*2
  298. battery_power = {
  299. 'M0': 26,
  300. 'M1': 21,
  301. 'M23': 13,
  302. 'N1': 9,
  303. 'N2': 2,
  304. 'N03': 1
  305. }
  306. p2g_power = {
  307. 'M0': 29,
  308. 'M1': 20,
  309. 'M23': 20,
  310. 'N1': 11,
  311. 'N2': 5,
  312. 'N03': 0
  313. }
  314. step_power = 8
  315. fig, axes = plt.subplots(nrows=6, ncols=2, sharex="col", sharey=True)
  316. w, h = fig.get_size_inches()
  317. fig.set_figwidth(w*1.5)
  318. fig.set_figheight(h*1.5)
  319. fig_storage, axes_storage = plt.subplots(nrows=6, ncols=2, sharex="col", sharey=True)
  320. fig_storage.set_figwidth(w*1.5)
  321. fig_storage.set_figheight(h*1.5)
  322. fig_dispatch, axes_dispatch = plt.subplots(nrows=6, ncols=2, sharex="col", sharey=True)
  323. fig_dispatch.set_figwidth(w*1.5)
  324. fig_dispatch.set_figheight(h*1.5)
  325. # for step in np.linspace(start, stop, 2050-2022, True)[::-1]:
  326. row = 0
  327. for scenario in scenarios:
  328. units = np.transpose([scenarios[scenario]])
  329. # 8 GW STEP capacity common to all scenarios
  330. storage_power = battery_power[scenario] + p2g_power[scenario] + step_power
  331. storage_max_load = battery_power[scenario]*BATTERY_CAPACITY + p2g_power[scenario]*24*7 + step_power*24
  332. # nuclear_load_model(p[:,:3], units, load, 20)
  333. if args.flexibility:
  334. S, production, gap, storage, adjusted_load, dp = objective_with_storage_and_flexibility(
  335. p, units,
  336. dispatchable,
  337. p_load=load,
  338. storage_capacities=[battery_power[scenario]*BATTERY_CAPACITY, step_power*STEP_CAPACITY, p2g_power[scenario]*P2G_CAPACITY],
  339. storage_max_deliveries=[battery_power[scenario], step_power, p2g_power[scenario]],
  340. storage_max_loads=[battery_power[scenario], step_power, p2g_power[scenario]],
  341. storage_efficiencies=[0.8, 0.8, 0.3]
  342. )
  343. print(f"{scenario}:", S, gap.max(), np.quantile(gap, 0.95))
  344. else:
  345. S, production, gap, storage, dp = objective_with_storages(
  346. p,
  347. units,
  348. dispatchable,
  349. p_load = load,
  350. storage_capacities=[battery_power[scenario]*BATTERY_CAPACITY, step_power*STEP_CAPACITY, p2g_power[scenario]*P2G_CAPACITY],
  351. storage_max_deliveries=[battery_power[scenario], step_power, p2g_power[scenario]],
  352. storage_max_loads=[battery_power[scenario], step_power, p2g_power[scenario]],
  353. storage_efficiencies=[0.8, 0.8, 0.3]
  354. )
  355. adjusted_load = load
  356. print(f"{scenario} w/o flexibility:", S, gap.max(), np.quantile(gap, 0.95))
  357. print(f"exports: {np.minimum(np.maximum(-gap, 0), 39).sum()/1000} TWh; imports: {np.minimum(np.maximum(gap, 0), 39).sum()/1000} TWh")
  358. print(f"dispatchable: " + ", ".join([f"{dp[i].sum()/1000:.2f} TWh" for i in range(dp.shape[0])]))
  359. potential['adjusted_load'] = adjusted_load
  360. potential['production'] = production
  361. potential['available'] = production-np.diff(storage.sum(axis=0), append=0)
  362. for i in range(3):
  363. potential[f"storage_{i}"] = np.diff(storage[i,:], append=0)#storage[i,:]/1000
  364. potential[f"storage_{i}"] = storage[i,:]/1000
  365. for i in range(dp.shape[0]):
  366. potential[f"dispatch_{i}"] = dp[i,:]
  367. potential["dispatch"] = dp.sum(axis=0)
  368. data = [
  369. potential.loc[(slice('2013-02-01 00:00:00',
  370. '2013-03-01 00:00:00'), 'FR'), :],
  371. potential.loc[(slice('2013-06-01 00:00:00',
  372. '2013-07-01 00:00:00'), 'FR'), :]
  373. ]
  374. months = [
  375. "Février",
  376. "Juin"
  377. ]
  378. labels = [
  379. "adjusted load (GW)",
  380. "production (GW)",
  381. "available power (production-storage) (GW)",
  382. "power deficit"
  383. ]
  384. labels_storage = [
  385. "Batterie (TWh)",
  386. "STEP (TWh)",
  387. "P2G (TWh)"
  388. ]
  389. labels_dispatch = [
  390. "Hydraulique (GW)",
  391. "Biomasse (GW)",
  392. "Thermique gaz (GW)"
  393. ]
  394. for col in range(2):
  395. ax = axes[row, col]
  396. ax.plot(data[col].index.get_level_values(0), data[col]
  397. ["adjusted_load"], label="adjusted load (GW)", lw=1)
  398. ax.plot(data[col].index.get_level_values(0), data[col]
  399. ["production"], label="production (GW)", ls="dotted", lw=1)
  400. ax.plot(data[col].index.get_level_values(0), data[col]["available"],
  401. label="available power (production-d(storage)/dt) (GW)", lw=1)
  402. ax.fill_between(
  403. data[col].index.get_level_values(0),
  404. data[col]["available"],
  405. data[col]["adjusted_load"],
  406. where=data[col]["adjusted_load"] > data[col]["available"],
  407. color='red',
  408. alpha=0.15
  409. )
  410. fmt = mdates.DateFormatter('%d/%m')
  411. ax.xaxis.set_major_formatter(fmt)
  412. ax.text(
  413. 0.5, 0.87, f"Scénario {scenario} ({months[col]})", ha='center', transform=ax.transAxes)
  414. ax.set_ylim(25, 225)
  415. ax = axes_storage[row, col]
  416. for i in np.arange(3):
  417. if i == 2:
  418. base = 0
  419. else:
  420. base = np.sum([data[col][f"storage_{j}"] for j in np.arange(i+1,3)], axis=0)
  421. ax.fill_between(data[col].index.get_level_values(0), base, base+data[col]
  422. [f"storage_{i}"], label=f"storage {i}", alpha=0.5)
  423. ax.plot(data[col].index.get_level_values(0), base+data[col]
  424. [f"storage_{i}"], label=f"storage {i}", lw=0.25)
  425. ax.xaxis.set_major_formatter(fmt)
  426. ax.text(
  427. 0.5, 0.87, f"Scénario {scenario} ({months[col]})", ha='center', transform=ax.transAxes)
  428. ax = axes_dispatch[row, col]
  429. for i in range(dp.shape[0]):
  430. if i == 0:
  431. base = 0
  432. else:
  433. base = np.sum([data[col][f"dispatch_{j}"] for j in np.arange(i)], axis=0)
  434. ax.fill_between(data[col].index.get_level_values(0), base, base+data[col]
  435. [f"dispatch_{i}"], label=f"dispatch {i}", alpha=0.5)
  436. ax.plot(data[col].index.get_level_values(0), base+data[col]
  437. [f"dispatch_{i}"], label=f"dispatch {i}", lw=0.25)
  438. ax.xaxis.set_major_formatter(fmt)
  439. ax.text(
  440. 0.5, 0.87, f"Scénario {scenario} ({months[col]})", ha='center', transform=ax.transAxes)
  441. row += 1
  442. for label in axes[-1, 0].get_xmajorticklabels() + axes[-1, 1].get_xmajorticklabels():
  443. label.set_rotation(30)
  444. label.set_horizontalalignment("right")
  445. for label in axes_storage[-1, 0].get_xmajorticklabels() + axes_storage[-1, 1].get_xmajorticklabels():
  446. label.set_rotation(30)
  447. label.set_horizontalalignment("right")
  448. for label in axes_dispatch[-1, 0].get_xmajorticklabels() + axes_dispatch[-1, 1].get_xmajorticklabels():
  449. label.set_rotation(30)
  450. label.set_horizontalalignment("right")
  451. flex = "With" if args.flexibility else "Without"
  452. plt.subplots_adjust(wspace=0, hspace=0)
  453. fig.suptitle(f"Simulations based on {args.begin}--{args.end} weather data.\n{flex} consumption flexibility; no nuclear seasonality (unrealistic)")
  454. fig.text(1, 0, 'Lucas Gautheron', ha="right")
  455. fig.legend(labels, loc='lower right', bbox_to_anchor=(1, -0.1),
  456. ncol=len(labels), bbox_transform=fig.transFigure)
  457. fig.savefig("output.png", bbox_inches="tight", dpi=200)
  458. fig_storage.suptitle(f"Simulations based on {args.begin}--{args.end} weather data.\n{flex} consumption flexibility; no nuclear seasonality (unrealistic)")
  459. fig_storage.text(1, 0, 'Lucas Gautheron', ha="right")
  460. fig_storage.legend(labels_storage, loc='lower right', bbox_to_anchor=(1, -0.1),
  461. ncol=len(labels_storage), bbox_transform=fig_storage.transFigure)
  462. fig_storage.savefig("output_storage.png", bbox_inches="tight", dpi=200)
  463. fig_dispatch.suptitle(f"Simulations based on {args.begin}--{args.end} weather data.\n{flex} consumption flexibility; no nuclear seasonality (unrealistic)")
  464. fig_dispatch.text(1, 0, 'Lucas Gautheron', ha="right")
  465. fig_dispatch.legend(labels_dispatch, loc='lower right', bbox_to_anchor=(1, -0.1),
  466. ncol=len(labels_dispatch), bbox_transform=fig_dispatch.transFigure)
  467. fig_dispatch.savefig("output_dispatch.png", bbox_inches="tight", dpi=200)
  468. plt.show()