-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmicrogrid_optimizer.py
executable file
·2296 lines (1941 loc) · 104 KB
/
microgrid_optimizer.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
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
Optimization class for simulating, filtering and ranking microgrid
systems.
File contents:
Classes:
Optimizer
GridSearchOptimizer (inherits from Optimizer)
Standalone functions:
get_electricity_rate
"""
import json
from logging import raiseExceptions
import multiprocessing
import os
import sys
import urllib
import copy
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
import tabulate
from geopy.geocoders import Nominatim
from generate_solar_profile import SolarProfileGenerator
from generate_tidal_profile import TidalProfileGenerator
from microgrid_simulator import REBattGenSimulator
from microgrid_system import PV, Wave, Tidal, SimpleLiIonBattery, SimpleMicrogridSystem, \
GeneratorGroup
from validation import validate_all_parameters, log_error, annual_load_profile_warnings
from constants import system_metrics, pv_metrics, battery_metrics, mre_metrics, \
generator_metrics, re_metrics, metric_order_size_gen, metric_order_existing_gen
from config import OUTPUT_DIR
class Optimizer:
""" Parent optimization class """
def next_system(self):
pass
def run_sims(self):
pass
class GridSearchOptimizer(Optimizer):
"""
Simulates a grid of microgrid systems, and for each system, runs all
scenario profiles.
Parameters
----------
power_profiles: dictionary of lists of Pandas series' with RE power profiles
for a 1kW system, also includes list of Pandas dataframes with info on whether
it is night if a PV system is included
annual_load_profile: Pandas series with a full year-long load
profile. It must have a DateTimeIndex with a timezone.
location: dictionary with the following keys and value
datatypes:
{'longitude': float, 'latitude': float, 'timezone': string,
'altitude': float}
tmy_solar: TMY solar pv production time series in kwh, only included if pv considered
tmy_mre: TMY mre production time series in kwh, only included if mre considered
pv_params: dictionary with the following keys and value
datatypes:
{'tilt': float, 'azimuth': float, 'module_capacity': float,
'module_area': float (in square inches),
'pv_racking': string (options: [roof, ground, carport]),
Default = ground,
'pv_tracking': string (options: [fixed, single_axis]),
'advanced_inputs': dict (currently does nothing)}
mre_params: dictionary with the following keys and value
datatypes:
{'generator_type': str,
'generator_capacity': float
device_name: str}
battery_params: dictionary with the following keys and value
datatypes:
{'battery_power_to_energy': float, 'initial_soc': float,
'one_way_battery_efficiency': float,
'one_way_inverter_efficiency': float,
'soc_upper_limit': float, 'soc_lower_limit': float,
'init_soc_lower_limit': float}
# TODO: update with wave costs
system_costs: dictionary containing the following Pandas
dataframes:
pv_costs: cost of PV per Watt, with the upper limit for pv
sizes as the columns:
100, 5000, 100000
and the pv racking and tracking type as the rows:
ground;fixed: 2.78, 1.64, 0.83
ground;single_axis: 2.97, 1.75, 0.89
roof;fixed: 2.65, 1.56, 0.83
carport;fixed: 3.15, 1.86, 0.83
tidal_costs: cost of tidal turbines per project/device, with the
following columns:
Project/Device Name
Rated Power (kW)
Turbine Count
Rotor Diameter (m)
Rotors per Turbine
Cost (USD)
om_costs: operations and maintenance costs for the following
components:
Generator ($/kW-yr): scalar - 102.65, exponent - 0.669
Battery ($/kW-yr): 2.41
PV_ground;fixed ($/kW-yr): 15
PV_ground;single_axis ($/kW-yr): 18
PV_roof;fixed ($/kW-yr): 17.50
PV_carport;fixed ($/kW-yr): 12.50
battery_costs: costs for the battery:
Battery System ($/Wh): 0.521
Inverter and BOS ($/W): 0.401
fuel_tank_costs: costs for fuel tanks based on size (gal)
generator_costs: list of possible generators, with the
following columns:
Power: Generator rated power
1/4 Load (gal/hr), 1/2 Load (gal/hr), 3/4 Load (gal/hr), Full Load (gal/hr):
loading levels for the generator fuel curve
Cost (USD): cost for specific generator size
duration: Timestep duration in seconds.
Default: 3600 (1 hour)
dispatch_strategy: determines the battery dispatch strategy.
Options include:
night_const_batt (constant discharge at night)
night_dynamic_batt (updates the discharge rate based on
remaining available capacity)
available_capacity (does not reserve any battery during specific times)
Default: night_dynamic_batt
batt_sizing_method: method for sizing the battery. Options are:
- longest_night
- no_RE_export
- unmet_load
Default = longest_night
electricity_rate: Local electricity rate in $/kWh. If it is set
to None, the rate is determined by looking up the average
state rate found here:
https://www.eia.gov/electricity/state/
Default = None
net_metering_rate: Rate in $/kWh used for calculating exported
RE revenue. If it is set to None, the rate is assumed to be
the same as the electricity rate.
Default = None
demand_rate: Demand charge rate in $/kW used for calculating RE
system revenue. Can be either a number or a list of 12
numbers, one for each month. If it is set to None, no demand
charge savings are calculated.
net_metering_limits: Dictionary specifying local limits for the
net-metering policy in the form of:
{type: ['capacity_cap' or 'percent_of_load'],
value: [<kW value> or <percentage>]}
Default = None
generator_buffer: Buffer between chosen generator size and
maximum required power. E.g. a buffer of 1.1 means a
generator has to be sized 10% larger than the maximum power.
Default = 1.1
existing_components: Dictionary containing Component objects for
equipment already on site in the form:
{'pv': <PV Object>, 'generator': <Generator Object>}
filter: lists any filtering criteria that have been applied to
results.
Default = None
rank: lists any ranking criteria that have been applied to
results.
Default = None
off_grid_load_profile: load profile to be used for off-grid
operation. If this parameter is not set to None, the
annual_load_profile is used to size the RE system and
calculate annual revenue, and this profile is used to size
the battery and generator and calculate resilience metrics.
Default = None
Methods
----------
size_RE_system: Sizes RE system with at least MRE and potentially
PV as well.
size_PV_for_netzero: Sizes PV system according to net zero and
incrementally smaller sizes
size_batt_by_longest_night: Sizes the battery system according
to the longest night of the year.
size_batt_for_no_RE_export: Sizes the battery such that no
excess RE is exported to the grid during normal operation.
size_batt_for_unmet_load: Sizes the battery such that it is
big enough to meet the largest daily load unmet by RE
create_new_system: Create a new SimpleMicrogridSystem to add to
the system grid
define_grid: Defines the grid of system sizes to consider
print_grid: Prints out the sizes in a grid of system
configurations
get_load_profiles: For each solar profile, extracts the load
profile from annual_load_profile for the corresponding
dates/times
next_system: Pops and returns the next system in the
input_system_grid list
run_sims: Runs the simulations for all systems and profiles
run_sims_par: Run the simulations for all systems and profiles
using Python's multiprocessing package
aggregate_by_system: Runs the simulation for a given system
configuration for multiple resource profiles and
aggregates the results
parse_results: Parses simulation results into a dataframe
filter_results: Filters the results_grid dataframe by specified
constraints
rank_results: Ranks the results_grid dataframe by specified
ranking criteria
print_systems_results: Returns info about each of the systems
from the results grid
plot_best_system: Displays dispatch and load duration plots for
3 systems (best in terms of ranking, best with battery,
and system with least fuel usage)
add_system: Add a specific system to the input list
get_system: Return a specific system based on its name
get_input_systems: Returns the dictionary of input systems
get_output_systems: Returns the dictionary of output systems
format_inputs: Formats the inputs into dicts for writing to file
save_results_to_file: Saves inputs, assumptions, and results to
an excel file
save_timeseries_to_json: Saves time series data to a json file
Calculated Attributes
----------
load_profiles: List of load profiles for the corresponding
outage periods
input_system_grid: Dictionary of MicrogridSystem objects to
simulate
output_system_grid: Dictionary of already simulated
MicrogridSystem objects
results_grid: Pandas dataframe containing output results from
the simulations, with one row per system
"""
def __init__(self, renewable_resources, power_profiles, annual_load_profile, location,
battery_params, system_costs, re_constraints, duration=3600,
pv_params=None, mre_params=None,
tmy_solar=None, tmy_mre=None,
size_re_resources_based_on_tmy=True,
size_battery_based_on_tmy=True,
size_resources_with_battery_eff_term=True,
dispatch_strategy='night_dynamic_batt',
batt_sizing_method='longest_night', electricity_rate=None,
net_metering_rate=None, demand_rate=None,
net_metering_limits=None,
generator_buffer=1.1,
existing_components={},
off_grid_load_profile=None,
output_tmy=False, validate=True):
self.renewable_resources = renewable_resources
self.power_profiles = power_profiles
self.annual_load_profile = annual_load_profile
self.location = location
self.tmy_solar = tmy_solar
self.tmy_mre = tmy_mre
self.pv_params = pv_params
self.mre_params = mre_params
self.size_re_resources_based_on_tmy = size_re_resources_based_on_tmy
self.size_battery_based_on_tmy = size_battery_based_on_tmy
self.size_resources_with_battery_eff_term = size_resources_with_battery_eff_term
self.battery_params = battery_params
self.system_costs = system_costs
self.re_constraints = re_constraints
self.duration = duration
self.dispatch_strategy = dispatch_strategy
self.batt_sizing_method = batt_sizing_method
self.electricity_rate = electricity_rate
self.net_metering_rate = net_metering_rate
self.demand_rate = demand_rate
self.net_metering_limits = net_metering_limits
self.generator_buffer = generator_buffer
self.existing_components = existing_components
self.off_grid_load_profile = off_grid_load_profile
self.output_tmy = output_tmy
self.load_profiles = []
self.input_system_grid = {} # Dict of MicrogridSystem objects
self.output_system_grid = {}
self.results_grid = None
self.filter = None
self.rank = None
if validate:
# List of initialized parameters to validate
args_dict = {'renewable_resources': renewable_resources,
'power_profiles': power_profiles,
'annual_load_profile': annual_load_profile,
'location': location,
'battery_params': battery_params,
'duration': duration,
'dispatch_strategy': dispatch_strategy,
'batt_sizing_method': batt_sizing_method,
'system_costs': system_costs,
'size_re_resources_based_on_tmy': size_re_resources_based_on_tmy,
'size_battery_based_on_tmy': size_battery_based_on_tmy,
'size_resources_with_battery_eff_term': size_resources_with_battery_eff_term}
if pv_params is not None:
args_dict['pv_params'] = pv_params
if tmy_solar is not None:
args_dict['tmy_solar'] = tmy_solar
if mre_params is not None:
args_dict['mre_params'] = mre_params
if tmy_mre is not None:
args_dict['tmy_mre'] = tmy_mre
if electricity_rate is not None:
args_dict['electricity_rate'] = electricity_rate
if net_metering_rate is not None:
args_dict['net_metering_rate'] = net_metering_rate
if demand_rate is not None:
args_dict['demand_rate_list'] = demand_rate
if net_metering_limits is not None:
args_dict['net_metering_limits'] = net_metering_limits
if len(existing_components):
args_dict['existing_components'] = existing_components
if off_grid_load_profile is not None:
args_dict['off_grid_load_profile'] = off_grid_load_profile
# Validate input parameters
validate_all_parameters(args_dict)
# Ensure that pv_params, mre_params, tmy_solar, and tmy_mre are not none if the
# corresponding resource is included in renewable_resources
if 'pv' in renewable_resources:
if pv_params is None or tmy_solar is None:
message = 'If a pv system is included in the considered resources, then both ' \
'pv_params and tmy_solar must be included as non-null inputs.'
log_error(message)
raise Exception(message)
if 'mre' in renewable_resources:
if mre_params is None or tmy_mre is None:
message = 'If an mre system is included in the considered resources, then both ' \
'mre_params and tmy_mre must be included as non-null inputs.'
log_error(message)
raise Exception(message)
# De-localize timezones from profiles
for re_resource, profiles in self.power_profiles.items():
for profile in profiles:
profile.index = profile.index.map(lambda x: x.tz_localize(None))
if tmy_solar is not None:
tmy_solar.index = tmy_solar.index.map(lambda x: x.tz_localize(None))
if tmy_mre is not None:
tmy_mre.index = tmy_mre.index.map(lambda x: x.tz_localize(None))
# Fix annual load profile index
self.annual_load_profile.index = pd.date_range(
start='1/1/2017', end='1/1/2018',
freq='{}S'.format(int(self.duration)))[:-1]
if self.off_grid_load_profile is not None:
self.off_grid_load_profile.index = self.annual_load_profile.index
if validate:
# Check for warnings
annual_load_profile_warnings(self.annual_load_profile)
if self.off_grid_load_profile is not None:
annual_load_profile_warnings(self.off_grid_load_profile)
# Get electricity rate data if a rate is not specified
if self.electricity_rate is None:
self.electricity_rate = get_electricity_rate(self.location,
validate=False)
# If no PV is included, ensure that dispatch strategy is set to 'available_capacity'
if 'pv' not in self.renewable_resources:
self.dispatch_strategy = 'available_capacity'
def size_RE_system(self, include_pv, include_mre):
"""
Sizes Renewable Energy components. Assumes there is an included MRE system and potentially
PV as well. The components are sized according to the following methodology:
1. Add MRE to the system in increments of 1 turbine up to either the max capacity of
MRE allowed, the max total RE capacity allowed, or the net-zero capacity.
2. Add PV (if included) to each MRE configuration such that it meets all of the
remaining load or reaches the max PV capacity or total RE capacity allowed.
"""
# Set up dictionary with sizes
re_sizes = {}
# Determine if sizing is based on TMY or profiles
if self.size_re_resources_based_on_tmy:
# Get the total annual load, mre production and pv production
total_load = self.annual_load_profile.sum()
if self.size_resources_with_battery_eff_term:
total_load = total_load / (self.battery_params['one_way_battery_efficiency']**2
* self.battery_params['one_way_inverter_efficiency']**2)
total_mre = self.tmy_mre.sum()
if self.pv_params:
total_pv = self.tmy_solar.sum()
else:
# Get the total load, mre and pv production from the profiles
total_load = np.sum([profile.sum() for profile in self.load_profiles])
if self.size_resources_with_battery_eff_term:
total_load = total_load / (self.battery_params['one_way_battery_efficiency']**2
* self.battery_params['one_way_inverter_efficiency']**2)
total_mre = np.sum([profile.sum() for profile in self.power_profiles['mre']])
if self.pv_params:
total_pv = np.sum([profile.sum() for profile in self.power_profiles['pv']])
# Calculate the total annual energy generated by one MRE turbine
total_annual_mre_per_turbine = total_mre * self.mre_params['generator_capacity']
# Add systems with increasing number of turbines up to max (set by load or constraints)
num_turbines = 1
while True:
mre_capacity = self.mre_params['generator_capacity'] * num_turbines
# Check if exceeded max mre or total re capacity
if ('mre' in self.re_constraints and mre_capacity > self.re_constraints['mre']) or \
('total' in self.re_constraints
and mre_capacity > self.re_constraints['total']):
break
# Add a system with current turbine number
re_sizes[f'{num_turbines}_mre_turbine'] = {'mre': mre_capacity}
# Check if total energy generated exceeds annual load and if so, stop adding new
# systems
if total_annual_mre_per_turbine * num_turbines > total_load:
break
# Add another turbine
num_turbines += 1
# Check include_mre and add another system if not already included
mre_nums = [mre_name.split('_')[0] for mre_name in re_sizes]
for mre_num in include_mre:
if mre_num not in mre_nums:
re_sizes[f'{mre_num}_mre_turbine'] = \
{'mre': mre_num * self.mre_params['generator_capacity']}
# Check existing components for mre and if included, use this as the minimum number of turbines
if 'mre' in self.existing_components:
existing_mre_num = self.existing_components['mre'].num_generators
mre_nums = [int(mre_name.split('_')[0]) for mre_name in re_sizes]
re_size_names = list(re_sizes.keys())
for re_size_name in re_size_names:
mre_num = int(re_size_name.split('_')[0])
# If less than existing mre_num then remove system
if mre_num < existing_mre_num:
re_sizes.pop(re_size_name)
# Add existing mre_num if not included
if existing_mre_num not in mre_nums:
re_sizes[f'{existing_mre_num}_mre_turbine'] = \
{'mre': existing_mre_num * self.mre_params['generator_capacity']}
# If PV is included, add one system with all PV and for the other systems, calculate the
# required capacity to reach NZ
if self.pv_params:
# Add PV to each MRE system
for system_name, system_sizes in re_sizes.items():
remaining_load = np.max([total_load - total_mre * system_sizes['mre'], 0])
pv_capacity = remaining_load / total_pv
if np.isnan(pv_capacity):
pv_capacity = 0
# Check that pv size is less than PV constraint and total RE capacity is less
# than total RE constraint
if 'pv' in self.re_constraints and pv_capacity > self.re_constraints['pv']:
pv_capacity = self.re_constraints['pv']
if 'total' in self.re_constraints \
and pv_capacity + system_sizes['mre'] > self.re_constraints['total']:
pv_capacity = self.re_constraints['total'] - system_sizes['mre']
# Add pv capacity to system sizes
re_sizes[system_name]['pv'] = pv_capacity
# Check that net-zero pv size is less than PV and RE capacity constraint
net_zero_pv = total_load / total_pv
if 'pv' in self.re_constraints and net_zero_pv > self.re_constraints['pv']:
net_zero_pv = self.re_constraints['pv']
if 'total' in self.re_constraints and net_zero_pv > self.re_constraints['total']:
net_zero_pv = self.re_constraints['total']
# Add a system with only PV
re_sizes['nz_pv'] = {'pv': net_zero_pv, 'mre': 0}
# If include pv is set, add pv sizes to each mre size
for pv_size in include_pv:
re_size_names = list(re_sizes.keys())
for re_size_name in re_size_names:
re_sizes[f'{re_size_name}_{pv_size}pv'] = \
{'pv': pv_size, 'mre': re_sizes[re_size_name]['mre']}
# If pv size in existing components, set pv size as minimum for each system
if 'pv' in self.existing_components:
for re_size_name, re_size in re_sizes.items():
if re_size['pv'] < self.existing_components['pv'].capacity:
re_sizes[re_size_name]['pv'] = self.existing_components['pv'].capacity
return re_sizes
def size_PV_for_netzero(self):
"""
Sizes PV system according to net zero and incrementally smaller
sizes.
The maximum PV size is determined by the net-zero case
(annual solar production = annual load) plus solar in excess
of load times the system RTE losses, with smaller sizes
calculated as % of net zero size:
- Maximum size = net-zero + excess*(1-RTE)
- Net-zero size
- Net-zero * 50%
- Net-zero * 25%
- 0 PV
"""
# Determine if sizing is based on TMY or profiles
if self.size_re_resources_based_on_tmy:
# Get the total annual load and pv production
total_load = self.annual_load_profile.sum()
if self.size_resources_with_battery_eff_term:
total_load = total_load / (self.battery_params['one_way_battery_efficiency']**2
* self.battery_params['one_way_inverter_efficiency']**2)
total_pv = self.tmy_solar.sum()
else:
# Get the total load and pv production from the profiles
total_load = np.sum([profile.sum() for profile in self.load_profiles])
if self.size_resources_with_battery_eff_term:
total_load = total_load / (self.battery_params['one_way_battery_efficiency']**2
* self.battery_params['one_way_inverter_efficiency']**2)
total_pv = np.sum([profile.sum() for profile in self.power_profiles['pv']])
net_zero = total_load / total_pv
# Calculate round-trip efficiency based on battery and inverter
# efficiency
system_rte = self.battery_params['one_way_battery_efficiency'] ** 2 \
* self.battery_params['one_way_inverter_efficiency'] ** 2
# Calculate the amount of pv energy lost through
# charging/discharging batteries at the net zero capacity
losses = pd.Series(self.tmy_solar.values * net_zero -
self.annual_load_profile.values)
losses.loc[losses < 0] = 0
total_lost = losses.sum() * (1 - system_rte)
# Maximum (net-zero) solar size is based on scaling total annual
# solar to equal annual load plus losses
max_cap = (total_load + total_lost) / total_pv
# Filter out any sizes above total or pv constraint
sizes = [max_cap, net_zero, net_zero * 0.5, net_zero * 0.25]
sizes_with_pv_constraint = []
if 'pv' in self.re_constraints:
for size in sizes:
if size <= self.re_constraints['pv']:
sizes_with_pv_constraint += [size]
else:
sizes_with_pv_constraint = sizes
sizes_with_total_constraint = []
if 'total' in self.re_constraints:
for size in sizes_with_pv_constraint:
if size <= self.re_constraints['total']:
sizes_with_total_constraint += [size]
else:
sizes_with_total_constraint = sizes_with_pv_constraint
if not len(sizes_with_total_constraint):
raise Exception('ERROR: no PV sizes were found below the specified PV or total capacity constraint. Try raising the constraint capacity.')
# Create grid based on max, min and standard intervals
return sizes_with_total_constraint
def size_batt_by_longest_night(self, load_profile):
"""
Sizes the battery system according to the longest night of the
year.
The maximum battery capacity is determined by the load of the
highest load night, with the power determined by a fixed
power to energy ratio. Smaller sizes are calculated as a
fraction of the maximum size, also with a fixed power to
energy ratio:
- Maximum size = capacity for longest night
- Maximum size * 75%
- Maximum size * 50%
- Maximum size * 25%
- O ES
"""
# Get nighttime load based on TMY pv power
night_df = load_profile.to_frame(name='load')
night_df['pv_power'] = self.tmy_solar.values
# Set daytime load to 0
night_df.loc[night_df['pv_power'] > 0, 'load'] = 0
# Get date (without hour) for each timestep
night_df['day'] = night_df.index.day
night_df['month'] = night_df.index.month
# Add up the energy for each night (since this is done by
# calendar date, these aren't technically continuous nights,
# but this whole calculation is an approximation anyway)
max_nightly_energy = night_df.groupby(['month', 'day'])['load']. \
sum().max()
# Maximum battery capacity = max nightly load / OWE
system_owe = self.battery_params['one_way_battery_efficiency'] \
* self.battery_params['one_way_inverter_efficiency']
max_cap = max_nightly_energy / system_owe
max_pow = max_cap * self.battery_params['battery_power_to_energy']
return [(max_cap, max_pow),
(max_cap * 0.75, max_pow * 0.75),
(max_cap * 0.5, max_pow * 0.5),
(max_cap * 0.25, max_pow * 0.25),
(0, 0)]
def _calculate_excess_RE(self, re_sizes):
"""
Calculates excess RE that can be used to charge a battery and load not met,
using either TMY data or individual profiles
Args:
re_sizes (dict): dictionary of PV and MRE capacities, with key as system name and
values as dictionaries with keys 'pv' and/or 'mre'
"""
# Determine if sizing is based on TMY or profiles
if self.size_battery_based_on_tmy:
# Get the total annual load and pv production
excess_re = self.annual_load_profile.copy(deep=True).to_frame(name='load')
if self.pv_params:
excess_re['pv_base'] = self.tmy_solar.values
else:
excess_re['pv_base'] = 0
if self.mre_params:
excess_re['mre_base'] = self.tmy_mre.values
else:
excess_re['mre_base'] = 0
else:
# Get the total load and pv production from the profiles
excess_re = pd.concat(self.load_profiles).to_frame(name='load')
if self.pv_params:
excess_re['pv_base'] = pd.concat(self.power_profiles['pv']).values
else:
excess_re['pv_base'] = 0
if self.mre_params:
excess_re['mre_base'] = pd.concat(self.power_profiles['mre']).values
else:
excess_re['mre_base'] = 0
# Calculate excess RE production for each system
for system_name, sizes in re_sizes.items():
excess_re[system_name] = excess_re['pv_base'] * sizes['pv'] \
+ excess_re['mre_base'] * sizes['mre']
excess_re[f'{system_name}_exported'] = excess_re[system_name] \
- excess_re['load']
excess_re[f'{system_name}_loadnotmet'] = excess_re['load'] \
- excess_re[system_name]
excess_re[excess_re < 0] = 0
# Calculate battery power as the maximum exported RE power
power = excess_re.max()
# Group load not met by day
excess_re['day'] = excess_re.index.date
cap = excess_re.groupby('day').sum().max()
return power, cap
def size_batt_for_no_RE_export(self, re_sizes):
"""
Sizes the battery such that no excess renewable energy is exported to the grid during
normal operation.
Args:
re_sizes (dict): dictionary of PV and MRE capacities, with key as system name and
values as dictionaries with keys 'pv' and/or 'mre'
"""
# Calculate excess RE
power, cap = self._calculate_excess_RE(re_sizes)
return {system_name: (round(cap[f'{system_name}_exported'] *
self.battery_params['one_way_inverter_efficiency'] *
self.battery_params['one_way_battery_efficiency'], 2),
round(power[f'{system_name}_exported'] *
self.battery_params['one_way_inverter_efficiency'], 2))
for system_name in re_sizes.keys()}
def size_batt_for_unmet_load(self, re_sizes):
"""
Sizes the battery such that the capacity is the maximum daily load not met by RE and the
power is the maximum excess hourly RE.
Args:
re_sizes (dict): dictionary of PV and MRE capacities, with key as system name and
values as dictionaries with keys 'pv' and/or 'mre'
"""
# Calculate excess RE
power, cap = self._calculate_excess_RE(re_sizes)
return {system_name: (round(cap[f'{system_name}_loadnotmet'] /
(self.battery_params['one_way_inverter_efficiency'] *
self.battery_params['one_way_battery_efficiency']), 2),
round(power[f'{system_name}_exported'] /
self.battery_params['one_way_inverter_efficiency'], 2))
for system_name in re_sizes.keys()}
def size_batt_for_no_pv_export(self, pv_sizes, load_profile):
"""
Sizes the battery such that no excess PV is exported to the grid
during normal operation.
"""
# Calculate excess PV production for each PV size
excess_pv = load_profile.to_frame(name='load')
excess_pv['pv_base'] = self.tmy_solar.values
for size in pv_sizes:
excess_pv[int(size)] = excess_pv['pv_base'] * size
excess_pv['{}_exported'.format(int(size))] = excess_pv[int(size)]\
- excess_pv['load']
excess_pv[excess_pv < 0] = 0
# Calculate battery power as the maximum exported PV power
power = excess_pv.max()
# Calculate capacity as the maximum daily exported PV energy
excess_pv['day'] = excess_pv.index.date
cap = excess_pv.groupby('day').sum().max()
return [(round(cap['{}_exported'.format(int(size))] *
self.battery_params['one_way_inverter_efficiency'] *
self.battery_params['one_way_battery_efficiency'], 2),
round(power['{}_exported'.format(int(size))] *
self.battery_params['one_way_inverter_efficiency'], 2))
for size in pv_sizes]
# TODO - Update when MRE params known
def create_new_system(self, pv_size, mre_size, battery_size):
"""
Create a new SimpleMicrogridSystem to add to the system grid.
"""
component_list = []
system_name_list = []
# Create PV object
if self.pv_params:
pv = PV('pv' in self.existing_components, pv_size,
self.pv_params['tilt'], self.pv_params['azimuth'],
self.pv_params['module_capacity'],
self.pv_params['module_area'],
self.pv_params['spacing_buffer'],
self.pv_params['pv_tracking'],
self.pv_params['pv_racking'],
self.pv_params['advanced_inputs'], validate=False)
component_list += [pv]
system_name_list += ['pv_{:.1f}kW'.format(pv_size)]
# Create MRE object
# TODO - update when we have the final list of params
if self.mre_params:
if self.mre_params['generator_type'] == 'tidal':
mre = Tidal('mre' in self.existing_components, mre_size,
self.mre_params['generator_capacity'],
self.mre_params['device_name'],
validate=False)
component_list += [mre]
system_name_list += ['tidal_{:.1f}kW'.format(mre_size)]
elif self.mre_params['generator_type'] == 'wave':
mre = Wave('mre' in self.existing_components, mre_size,
self.mre_params['num_generators'],
self.mre_params['generator_capacity'],
self.mre_params['wave_inputs'],
validate=False)
component_list += [mre]
system_name_list += ['wave_{:.1f}kW'.format(mre_size)]
# Create Battery object
batt = SimpleLiIonBattery(
'battery' in self.existing_components, battery_size[1],
battery_size[0], self.battery_params['initial_soc'],
self.battery_params['one_way_battery_efficiency'],
self.battery_params['one_way_inverter_efficiency'],
self.battery_params['soc_upper_limit'],
self.battery_params['soc_lower_limit'], validate=False)
component_list += [batt]
system_name_list += ['batt_{:.1f}kW_{:.1f}kWh'.format(battery_size[1], battery_size[0])]
# Determine system name
system_name = '_'.join(system_name_list)
# Create system object
system = SimpleMicrogridSystem(system_name)
# Add components to system
for component in component_list:
system.add_component(component, validate=False)
return system_name, system
def define_grid(self, include_pv=(), include_batt=(), include_mre=(), validate=True):
"""
Defines the grid of system sizes to consider.
Parameters:
include_pv: list of pv sizes to be added to the grid (in kW)
include_mre: list of mre sizes to be added to the grid (in number of turbines)
include_batt: list of battery sizes to be added to the grid
in the form of a tuple:
(batt capacity, batt power) in (kWh, kW)
"""
if validate:
# List of initialized parameters to validate
args_dict = {}
if len(include_pv):
args_dict['include_pv'] = include_pv
if len(include_batt):
args_dict['include_batt'] = include_batt
if len(include_mre):
args_dict['include_mre'] = include_mre
if len(args_dict):
# Validate input parameters
validate_all_parameters(args_dict)
# If marine renewables are included, use method that can accept different types of RE
if self.mre_params:
re_sizes = self.size_RE_system(include_pv, include_mre)
else:
# Size the pv system based on load and pv power
pv_range = self.size_PV_for_netzero()
# Add any sizes in include_pv
for size in include_pv:
pv_range += [size]
# If there is an existing pv system, use this to inform ranges
if 'pv' in self.existing_components:
# Use the current PV size as the minimum
min_cap = self.existing_components['pv'].capacity
# If it is not currently in the range, add it
if self.existing_components['pv'].capacity not in pv_range:
pv_range += [self.existing_components['pv'].capacity]
else:
min_cap = 0
# Get rid of any pv sizes smaller than the minimum (e.g. from
# existing system)
pv_range = [elem for elem in pv_range if elem >= min_cap]
# Determine which method to use for sizing the battery
if self.mre_params:
# Size battery to capture all excess RE generation
if self.batt_sizing_method == 'no_RE_export':
batt_range = self.size_batt_for_no_RE_export(re_sizes)
elif self.batt_sizing_method == 'unmet_load':
batt_range = self.size_batt_for_unmet_load(re_sizes)
else:
# Add error about wrong label
message = 'Invalid battery sizing method, for systems with MRE, you must choose no_re_export or unmet_load'
log_error(message)
raise Exception(message)
# Add add'l systems for battery to capture 75% and 50% of excess energy
re_sizes_new = {}
batt_range_new = {}
for system_name, re_size in re_sizes.items():
batt_size = batt_range[system_name]
for batt_percent in [1, .75, .50]:
re_sizes_new[f'{system_name}_{batt_percent}batt'] = re_size
batt_range_new[f'{system_name}_{batt_percent}batt'] = \
tuple(b_size * batt_percent for b_size in batt_size)
# Add and any battery sizes from include_batt list
for batt_size in include_batt:
re_sizes_new[f'{system_name}_{batt_size}batt'] = re_size
batt_range_new[f'{system_name}_{batt_size}batt'] = batt_size
re_sizes = re_sizes_new
batt_range = batt_range_new
# If batteries are included in existing components, make this size the minimum
if 'batt' in self.existing_components:
for re_size_name, batt_size in batt_range.items():
if batt_size[0] < self.existing_components['batt'].batt_capacity:
batt_range[re_size_name] = (self.existing_components['batt'].batt_capacity,
self.existing_components['batt'].power)
elif self.batt_sizing_method == 'longest_night':
# Determine which load profile to use for sizing the battery
if self.off_grid_load_profile is None:
batt_sizing_load = self.annual_load_profile.copy(deep=True)
else:
batt_sizing_load = self.off_grid_load_profile.copy(deep=True)
# Determine maximum battery size based on "worst" night (night
# with highest load)
batt_range = self.size_batt_by_longest_night(batt_sizing_load)
elif self.batt_sizing_method == 'no_RE_export':
# Size battery to capture all excess PV generation
batt_range = self.size_batt_for_no_pv_export(
pv_range, self.annual_load_profile.copy(deep=True))
# Add add'l systems for battery to capture 75% and 50% of excess energy
pv_range_new = []
batt_range_new = []
for i, pv_size in enumerate(pv_range):
batt_size = batt_range[i]
for batt_percent in [1, .75, .50]:
pv_range_new += [pv_size]
batt_range_new += [tuple(b_size * batt_percent for b_size in batt_size)]
# Add and any battery sizes from include_batt list
for batt_size in include_batt:
pv_range_new += [pv_size]
batt_range_new += [batt_size]
pv_range = pv_range_new
batt_range = batt_range_new
else:
# Add error about wrong label
message = 'Invalid battery sizing method'
log_error(message)
raise Exception(message)
# Add any sizes in include_batt
# Note: this will not have an effect for the no pv export battery
# sizing methodology
if self.mre_params is None:
for size in include_batt:
batt_range += [size]
# Create MicrogridSystem objects for each system
if self.mre_params:
for system_name in re_sizes:
system_name, system = self.create_new_system(re_sizes[system_name]['pv'],
re_sizes[system_name]['mre'],
batt_range[system_name])
self.input_system_grid[system_name] = system
elif self.batt_sizing_method == 'longest_night':
for pv_size in pv_range:
for battery_size in batt_range:
# Add system to input system dictionary