diff --git a/src/gsy_e/gsy_e_core/export.py b/src/gsy_e/gsy_e_core/export.py index 7bc65162a2..935e3b6914 100644 --- a/src/gsy_e/gsy_e_core/export.py +++ b/src/gsy_e/gsy_e_core/export.py @@ -63,6 +63,7 @@ if TYPE_CHECKING: from gsy_e.gsy_e_core.sim_results.endpoint_buffer import SimulationEndpointBuffer + from gsy_e.gsy_e_core.simulation.setup import SimulationSetup from gsy_e.models.market.future import FutureMarkets _log = logging.getLogger(__name__) @@ -103,12 +104,14 @@ def __init__( subdir: str, endpoint_buffer: "SimulationEndpointBuffer", carbon_ratio_file: str, + config_params: "SimulationSetup", ): self.area = root_area self.endpoint_buffer = endpoint_buffer self.file_stats_endpoint = self._file_export_endpoints_class() self.raw_data_subdir = None self.carbon_ratio_file = carbon_ratio_file + self.config_params = config_params try: if path is not None: path = os.path.abspath(path) @@ -147,6 +150,19 @@ def _export_json_data(self) -> None: with open(json_file, "w", encoding="utf-8") as outfile: json.dump(value, outfile, indent=2) + # Export PV ROI + pv_assets = self.area.get_pv_assets() + for pv in pv_assets: + print("pv.uuid", pv.uuid) + cumulative_grid_trades = json_report.get("cumulative_grid_trades") + energy_produced_kWh = cumulative_grid_trades[pv.uuid]["produced"] + summary = pv.strategy.roi( + duration_days=self.config_params.config.sim_duration.days, + energy_produced_kWh=energy_produced_kWh, + ) + print("summary ", summary) + + # Export Carbon Emissions if self.carbon_ratio_file: carbon_emissions_handler = CarbonEmissionsHandler() carbon_ratio = ( diff --git a/src/gsy_e/gsy_e_core/simulation/results_manager.py b/src/gsy_e/gsy_e_core/simulation/results_manager.py index d7b212d08e..075ed2dfe0 100644 --- a/src/gsy_e/gsy_e_core/simulation/results_manager.py +++ b/src/gsy_e/gsy_e_core/simulation/results_manager.py @@ -78,6 +78,7 @@ def init_results( self.export_subdir, self._endpoint_buffer, self.carbon_ratio_file, + config_params, ) @property diff --git a/src/gsy_e/models/area/area.py b/src/gsy_e/models/area/area.py index ea367cbbd8..f5d6d93e1d 100644 --- a/src/gsy_e/models/area/area.py +++ b/src/gsy_e/models/area/area.py @@ -42,8 +42,8 @@ from gsy_e.models.config import SimulationConfig from gsy_e.models.market.forward import ForwardMarketBase from gsy_e.models.market.future import FutureMarkets - from gsy_e.models.strategy.external_strategies import ExternalMixin +from gsy_e.models.strategy.pv import PVStrategy log = getLogger(__name__) @@ -630,6 +630,22 @@ def get_results_dict(self) -> dict: ), } + def get_pv_assets(self) -> List["Asset"]: + """Return a list of all PV assets or areas with PV strategy in this area and sub-areas.""" + pv_assets = [] + + for child in self.children: + has_pv_strategy = hasattr(child, "strategy") and isinstance(child.strategy, PVStrategy) + + if isinstance(child, Asset) and has_pv_strategy: + pv_assets.append(child) + elif has_pv_strategy: + pv_assets.append(child) + elif isinstance(child, Area): + pv_assets.extend(child.get_pv_assets()) + + return pv_assets + class Market(Area): """Class to define geographical market areas that can contain children (areas or assets).""" diff --git a/src/gsy_e/models/strategy/pv.py b/src/gsy_e/models/strategy/pv.py index c0caee30ec..847859b3fb 100644 --- a/src/gsy_e/models/strategy/pv.py +++ b/src/gsy_e/models/strategy/pv.py @@ -15,6 +15,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ + import traceback from logging import getLogger @@ -22,8 +23,7 @@ from gsy_framework.data_classes import TraderDetails from gsy_framework.exceptions import GSyException from gsy_framework.read_user_profile import read_arbitrary_profile, InputProfileTypes -from gsy_framework.utils import ( - get_from_profile_same_weekday_and_time, key_in_dict_and_not_none) +from gsy_framework.utils import get_from_profile_same_weekday_and_time, key_in_dict_and_not_none from gsy_framework.validators import PVValidator from pendulum import duration @@ -45,16 +45,18 @@ class PVStrategy(BidEnabledStrategy, UseMarketMakerMixin): """PV Strategy class for gaussian generation profile.""" # pylint: disable=too-many-arguments - def __init__(self, panel_count: int = 1, - initial_selling_rate: - float = ConstSettings.GeneralSettings.DEFAULT_MARKET_MAKER_RATE, - final_selling_rate: - float = ConstSettings.PVSettings.SELLING_RATE_RANGE.final, - fit_to_limit: bool = True, - update_interval=None, - energy_rate_decrease_per_update=None, - capacity_kW: float = None, - use_market_maker_rate: bool = False): + def __init__( + self, + panel_count: int = 1, + initial_selling_rate: float = ConstSettings.GeneralSettings.DEFAULT_MARKET_MAKER_RATE, + final_selling_rate: float = ConstSettings.PVSettings.SELLING_RATE_RANGE.final, + fit_to_limit: bool = True, + update_interval=None, + energy_rate_decrease_per_update=None, + capacity_kW: float = None, + use_market_maker_rate: bool = False, + price_installation_per_kW: float = 100, + ): """ Args: panel_count: Number of solar panels for this PV plant @@ -63,20 +65,28 @@ def __init__(self, panel_count: int = 1, fit_to_limit: Linear curve following initial_selling_rate & initial_selling_rate update_interval: Interval after which PV will update its offer energy_rate_decrease_per_update: Slope of PV Offer change per update - capacity_kW: power rating of the predefined profiles + capacity_kW: Power rating of the predefined profiles + price_installation_per_kW: Installation cost per kW of capacity_kW """ super().__init__() self._energy_params = PVEnergyParameters(panel_count, capacity_kW) self.use_market_maker_rate = use_market_maker_rate - self._init_price_update(update_interval, initial_selling_rate, final_selling_rate, - fit_to_limit, energy_rate_decrease_per_update) + self._init_price_update( + update_interval, + initial_selling_rate, + final_selling_rate, + fit_to_limit, + energy_rate_decrease_per_update, + ) + self._price_installation_per_kW = price_installation_per_kW def serialize(self): """Return dict with the current strategy parameter values.""" return { **self._energy_params.serialize(), **self.offer_update.serialize(), - "use_market_maker_rate": self.use_market_maker_rate + "use_market_maker_rate": self.use_market_maker_rate, + "price_installation_per_kW": self._price_installation_per_kW, } @classmethod @@ -91,27 +101,38 @@ def state(self) -> PVState: return self._energy_params._state # pylint: disable=protected-access # pylint: disable=too-many-arguments - def _init_price_update(self, update_interval, initial_selling_rate, final_selling_rate, - fit_to_limit, energy_rate_decrease_per_update): + def _init_price_update( + self, + update_interval, + initial_selling_rate, + final_selling_rate, + fit_to_limit, + energy_rate_decrease_per_update, + ): # Instantiate instance variables that should not be shared with child classes self.final_selling_rate = final_selling_rate if update_interval is None: - update_interval = \ - duration(minutes=ConstSettings.GeneralSettings.DEFAULT_UPDATE_INTERVAL) + update_interval = duration( + minutes=ConstSettings.GeneralSettings.DEFAULT_UPDATE_INTERVAL + ) if isinstance(update_interval, int): update_interval = duration(minutes=update_interval) PVValidator.validate_rate( fit_to_limit=fit_to_limit, - energy_rate_decrease_per_update=energy_rate_decrease_per_update) + energy_rate_decrease_per_update=energy_rate_decrease_per_update, + ) - self.offer_update = TemplateStrategyOfferUpdater(initial_selling_rate, final_selling_rate, - fit_to_limit, - energy_rate_decrease_per_update, - update_interval) + self.offer_update = TemplateStrategyOfferUpdater( + initial_selling_rate, + final_selling_rate, + fit_to_limit, + energy_rate_decrease_per_update, + update_interval, + ) def area_reconfigure_event(self, *args, **kwargs): """Reconfigure the device properties at runtime using the provided arguments.""" @@ -125,23 +146,28 @@ def _area_reconfigure_prices(self, **kwargs): initial_rate = ( read_arbitrary_profile(InputProfileTypes.IDENTITY, kwargs["initial_selling_rate"]) if kwargs.get("initial_selling_rate") is not None - else self.offer_update.initial_rate_profile_buffer) + else self.offer_update.initial_rate_profile_buffer + ) final_rate = ( read_arbitrary_profile(InputProfileTypes.IDENTITY, kwargs["final_selling_rate"]) if kwargs.get("final_selling_rate") is not None - else self.offer_update.final_rate_profile_buffer) + else self.offer_update.final_rate_profile_buffer + ) energy_rate_change_per_update = ( - read_arbitrary_profile(InputProfileTypes.IDENTITY, - kwargs["energy_rate_decrease_per_update"]) + read_arbitrary_profile( + InputProfileTypes.IDENTITY, kwargs["energy_rate_decrease_per_update"] + ) if kwargs.get("energy_rate_decrease_per_update") is not None - else self.offer_update.energy_rate_change_per_update_profile_buffer) + else self.offer_update.energy_rate_change_per_update_profile_buffer + ) fit_to_limit = ( kwargs["fit_to_limit"] if kwargs.get("fit_to_limit") is not None - else self.offer_update.fit_to_limit) + else self.offer_update.fit_to_limit + ) if key_in_dict_and_not_none(kwargs, "update_interval"): if isinstance(kwargs["update_interval"], int): @@ -155,11 +181,15 @@ def _area_reconfigure_prices(self, **kwargs): self.use_market_maker_rate = kwargs["use_market_maker_rate"] try: - self._validate_rates(initial_rate, final_rate, energy_rate_change_per_update, - fit_to_limit) + self._validate_rates( + initial_rate, final_rate, energy_rate_change_per_update, fit_to_limit + ) except GSyException as e: # pylint: disable=broad-except - log.error("PVStrategy._area_reconfigure_prices failed. Exception: %s. " - "Traceback: %s", e, traceback.format_exc()) + log.error( + "PVStrategy._area_reconfigure_prices failed. Exception: %s. " "Traceback: %s", + e, + traceback.format_exc(), + ) return self.offer_update.set_parameters( @@ -167,23 +197,33 @@ def _area_reconfigure_prices(self, **kwargs): final_rate=final_rate, energy_rate_change_per_update=energy_rate_change_per_update, fit_to_limit=fit_to_limit, - update_interval=update_interval + update_interval=update_interval, ) - def _validate_rates(self, initial_rate, final_rate, energy_rate_change_per_update, - fit_to_limit): + def _validate_rates( + self, initial_rate, final_rate, energy_rate_change_per_update, fit_to_limit + ): # all parameters have to be validated for each time slot here for time_slot in initial_rate.keys(): - if self.area and self.area.current_market \ - and time_slot < self.area.current_market.time_slot: + if ( + self.area + and self.area.current_market + and time_slot < self.area.current_market.time_slot + ): continue - rate_change = None if fit_to_limit else \ - get_from_profile_same_weekday_and_time(energy_rate_change_per_update, time_slot) + rate_change = ( + None + if fit_to_limit + else get_from_profile_same_weekday_and_time( + energy_rate_change_per_update, time_slot + ) + ) PVValidator.validate_rate( initial_selling_rate=initial_rate[time_slot], final_selling_rate=get_from_profile_same_weekday_and_time(final_rate, time_slot), energy_rate_decrease_per_update=rate_change, - fit_to_limit=fit_to_limit) + fit_to_limit=fit_to_limit, + ) def event_activate(self, **kwargs): self.event_activate_price() @@ -194,10 +234,12 @@ def event_activate(self, **kwargs): def event_activate_price(self): self._replace_rates_with_market_maker_rates() - self._validate_rates(self.offer_update.initial_rate_profile_buffer, - self.offer_update.final_rate_profile_buffer, - self.offer_update.energy_rate_change_per_update_profile_buffer, - self.offer_update.fit_to_limit) + self._validate_rates( + self.offer_update.initial_rate_profile_buffer, + self.offer_update.final_rate_profile_buffer, + self.offer_update.energy_rate_change_per_update_profile_buffer, + self.offer_update.fit_to_limit, + ) def event_activate_energy(self): """Activate energy parameters of the PV.""" @@ -228,7 +270,8 @@ def set_produced_energy_forecast_in_state(self, reconfigure=True): time_slots.extend(self.area.future_market_time_slots) for time_slot in time_slots: self._energy_params.set_produced_energy_forecast( - time_slot, self.simulation_config.slot_length) + time_slot, self.simulation_config.slot_length + ) def event_market_cycle(self): super().event_market_cycle() @@ -246,14 +289,15 @@ def _set_energy_measurement_of_last_market(self): self._energy_params.set_energy_measurement_kWh(self.area.current_market.time_slot) def _delete_past_state(self): - if (constants.RETAIN_PAST_MARKET_STRATEGIES_STATE is True or - self.area.current_market is None): + if ( + constants.RETAIN_PAST_MARKET_STRATEGIES_STATE is True + or self.area.current_market is None + ): return self.state.delete_past_state_values(self.area.current_market.time_slot) self.offer_update.delete_past_state_values(self.area.current_market.time_slot) - self._future_market_strategy.delete_past_state_values( - self.area.current_market.time_slot) + self._future_market_strategy.delete_past_state_values(self.area.current_market.time_slot) def event_market_cycle_price(self): """Manage price parameters during the market cycle event.""" @@ -266,16 +310,16 @@ def event_market_cycle_price(self): # market in order to validate that more offers need to be posted. offer_energy_kWh -= self.offers.open_offer_energy(market.id) if offer_energy_kWh > 0: - offer_price = \ - self.offer_update.initial_rate[market.time_slot] * offer_energy_kWh + offer_price = self.offer_update.initial_rate[market.time_slot] * offer_energy_kWh try: offer = market.offer( offer_price, offer_energy_kWh, - TraderDetails(self.owner.name, self.owner.uuid, self.owner.name, - self.owner.uuid), + TraderDetails( + self.owner.name, self.owner.uuid, self.owner.name, self.owner.uuid + ), original_price=offer_price, - time_slot=market.time_slot + time_slot=market.time_slot, ) self.offers.post(offer, market.id) except MarketException: @@ -292,12 +336,21 @@ def event_offer_traded(self, *, market_id, trade): if trade.seller.name == self.owner.name: self.state.decrement_available_energy( - trade.traded_energy, trade.time_slot, self.owner.name) + trade.traded_energy, trade.time_slot, self.owner.name + ) def event_bid_traded(self, *, market_id, bid_trade): super().event_bid_traded(market_id=market_id, bid_trade=bid_trade) self._settlement_market_strategy.event_bid_traded(self, market_id, bid_trade) + def roi(self, area_uuid, duration_days, energy_produced_kWh): + """Calculate the return on investment (ROI) for the PV asset.""" + print("self", self) + print("duration_days", duration_days) + print("energy_produced_kWh", energy_produced_kWh) + + pass + @property def asset_type(self): return AssetType.PRODUCER diff --git a/src/gsy_e/setup/default_3_pv_only.py b/src/gsy_e/setup/default_3_pv_only.py index 7269a71c0e..2e9c08103d 100644 --- a/src/gsy_e/setup/default_3_pv_only.py +++ b/src/gsy_e/setup/default_3_pv_only.py @@ -29,30 +29,39 @@ def get_setup(config): Area( "House 1", [ - Area("H1 General Load", strategy=LoadHoursStrategy(avg_power_W=100, - hrs_of_day=list( - range(12, 16))), - ), - ] + Area( + "H1 General Load", + strategy=LoadHoursStrategy( + avg_power_W=100, hrs_of_day=list(range(12, 16)) + ), + ), + ], ), Area( "House 2", [ - Area("H2 General Load", strategy=LoadHoursStrategy(avg_power_W=100, - hrs_of_day=list( - range(12, 16))), - ), - Area("H2 PV", strategy=PVStrategy(1, 80), - ), - ] + Area( + "H2 General Load", + strategy=LoadHoursStrategy( + avg_power_W=100, hrs_of_day=list(range(12, 16)) + ), + ), + Area( + "H2 PV", + strategy=PVStrategy( + panel_count=1, + initial_selling_rate=80, + capacity_kW=1, + price_installation_per_kW=100, + ), + ), + ], ), - # Area("Commercial Energy Producer", # strategy=CommercialStrategy(energy_range_wh=(40, 120), energy_price=30), # # ), - ], - config=config + config=config, ) return area diff --git a/tests/strategies/test_strategy_pv.py b/tests/strategies/test_strategy_pv.py index 103b928463..e190508e6e 100644 --- a/tests/strategies/test_strategy_pv.py +++ b/tests/strategies/test_strategy_pv.py @@ -35,6 +35,7 @@ from gsy_e.models.config import create_simulation_config_from_global_config from gsy_e.models.strategy.predefined_pv import PVPredefinedStrategy, PVUserProfileStrategy from gsy_e.models.strategy.pv import PVStrategy +from gsy_e.setup.default_3_pv_only import get_setup ENERGY_FORECAST = {} # type: Dict[pendulum.DateTime, float] TIME = pendulum.today(tz=TIME_ZONE).at(hour=10, minute=45, second=0) @@ -679,3 +680,11 @@ def test_set_energy_measurement_of_last_market(utils_mock, pv_strategy): pv_strategy.state.set_energy_measurement_kWh.assert_called_once_with( 100, pv_strategy.area.current_market.time_slot ) + + +def test_pv_roi(pv_strategy): + """The ROI calculation for a 25-year period returns the expected value.""" + area = get_setup({}) + pv_assets = area.get_pv_assets() + summary = pv_assets[0].strategy.roi() + assert False