Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug Fix Load Following Dispatch Strategy #341

Merged
merged 12 commits into from
Aug 29, 2024
81 changes: 44 additions & 37 deletions examples/04-load-following-battery.ipynb

Large diffs are not rendered by default.

41 changes: 29 additions & 12 deletions hopp/simulation/technologies/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,29 +118,46 @@ def simulate_grid_connection(
"""
if self.site.follow_desired_schedule:
# Desired schedule sets the upper bound of the system output, any over generation is curtailed
lifetime_schedule: NDArrayFloat = np.tile([
x * 1e3 for x in self.site.desired_schedule],
int(project_life / (len(self.site.desired_schedule) // self.site.n_timesteps))
)
self.generation_profile = list(np.minimum(total_gen, lifetime_schedule)) # TODO: remove list() cast once parent class uses numpy

self.missed_load = np.array([schedule - gen if gen > 0 else schedule for (schedule, gen) in
zip(lifetime_schedule, self.generation_profile)])
self.missed_load_percentage = sum(self.missed_load)/sum(lifetime_schedule)

if self.site.curtailment_value_type == "interconnect_kw":
lifetime_schedule: NDArrayFloat = np.tile([self.interconnect_kw],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why call this "lifetime_schedule"? Isn't this the maximum allowed generation?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it is legacy

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction. I think this is much more clear.

len(total_gen))
desired_schedule = np.tile(
[x * 1e3 for x in self.site.desired_schedule],
int(project_life / (len(self.site.desired_schedule) // self.site.n_timesteps))
)
elif self.site.curtailment_value_type == "desired_schedule":
lifetime_schedule: NDArrayFloat = np.tile([
x * 1e3 for x in self.site.desired_schedule],
int(project_life / (len(self.site.desired_schedule) // self.site.n_timesteps))
)
desired_schedule = lifetime_schedule

# Generate the final generation profile by curtailing over-generation
self.generation_profile = np.minimum(total_gen, lifetime_schedule)

# Calculate missed load and missed load percentage
self.missed_load = np.array([
max(schedule - gen, 0)
for schedule, gen in zip(desired_schedule, self.generation_profile)
])
self.missed_load_percentage = sum(self.missed_load)/sum(desired_schedule)

# Calculate curtailed schedule and curtailed schedule percentage
self.schedule_curtailed = np.array([gen - schedule if gen > schedule else 0. for (gen, schedule) in
zip(total_gen, lifetime_schedule)])
self.schedule_curtailed_percentage = sum(self.schedule_curtailed)/sum(lifetime_schedule)

# NOTE: This is currently only happening for load following, would be good to make it more general
# i.e. so that this analysis can be used when load following isn't being used (without storage)
# for comparison
# Hybrid power production for load following
N_hybrid = len(self.generation_profile)

final_power_production = total_gen
schedule = [x for x in lifetime_schedule]
schedule = [x for x in desired_schedule]
hybrid_power = [(final_power_production[x] - (schedule[x]*0.95)) for x in range(len(final_power_production))]

# Count the instances where load is met
load_met = len([i for i in hybrid_power if i >= 0])
self.time_load_met = 100 * load_met/N_hybrid

Expand Down Expand Up @@ -189,7 +206,7 @@ def simulate_grid_connection(

logger.info('Total number of hours available for ERS: ', np.round(self.total_number_hours,2))
else:
self.generation_profile = total_gen
self.generation_profile = total_gen #actual

self.total_gen_max_feasible_year1 = np.array(total_gen_max_feasible_year1)
self.system_capacity_kw = hybrid_size_kw # TODO: Should this be interconnection limit?
Expand Down
6 changes: 5 additions & 1 deletion hopp/simulation/technologies/sites/site_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class SiteInfo(BaseClass):
hub_height: Turbine hub height for resource download in meters. Defaults to 97.0.
capacity_hours: Boolean list indicating hours for capacity payments. Defaults to [].
desired_schedule: Absolute desired load profile in MWe. Defaults to [].
curtailment_value_type: whether to curtail power above grid interconnection limit or desired schedule.
Options "interconnect_kw" or "desired_schedule". Defaults to "interconnect_kw".
solar: Whether to set solar data for this site. Defaults to True.
wind: Whether to set wind data for this site. Defaults to True.
wave: Whether to set wave data for this site. Defaults to True.
Expand All @@ -66,6 +68,8 @@ class SiteInfo(BaseClass):
hub_height: hopp_float_type = field(default=97., converter=hopp_float_type)
capacity_hours: NDArray = field(default=[], converter=converter(bool))
desired_schedule: NDArrayFloat = field(default=[], converter=converter())
curtailment_value_type: str = field(default="interconnect_kw", validator=contains(["interconnect_kw", "desired_schedule"]))

solar: bool = field(default=True)
wind: bool = field(default=True)
wave: bool = field(default=False)
Expand Down Expand Up @@ -140,7 +144,7 @@ def __attrs_post_init__(self):
if self.wind:
# TODO: allow hub height to be used as an optimization variable
self.wind_resource = WindResource(data['lat'], data['lon'], data['year'], wind_turbine_hub_ht=self.hub_height,
filepath=self.wind_resource_file, source=self.wind_resource_origin)
filepath=self.wind_resource_file, source=self.wind_resource_origin)
n_timesteps = len(self.wind_resource.data['data']) // 8760 * 8760
if self.n_timesteps is None:
self.n_timesteps = n_timesteps
Expand Down
74 changes: 48 additions & 26 deletions tests/hopp/test_detailed_pv_plant.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
import pytest
from pytest import fixture

from hopp.simulation.technologies.pv.detailed_pv_plant import DetailedPVConfig, DetailedPVPlant
from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel
from hopp.simulation.technologies.pv.detailed_pv_plant import (
DetailedPVConfig,
DetailedPVPlant,
)
from hopp.simulation.technologies.financial.custom_financial_model import (
CustomFinancialModel,
)
from hopp.simulation.technologies.layout.pv_layout import PVGridParameters
from tests.hopp.utils import create_default_site_info, DEFAULT_FIN_CONFIG

config_data = {
'system_capacity_kw': 100,
'tech_config': {
'subarray2_enable': 0
}
}
config_data = {"system_capacity_kw": 100, "tech_config": {"subarray2_enable": 0}}


@fixture
Expand All @@ -26,7 +26,25 @@ def test_detailed_pv_plant_initialization(site, subtests):
"""Test simple instantiation (no layout params)."""
config = DetailedPVConfig.from_dict(config_data)
pv_plant = DetailedPVPlant(site=site, config=config)
assert pv_plant.site == site
with subtests.test("Site: lat and lon"):
assert pv_plant.site.lat == site.lat
assert pv_plant.site.lon == site.lon
with subtests.test("Site: elev"):
assert pv_plant.site.data["elev"] == site.data["elev"]
with subtests.test("Site: year"):
assert pv_plant.site.data["year"] == site.data["year"]
with subtests.test("Site: tz"):
assert pv_plant.site.data["tz"] == site.data["tz"]
with subtests.test("Site: site boundaries"):
assert (
pv_plant.site.data["site_boundaries"]["verts"]
== site.data["site_boundaries"]["verts"]
)
with subtests.test("Site: site boundaries simple"):
assert (
pv_plant.site.data["site_boundaries"]["verts_simple"]
== site.data["site_boundaries"]["verts_simple"]
)
assert pv_plant._financial_model is not None
assert pv_plant.layout is not None
assert pv_plant.layout.parameters is None
Expand All @@ -36,20 +54,22 @@ def test_detailed_pv_plant_initialization(site, subtests):
def test_single_subarray_limitation(site):
"""Ensure only one subarray is allowed."""
config_with_multiple_subarrays = {
'system_capacity_kw': 100,
'tech_config': {
'subarray2_enable': 1
}
"system_capacity_kw": 100,
"tech_config": {"subarray2_enable": 1},
}
config = DetailedPVConfig.from_dict(config_with_multiple_subarrays)
with pytest.raises(Exception, match=r"Detailed PV plant currently only supports one subarray."):
with pytest.raises(
Exception, match=r"Detailed PV plant currently only supports one subarray."
):
DetailedPVPlant(site=site, config=config)


def test_processed_assign(site, subtests):
"""Test more detailed instantiation with `tech_config`."""
pvsamv1_defaults_file = Path(__file__).absolute().parent / "pvsamv1_basic_params.json"
with open(pvsamv1_defaults_file, 'r') as f:
pvsamv1_defaults_file = (
Path(__file__).absolute().parent / "pvsamv1_basic_params.json"
)
with open(pvsamv1_defaults_file, "r") as f:
tech_config = json.load(f)

with subtests.test("With Pvsamv1 configuration file"):
Expand All @@ -61,27 +81,29 @@ def test_processed_assign(site, subtests):
def test_layout_parameters(site):
"""Ensure layout parameters are set properly if provided."""
config_with_layout_params = {
'system_capacity_kw': 100,
'layout_params': PVGridParameters(
"system_capacity_kw": 100,
"layout_params": PVGridParameters(
x_position=0.5,
y_position=0.5,
aspect_power=0,
gcr=0.5,
s_buffer=2,
x_buffer=2
)
x_buffer=2,
),
}
config = DetailedPVConfig.from_dict(config_with_layout_params)
pv_plant = DetailedPVPlant(site=site, config=config)
assert pv_plant.layout.parameters == config_with_layout_params['layout_params']
assert pv_plant.layout.parameters == config_with_layout_params["layout_params"]


def test_custom_financial(site):
"""Test with a non-default financial model."""
config = DetailedPVConfig.from_dict({
'system_capacity_kw': 100,
'fin_model': CustomFinancialModel(DEFAULT_FIN_CONFIG),
})
config = DetailedPVConfig.from_dict(
{
"system_capacity_kw": 100,
"fin_model": CustomFinancialModel(DEFAULT_FIN_CONFIG),
}
)
pv_plant = DetailedPVPlant(site=site, config=config)
assert pv_plant._financial_model is not None
assert isinstance(pv_plant._financial_model, CustomFinancialModel)
assert isinstance(pv_plant._financial_model, CustomFinancialModel)
35 changes: 34 additions & 1 deletion tests/hopp/test_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ def test_simulate_grid_connection(mock_simulate_power, site, subtests):
with subtests.test("follow desired schedule: curtailment"):
desired_schedule = np.repeat([3], site.n_timesteps)
site2 = create_default_site_info(
desired_schedule=desired_schedule
desired_schedule=desired_schedule,
curtailment_value_type = "desired_schedule"
)
config = GridConfig.from_dict({"interconnect_kw": interconnect_kw})
grid = Grid(site2, config=config)
Expand Down Expand Up @@ -128,3 +129,35 @@ def test_simulate_grid_connection(mock_simulate_power, site, subtests):

assert_array_equal(grid.schedule_curtailed, np.repeat([2000], timesteps))
assert_approx_equal(grid.schedule_curtailed_percentage, 2/3)

with subtests.test("follow desired schedule: curtailment interconnection"):
desired_schedule = np.repeat([3], site.n_timesteps)
site2 = create_default_site_info(
desired_schedule=desired_schedule,
)
config = GridConfig.from_dict({"interconnect_kw": interconnect_kw})
grid = Grid(site2, config=config)
grid.simulate_grid_connection(
hybrid_size_kw,
total_gen,
project_life,
lifetime_sim,
total_gen_max_feasible_year1
)

timesteps = site.n_timesteps * project_life
assert_array_equal(
grid.generation_profile,
np.repeat([5000], timesteps),
"gen profile should be reduced"
)

msg = "no load should be missed"
assert_array_equal(
grid.missed_load,
np.repeat([0], timesteps),
msg
)
assert grid.missed_load_percentage == 0., msg

assert_array_equal(grid.schedule_curtailed, np.repeat([0], timesteps))
98 changes: 95 additions & 3 deletions tests/hopp/test_hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,25 @@ def test_hybrid_wave_only(hybrid_config, wavesite, subtests):
assert hybrid_plant.wave._financial_model.FinancialParameters == approx(
hybrid_plant.grid._financial_model.FinancialParameters
)
with subtests.test("Revenue"):
assert hybrid_plant.wave._financial_model.Revenue == approx(
hybrid_plant.grid._financial_model.Revenue
with subtests.test("Revenue: ppa price input"):
assert hybrid_plant.wave._financial_model.Revenue.ppa_price_input == approx(
hybrid_plant.grid._financial_model.Revenue.ppa_price_input
)
with subtests.test("Revenue: ppa escalation"):
assert hybrid_plant.wave._financial_model.Revenue.ppa_escalation == approx(
hybrid_plant.grid._financial_model.Revenue.ppa_escalation
)
with subtests.test("Revenue: ppa multiplier model"):
assert (
hybrid_plant.wave._financial_model.Revenue.ppa_multiplier_model
== approx(hybrid_plant.grid._financial_model.Revenue.ppa_multiplier_model)
)
with subtests.test("Revenue: ppa price input"):
assert (
hybrid_plant.wave._financial_model.Revenue.dispatch_factors_ts.all()
== approx(
hybrid_plant.grid._financial_model.Revenue.dispatch_factors_ts.all()
)
)
with subtests.test("SystemCosts"):
assert hybrid_plant.wave._financial_model.SystemCosts == approx(
Expand Down Expand Up @@ -412,6 +428,82 @@ def test_hybrid_pv_only(hybrid_config):
assert npvs.hybrid == approx(-5121293, 1e3)


def test_hybrid_pv_only_custom_fin(hybrid_config, subtests):
solar_only = {
"pv": {
"system_capacity_kw": 5000,
"layout_params": {
"x_position": 0.5,
"y_position": 0.5,
"aspect_power": 0,
"gcr": 0.5,
"s_buffer": 2,
"x_buffer": 2,
},
"dc_degradation": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
"fin_model": DEFAULT_FIN_CONFIG,
},
"grid": {
"interconnect_kw": interconnection_size_kw,
"fin_model": DEFAULT_FIN_CONFIG,
},
}
hybrid_config["technologies"] = solar_only
hybrid_config["config"] = {
"cost_info": {
"solar_installed_cost_mw": 400 * 1000,
}
}
hi = HoppInterface(hybrid_config)

hybrid_plant = hi.system
hybrid_plant.set_om_costs_per_kw(pv_om_per_kw=20)

hi.simulate()

aeps = hybrid_plant.annual_energies
npvs = hybrid_plant.net_present_values
cf = hybrid_plant.capacity_factors

with subtests.test("total installed cost"):
assert hybrid_plant.pv.total_installed_cost == approx(2000000, 1e-3)

with subtests.test("om cost"):
assert hybrid_plant.pv.om_capacity == (20,)

with subtests.test("capacity factor"):
assert cf.hybrid == approx(cf.pv)

with subtests.test("aep"):
assert aeps.pv == approx(9884106.55, 1e-3)
assert aeps.hybrid == aeps.pv

def test_detailed_pv_system_capacity(hybrid_config, subtests):
with subtests.test(
"Detailed PV model (pvsamv1) using defaults except the top level system_capacity_kw parameter"
Expand Down
Loading