-
Notifications
You must be signed in to change notification settings - Fork 4
/
models.py
430 lines (355 loc) · 16.8 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
"""The test case power system models."""
import os
import logging
import shutil
import pandas as pd
import calliope
import tests
# Emission intensities of technologies, in ton CO2 equivalent per GWh
EMISSION_INTENSITIES = {'baseload': 200,
'peaking': 400,
'wind': 0,
'unmet': 0}
def load_time_series_data(model_name):
"""Load demand and wind time series data for model.
Parameters:
-----------
model_name (str) : '1_region' or '6_region'
Returns:
--------
ts_data (pandas DataFrame) : time series data for use in model
"""
ts_data = pd.read_csv('data/demand_wind.csv', index_col=0)
ts_data.index = pd.to_datetime(ts_data.index)
# If 1_region model, take demand and wind from region 5
if model_name == '1_region':
ts_data = ts_data.loc[:, ['demand_region5', 'wind_region5']]
ts_data.columns = ['demand', 'wind']
return ts_data
def detect_missing_leap_days(ts_data):
"""Detect if a time series has missing leap days.
Parameters:
-----------
ts_data (pandas DataFrame) : time series
"""
feb28_index = ts_data.index[(ts_data.index.year % 4 == 0)
& (ts_data.index.month == 2)
& (ts_data.index.day == 28)]
feb29_index = ts_data.index[(ts_data.index.year % 4 == 0)
& (ts_data.index.month == 2)
& (ts_data.index.day == 29)]
mar01_index = ts_data.index[(ts_data.index.year % 4 == 0)
& (ts_data.index.month == 3)
& (ts_data.index.day == 1)]
if len(feb29_index) < min((len(feb28_index), len(mar01_index))):
return True
return False
def get_scenario(run_mode, baseload_integer, baseload_ramping, allow_unmet):
"""Get the scenario name for different run settings.
Parameters:
-----------
run_mode (str) : 'plan' or 'operate': whether to let the model
determine the optimal capacities or work with prescribed ones
baseload_integer (bool) : activate baseload integer capacity
constraint (built in units of 3GW)
baseload_ramping (bool) : enforce baseload ramping constraint
allow_unmet (bool) : allow unmet demand in planning mode (always
allowed in operate mode)
Returns:
--------
scenario (str) : name of scenario to pass in Calliope model
"""
scenario = run_mode
if run_mode == 'plan' and not baseload_integer:
scenario = scenario + ',continuous'
if run_mode == 'plan' and baseload_integer:
scenario = scenario + ',integer'
if run_mode == 'plan' and allow_unmet:
scenario = scenario + ',allow_unmet'
if baseload_ramping:
scenario = scenario + ',ramping'
return scenario
def get_cap_override_dict(model_name, fixed_caps):
"""Create an override dictionary that can be used to set fixed
fixed capacities in a Calliope model run.
Parameters:
-----------
model_name (str) : '1_region' or '6_region'
fixed_caps (pandas Series/DataFrame or dict) : the fixed capacities.
A DataFrame created via model.get_summary_outputs will work.
Returns:
--------
o_dict (dict) : A dict that can be fed as override_dict into Calliope
model in operate mode
"""
if isinstance(fixed_caps, pd.DataFrame):
fixed_caps = fixed_caps.iloc[:, 0] # Change to Series
o_dict = {}
# Add baseload, peaking and wind capacities
if model_name == '1_region':
for tech, attribute in [('baseload', 'energy_cap_equals'),
('peaking', 'energy_cap_equals'),
('wind', 'resource_area_equals')]:
idx = ('locations.region1.techs.{}.constraints.{}'.
format(tech, attribute))
o_dict[idx] = fixed_caps['cap_{}_total'.format(tech)]
# Add baseload, peaking, wind and transmission capacities
if model_name == '6_region':
for region in ['region{}'.format(i+1) for i in range(6)]:
for tech, attribute in [('baseload', 'energy_cap_equals'),
('peaking', 'energy_cap_equals'),
('wind', 'resource_area_equals')]:
try:
idx = ('locations.{}.techs.{}_{}.constraints.{}'.
format(region, tech, region, attribute))
o_dict[idx] = (
fixed_caps['cap_{}_{}'.format(tech, region)]
)
except KeyError:
pass
for region_to in ['region{}'.format(i+1) for i in range(6)]:
tech = 'transmission'
idx = ('links.{},{}.techs.{}_{}_{}.constraints.energy_cap_equals'.
format(region, region_to, tech, region, region_to))
try:
o_dict[idx] = fixed_caps['cap_transmission_{}_{}'.
format(region, region_to)]
except KeyError:
pass
if len(o_dict.keys()) == 0:
raise AttributeError('Override dict is empty. Check if something '
'has gone wrong.')
return o_dict
def calculate_carbon_emissions(generation_levels):
"""Calculate total carbon emissions.
Parameters:
-----------
generation_levels (pandas DataFrame or dict) : generation levels
for the 4 technologies (baseload, peaking, wind and unmet)
"""
emissions_tot = \
EMISSION_INTENSITIES['baseload'] * generation_levels['baseload'] + \
EMISSION_INTENSITIES['peaking'] * generation_levels['peaking'] + \
EMISSION_INTENSITIES['wind'] * generation_levels['wind'] + \
EMISSION_INTENSITIES['unmet'] * generation_levels['unmet']
return emissions_tot
class ModelBase(calliope.Model):
"""Instance of either 1-region or 6-region model."""
def __init__(self, model_name, ts_data, run_mode,
baseload_integer=False, baseload_ramping=False,
allow_unmet=False, fixed_caps=None, extra_override=None,
run_id=0):
"""
Create instance of either 1-region or 6-region model.
Parameters:
-----------
model_name (str) : either '1_region' or '6_region'
ts_data (pandas DataFrame) : time series with demand and wind data.
It may also contain custom time step weights
run_mode (str) : 'plan' or 'operate': whether to let the model
determine the optimal capacities or work with prescribed ones
baseload_integer (bool) : activate baseload integer capacity
constraint (built in units of 3GW)
baseload_ramping (bool) : enforce baseload ramping constraint
allow_unmet (bool) : allow unmet demand in planning mode (always
allowed in operate mode)
fixed_caps (dict or Pandas DataFrame) : fixed capacities as override
extra_override (str) : name of additional override, to customise
model. The override should be defined in the relevant model.yaml
run_id (int) : can be changed if multiple models are run in parallel
"""
if model_name not in ['1_region', '6_region']:
raise ValueError('Invalid model name '
'(choose 1_region or 6_region)')
self.model_name = model_name
self.base_dir = os.path.join('models', model_name)
self.num_timesteps = ts_data.shape[0]
# Create scenarios and overrides
scenario = get_scenario(run_mode, baseload_integer,
baseload_ramping, allow_unmet)
if extra_override is not None:
scenario = ','.join((scenario, extra_override))
override_dict = (get_cap_override_dict(model_name, fixed_caps)
if fixed_caps is not None else None)
# Calliope requires a CSV file of the time series data to be present
# at time of initialisation. This creates a new directory with the
# model files and data for the model, then deletes it once the model
# exists in Python
self._base_dir_iter = self.base_dir + '_' + str(run_id)
if os.path.exists(self._base_dir_iter):
shutil.rmtree(self._base_dir_iter)
shutil.copytree(self.base_dir, self._base_dir_iter)
ts_data = self._create_init_time_series(ts_data)
ts_data.to_csv(os.path.join(self._base_dir_iter, 'demand_wind.csv'))
super(ModelBase, self).__init__(os.path.join(self._base_dir_iter,
'model.yaml'),
scenario=scenario,
override_dict=override_dict)
shutil.rmtree(self._base_dir_iter)
# Adjust weights if these are included in ts_data
if 'weight' in ts_data.columns:
self.inputs.timestep_weights.values = \
ts_data.loc[:, 'weight'].values
logging.info('Time series inputs:\n%s', ts_data)
logging.info('Override dict:\n%s', override_dict)
if run_mode == 'operate' and fixed_caps is None:
logging.warning('No fixed capacities passed into model call. '
'Will read fixed capacities from model.yaml')
def _create_init_time_series(self, ts_data):
"""Create demand and wind time series data for Calliope model
initialisation.
"""
# Avoid changing ts_data outside function
ts_data_used = ts_data.copy()
if self.model_name == '1_region':
expected_columns = {'demand', 'wind'}
elif self.model_name == '6_region':
expected_columns = {'demand_region2', 'demand_region4',
'demand_region5', 'wind_region2',
'wind_region5', 'wind_region6'}
if not expected_columns.issubset(ts_data.columns):
raise AttributeError('Input time series: incorrect columns')
# Detect missing leap days -- reset index if so
if detect_missing_leap_days(ts_data_used):
logging.warning('Missing leap days detected in input time series.'
'Time series index reset to start in 2020.')
ts_data_used.index = pd.date_range(start='2020-01-01',
periods=self.num_timesteps,
freq='h')
# Demand must be negative for Calliope
ts_data_used.loc[:, ts_data.columns.str.contains('demand')] = (
-ts_data_used.loc[:, ts_data.columns.str.contains('demand')]
)
return ts_data_used
class SixRegionModel(ModelBase):
"""Instance of 6-region power system model."""
def __init__(self, ts_data, run_mode, baseload_integer=False,
baseload_ramping=False, allow_unmet=False,
fixed_caps=None, extra_override=None, run_id=0):
"""Initialize model from ModelBase parent."""
super(SixRegionModel, self).__init__(
model_name='6_region',
ts_data=ts_data,
run_mode=run_mode,
baseload_integer=baseload_integer,
baseload_ramping=baseload_ramping,
allow_unmet=allow_unmet,
fixed_caps=fixed_caps,
extra_override=extra_override,
run_id=run_id
)
def get_summary_outputs(self):
"""Create pandas DataFrame of subset of relevant model outputs."""
assert hasattr(self, 'results'), \
'Model outputs have not been calculated: call self.run() first.'
outputs = pd.DataFrame(columns=['output']) # Output DataFrame
corrfac = (8760/self.num_timesteps) # For annualisation
# Insert model outputs at regional level
for region in ['region{}'.format(i+1) for i in range(6)]:
# Baseload and peaking capacity
for tech in ['baseload', 'peaking']:
try:
outputs.loc['cap_{}_{}'.format(tech, region)] = (
float(self.results.energy_cap.loc[
'{}::{}_{}'.format(region, tech, region)
])
)
except KeyError:
pass
# Wind capacity
for tech in ['wind']:
try:
outputs.loc['cap_{}_{}'.format(tech, region)] = (
float(self.results.resource_area.loc[
'{}::{}_{}'.format(region, tech, region)
])
)
except KeyError:
pass
# Peak unmet demand
for tech in ['unmet']:
try:
outputs.loc['peak_unmet_{}'.format(region)] = (
float(self.results.carrier_prod.loc[
'{}::{}_{}::power'.format(region, tech, region)
].max())
)
except KeyError:
pass
# Transmission capacity
for tech in ['transmission']:
for region_to in ['region{}'.format(i+1) for i in range(6)]:
# No double counting of links -- one way only
if int(region[-1]) < int(region_to[-1]):
try:
outputs.loc['cap_transmission_{}_{}'.format(
region, region_to
)] = float(self.results.energy_cap.loc[
'{}::{}_{}_{}:{}'.format(region,
tech,
region,
region_to,
region_to)
])
except KeyError:
pass
# Baseload, peaking, wind and unmet generation levels
for tech in ['baseload', 'peaking', 'wind', 'unmet']:
try:
outputs.loc['gen_{}_{}'.format(tech, region)] = (
corrfac * float(
(self.results.carrier_prod.loc[
'{}::{}_{}::power'.format(region,
tech,
region)]
*self.inputs.timestep_weights).sum()
)
)
except KeyError:
pass
# Demand levels
try:
outputs.loc['demand_{}'.format(region)] = -corrfac * float(
(self.results.carrier_con.loc[
'{}::demand_power::power'.format(region)]
* self.inputs.timestep_weights).sum()
)
except KeyError:
pass
# Insert total capacities
for tech in ['baseload', 'peaking', 'wind', 'transmission']:
outputs.loc['cap_{}_total'.format(tech)] = outputs.loc[
outputs.index.str.contains('cap_{}'.format(tech))
].sum()
outputs.loc['peak_unmet_total'] = outputs.loc[
outputs.index.str.contains('peak_unmet')
].sum()
# Insert total peak unmet demand -- not necessarily equal to
# peak_unmet_total. Total unmet capacity sums peak unmet demand
# across regions, whereas this is the systemwide peak unmet demand
outputs.loc['peak_unmet_systemwide'] = float(self.results.carrier_prod.loc[
self.results.carrier_prod.loc_tech_carriers_prod.str.contains(
'unmet')].sum(axis=0).max())
# Insert total annualised generation and unmet demand levels
for tech in ['baseload', 'peaking', 'wind', 'unmet']:
outputs.loc['gen_{}_total'.format(tech)] = outputs.loc[
outputs.index.str.contains('gen_{}'.format(tech))
].sum()
# Insert total annualised demand levels
outputs.loc['demand_total'] = (
outputs.loc[outputs.index.str.contains('demand')].sum()
)
# Insert annualised total system cost
outputs.loc['cost_total'] = corrfac * float(self.results.cost.sum())
# Insert annualised carbon emissions
outputs.loc['emissions_total'] = calculate_carbon_emissions(
generation_levels={
'baseload': outputs.loc['gen_baseload_total'],
'peaking': outputs.loc['gen_peaking_total'],
'wind': outputs.loc['gen_wind_total'],
'unmet': outputs.loc['gen_unmet_total']
}
)
return outputs
if __name__ == '__main__':
raise NotImplementedError()