Source code for idaes.apps.grid_integration.bidder

#################################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2024 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved.  Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
#################################################################################
import os
from abc import ABC, abstractmethod
import datetime
import pandas as pd
import pyomo.environ as pyo
from pyomo.opt.base.solvers import OptSolver
from pyomo.common.dependencies import attempt_import
from idaes.apps.grid_integration.utils import convert_marginal_costs_to_actual_costs
import idaes.logger as idaeslog

egret, egret_avail = attempt_import("egret")
if egret_avail:
    from egret.model_library.transmission import tx_utils

_logger = idaeslog.getLogger(__name__)


class AbstractBidder(ABC):
    """
    The abstract class for all the bidder and self-schedulers.
    """

    @abstractmethod
    def update_day_ahead_model(self, **kwargs):
        """
        Update the day-ahead model (advance timesteps) with necessary parameters in kwargs.

        Arguments:
            kwargs: necessary profiles to update the underlying model. {stat_name: [...]}

        Returns:
            None
        """

    @abstractmethod
    def update_real_time_model(self, **kwargs):
        """
        Update the real-time model (advance timesteps) with necessary parameters in kwargs.

        Arguments:
            kwargs: necessary profiles to update the underlying model. {stat_name: [...]}

        Returns:
            None
        """

    @abstractmethod
    def compute_day_ahead_bids(self, date, hour, **kwargs):
        """
        Solve the model to bid/self-schedule into the day-ahead market. After solving,
        record the schedule from the solve.

        Arguments:

            date: current simulation date

            hour: current simulation hour

            **kwargs: other information to record

        Returns:
            None
        """

    @abstractmethod
    def compute_real_time_bids(self, date, hour, **kwargs):
        """
        Solve the model to bid/self-schedule into the real-time market. After solving,
        record the schedule from the solve.

        Arguments:

            date: current simulation date

            hour: current simulation hour

            **kwargs: other information to record

        Returns:
            None
        """

    @abstractmethod
    def write_results(self, path):
        """
        This methods writes the saved results into an csv file.

        Arguments:
            path: the path to write the results.

        Return:
            None
        """

    @abstractmethod
    def formulate_DA_bidding_problem(self):
        """
        Formulate the day-ahead bidding optimization problem by adding necessary
        parameters, constraints, and objective function.

        Arguments:
            None

        Returns:
            None
        """

    @abstractmethod
    def formulate_RT_bidding_problem(self):
        """
        Formulate the real-time bidding optimization problem by adding necessary
        parameters, constraints, and objective function.

        Arguments:
            None

        Returns:
            None
        """

    @abstractmethod
    def record_bids(self, bids, model, date, hour):
        """
        This function records the bids (schedule) and the details in the
        underlying bidding model.

        Arguments:
            bids: the obtained bids for this date

            model: the model we obtained bids from

            date: the date we bid into

            hour: the hour we bid into

        Returns:
            None

        """

    @property
    @abstractmethod
    def generator(self):
        return "AbstractGenerator"

    def _check_inputs(self):
        """
        Check if the inputs to construct the tracker is valid. If not raise errors.
        """

        self._check_bidding_model_object()
        self._check_n_scenario()
        self._check_solver()

    def _check_bidding_model_object(self):
        """
        Check if tracking model object has the necessary methods and attributes.
        """

        method_list = ["populate_model", "update_model"]
        attr_list = ["power_output", "total_cost", "model_data"]
        msg = "Bidding model object does not have a "

        for m in method_list:
            obtained_m = getattr(self.bidding_model_object, m, None)
            if obtained_m is None:
                raise AttributeError(
                    msg
                    + f"{m}() method. The bidder object needs the users to implement this method in their model object."
                )

        for attr in attr_list:
            obtained_attr = getattr(self.bidding_model_object, attr, None)
            if obtained_attr is None:
                raise AttributeError(
                    msg
                    + f"{attr} property. The bidder object needs the users to specify this property in their model object."
                )

    def _check_n_scenario(self):
        """
        Check if the number of LMP scenarios is an integer and greater than 0.
        """

        # check if it is an integer
        if not isinstance(self.n_scenario, int):
            raise TypeError(
                f"The number of LMP scenarios should be an integer, but a {type(self.n_scenario).__name__} was given."
            )

        if self.n_scenario <= 0:
            raise ValueError(
                f"The number of LMP scenarios should be greater than zero, but {self.n_scenario} was given."
            )

    def _check_solver(self):
        """
        Check if provides solver is a valid Pyomo solver object.
        """

        if not isinstance(self.solver, OptSolver):
            raise TypeError(
                f"The provided solver {self.solver} is not a valid Pyomo solver."
            )


class StochasticProgramBidder(AbstractBidder):
    """
    Template class for bidders that use scenario-based stochastic programs.
    """

    def __init__(
        self,
        bidding_model_object,
        day_ahead_horizon,
        real_time_horizon,
        n_scenario,
        solver,
        forecaster,
        real_time_underbid_penalty,
    ):
        """
        Initializes the stochastic bidder object.

        Arguments:
            bidding_model_object: the model object for bidding

            day_ahead_horizon: number of time periods in the day-ahead bidding problem

            real_time_horizon: number of time periods in the real-time bidding problem

            n_scenario: number of uncertain LMP scenarios

            solver: a Pyomo mathematical programming solver object

            forecaster: an initialized LMP forecaster object

            real_time_underbid_penalty: penalty for RT power bid that's less than DA power bid, non-negative

        Returns:
            None
        """

        self.bidding_model_object = bidding_model_object
        self.day_ahead_horizon = day_ahead_horizon
        self.real_time_horizon = real_time_horizon
        self.n_scenario = n_scenario
        self.solver = solver
        self.forecaster = forecaster
        self.real_time_underbid_penalty = real_time_underbid_penalty

        self._check_inputs()

        self.generator = self.bidding_model_object.model_data.gen_name

        # day-ahead model
        self.day_ahead_model = self.formulate_DA_bidding_problem()
        self.real_time_model = self.formulate_RT_bidding_problem()

        # declare a list to store results
        self.bids_result_list = []

    def _set_up_bidding_problem(self, horizon):
        """
        Set up the base stochastic programming bidding problems.

        Arguments:
            horizon: number of time periods in the bidding problem

        Returns:
            pyomo.core.base.PyomoModel.ConcreteModel: base bidding model

        """

        model = pyo.ConcreteModel()

        model.SCENARIOS = pyo.Set(initialize=range(self.n_scenario))

        model.fs = pyo.Block(model.SCENARIOS)
        for i in model.SCENARIOS:
            self.bidding_model_object.populate_model(model.fs[i], horizon)

        self._save_power_outputs(model)

        self._add_bidding_params(model)
        self._add_bidding_vars(model)
        self._add_bidding_objective(model)

        return model

    def formulate_DA_bidding_problem(self):
        """
        Set up the day-ahead stochastic programming bidding problems.

        Returns:
            pyomo.core.base.PyomoModel.ConcreteModel: base bidding model

        """

        model = self._set_up_bidding_problem(self.day_ahead_horizon)
        self._add_DA_bidding_constraints(model)

        # do not relax the DA offering UB
        for i in model.SCENARIOS:
            model.fs[i].real_time_underbid_power.fix(0)

        return model

    def formulate_RT_bidding_problem(self):
        """
        Set up the real-time stochastic programming bidding problems.

        Returns:
            pyomo.core.base.PyomoModel.ConcreteModel: base bidding model

        """

        model = self._set_up_bidding_problem(self.real_time_horizon)
        self._add_RT_bidding_constraints(model)

        # relax the DA offering UB
        for i in model.SCENARIOS:
            model.fs[i].real_time_underbid_power.unfix()

        return model

    def _save_power_outputs(self, model):
        """
        Create references of the power output variable in each price scenario
        block.

        Arguments:
            None

        Returns:
            None
        """

        for i in model.SCENARIOS:
            # get the power output
            power_output_name = self.bidding_model_object.power_output
            model.fs[i].power_output_ref = pyo.Reference(
                getattr(model.fs[i], power_output_name)
            )

        return

    def _add_bidding_params(self, model):
        """
        Add necessary bidding parameters to the model, i.e., market energy price.

        Arguments:
            model: bidding model

        Returns:
            None
        """

        for i in model.SCENARIOS:
            time_index = model.fs[i].power_output_ref.index_set()
            model.fs[i].day_ahead_energy_price = pyo.Param(
                time_index, initialize=0, mutable=True
            )
            model.fs[i].real_time_energy_price = pyo.Param(
                time_index, initialize=0, mutable=True
            )
            model.fs[i].real_time_underbid_penalty = pyo.Param(
                initialize=self.real_time_underbid_penalty, mutable=True
            )

        return

    def _add_bidding_vars(self, model):
        """
        Add necessary bidding parameters to the model, i.e., market energy price.

        Arguments:
            model: bidding model

        Returns:
            None
        """

        def relaxed_day_ahead_power_ub_rule(fs, t):
            return (
                fs.power_output_ref[t] + fs.real_time_underbid_power[t]
                >= fs.day_ahead_power[t]
            )

        for i in model.SCENARIOS:
            time_index = model.fs[i].power_output_ref.index_set()
            model.fs[i].day_ahead_power = pyo.Var(
                time_index, initialize=0, within=pyo.NonNegativeReals
            )

            model.fs[i].real_time_underbid_power = pyo.Var(
                time_index, initialize=0, within=pyo.NonNegativeReals
            )

            model.fs[i].day_ahead_power_ub = pyo.Constraint(
                time_index, rule=relaxed_day_ahead_power_ub_rule
            )

        return

    def _add_bidding_objective(self, model):
        """
        Add objective function to the model, i.e., maximizing the expected profit
        of the energy system.

        Arguments:
            model: bidding model

        Returns:
            None
        """

        # declare an empty objective
        model.obj = pyo.Objective(expr=0, sense=pyo.maximize)

        for k in model.SCENARIOS:
            time_index = model.fs[k].power_output_ref.index_set()

            # currently .total_cost is a tuple of 2 items
            # the first item is the name of the cost expression
            # the second item is the weight for the cost
            cost_name = self.bidding_model_object.total_cost[0]
            cost = getattr(model.fs[k], cost_name)
            weight = self.bidding_model_object.total_cost[1]

            for t in time_index:
                model.obj.expr += (
                    model.fs[k].day_ahead_energy_price[t]
                    * model.fs[k].day_ahead_power[t]
                    + model.fs[k].real_time_energy_price[t]
                    * (model.fs[k].power_output_ref[t] - model.fs[k].day_ahead_power[t])
                    - weight * cost[t]
                    - model.fs[k].real_time_underbid_penalty
                    * model.fs[k].real_time_underbid_power[t]
                )

        return

    def _compute_bids(
        self,
        day_ahead_price,
        real_time_energy_price,
        date,
        hour,
        model,
        power_var_name,
        energy_price_param_name,
        market,
    ):
        """
        Solve the model to bid into the markets. After solving, record the bids from the solve.

        Arguments:

            day_ahead_price: day-ahead price forecasts needed to solve the bidding problem

            real_time_energy_price: real-time price forecasts needed to solve the bidding problem

            date: current simulation date

            hour: current simulation hour

            model: bidding model

            power_var_name: the name of the power output (str)

            energy_price_param_name: the name of the energy price forecast params (str)

            market: the market name (str), e.g., Day-ahead, real-time

        Returns:
            dict: the obtained bids
        """

        # update the price forecasts
        self._pass_price_forecasts(model, day_ahead_price, real_time_energy_price)

        self.solver.solve(model, tee=True)

        bids = self._assemble_bids(
            model,
            power_var_name=power_var_name,
            energy_price_param_name=energy_price_param_name,
            hour=hour,
        )

        self.record_bids(bids, model=model, date=date, hour=hour, market=market)

        return bids

    def compute_day_ahead_bids(self, date, hour=0):
        """
        Solve the model to bid into the day-ahead market. After solving, record
        the bids from the solve.

        Arguments:

            date: current simulation date

            hour: current simulation hour

        Returns:
            dict: the obtained bids
        """

        (
            day_ahead_price,
            real_time_energy_price,
        ) = self.forecaster.forecast_day_ahead_and_real_time_prices(
            date=date,
            hour=hour,
            bus=self.bidding_model_object.model_data.bus,
            horizon=self.day_ahead_horizon,
            n_samples=self.n_scenario,
        )

        return self._compute_bids(
            day_ahead_price=day_ahead_price,
            real_time_energy_price=real_time_energy_price,
            date=date,
            hour=hour,
            model=self.day_ahead_model,
            power_var_name="day_ahead_power",
            energy_price_param_name="day_ahead_energy_price",
            market="Day-ahead",
        )

    def compute_real_time_bids(
        self, date, hour, realized_day_ahead_prices, realized_day_ahead_dispatches
    ):
        """
        Solve the model to bid into the real-time market. After solving, record
        the bids from the solve.

        Arguments:

            date: current simulation date

            hour: current simulation hour

        Returns:
            dict: the obtained bids
        """

        real_time_energy_price = self.forecaster.forecast_real_time_prices(
            date=date,
            hour=hour,
            bus=self.bidding_model_object.model_data.bus,
            horizon=self.real_time_horizon,
            n_samples=self.n_scenario,
        )

        self._pass_realized_day_ahead_dispatches(realized_day_ahead_dispatches, hour)
        self._pass_realized_day_ahead_prices(realized_day_ahead_prices, date, hour)

        return self._compute_bids(
            day_ahead_price=None,
            real_time_energy_price=real_time_energy_price,
            date=date,
            hour=hour,
            model=self.real_time_model,
            power_var_name="power_output_ref",
            energy_price_param_name="real_time_energy_price",
            market="Real-time",
        )

    def _pass_realized_day_ahead_prices(self, realized_day_ahead_prices, date, hour):
        """
        Pass the realized day-ahead prices into model parameters.

        Arguments:
            realized_day_ahead_prices: realized day-ahead prices

            date: current simulation date

            hour: current simulation hour

        Returns:
            None
        """

        time_index = self.real_time_model.fs[0].day_ahead_energy_price.index_set()

        # forecast the day-ahead price, if not enough realized data
        if len(realized_day_ahead_prices[hour:]) < len(time_index):
            forecasts = self.forecaster.forecast_day_ahead_prices(
                date=date + datetime.timedelta(days=1),
                hour=0,
                bus=self.bidding_model_object.model_data.bus,
                horizon=self.day_ahead_horizon,
                n_samples=self.n_scenario,
            )

        for s in self.real_time_model.SCENARIOS:
            for t in time_index:
                try:
                    price = realized_day_ahead_prices[t + hour]
                except IndexError:
                    self.real_time_model.fs[s].day_ahead_energy_price[t] = forecasts[s][
                        (t + hour) - 24
                    ]
                else:
                    self.real_time_model.fs[s].day_ahead_energy_price[t] = price

    def _pass_realized_day_ahead_dispatches(self, realized_day_ahead_dispatches, hour):
        """
        Pass the realized day-ahead dispatches into model and fix the corresponding variables.

        Arguments:
            realized_day_ahead_dispatches: realized day-ahead dispatches

            hour: current simulation hour

        Returns:
            None
        """

        time_index = self.real_time_model.fs[0].day_ahead_power.index_set()
        for s in self.real_time_model.SCENARIOS:
            for t in time_index:
                try:
                    dispatch = realized_day_ahead_dispatches[t + hour]
                except IndexError:
                    self.real_time_model.fs[s].day_ahead_power[t].unfix()
                    # unrelax the DA offering UB
                    self.real_time_model.fs[s].real_time_underbid_power[t].fix(0)
                else:
                    self.real_time_model.fs[s].day_ahead_power[t].fix(dispatch)
                    # relax the DA offering UB
                    self.real_time_model.fs[s].real_time_underbid_power[t].unfix()

    def update_day_ahead_model(self, **kwargs):
        """
        This method updates the parameters in the day-ahead model based on the implemented profiles.

        Arguments:
            kwargs: the newly implemented stats. {stat_name: [...]}

        Returns:
            None
        """

        self._update_model(self.day_ahead_model, **kwargs)

    def update_real_time_model(self, **kwargs):
        """
        This method updates the parameters in the real-time model based on the implemented profiles.

        Arguments:
            kwargs: the newly implemented stats. {stat_name: [...]}

        Returns:
            None
        """

        self._update_model(self.real_time_model, **kwargs)

    def _update_model(self, model, **kwargs):
        """
        Update the flowsheets in all the price scenario blocks to advance time
        step.

        Arguments:

            model: bidding model

            kwargs: necessary profiles to update the underlying model. {stat_name: [...]}

        Returns:
            None
        """

        for i in model.SCENARIOS:
            self.bidding_model_object.update_model(b=model.fs[i], **kwargs)

        return

    def record_bids(self, bids, model, date, hour, market):
        """
        This function records the bids and the details in the underlying bidding model.

        Arguments:
            bids: the obtained bids for this date.

            model: bidding model

            date: the date we bid into

            hour: the hour we bid into

        Returns:
            None

        """

        # record bids
        self._record_bids(bids, date, hour, Market=market)

        # record the details of bidding model
        for i in model.SCENARIOS:
            self.bidding_model_object.record_results(
                model.fs[i], date=date, hour=hour, Scenario=i, Market=market
            )

        return

    def _pass_price_forecasts(self, model, day_ahead_price, real_time_energy_price):
        """
        Pass the price forecasts into model parameters.

        Arguments:
            day_ahead_price: day-ahead price forecasts needed to solve the bidding problem

            real_time_energy_price: real-time price forecasts needed to solve the bidding problem

        Returns:
            None
        """

        for i in model.SCENARIOS:

            time_index = model.fs[i].real_time_energy_price.index_set()

            if day_ahead_price is not None:
                for t, p in zip(time_index, day_ahead_price[i]):
                    model.fs[i].day_ahead_energy_price[t] = p

            for t, p in zip(time_index, real_time_energy_price[i]):
                model.fs[i].real_time_energy_price[t] = p

        return

    def write_results(self, path):
        """
        This methods writes the saved operation stats into an csv file.

        Arguments:
            path: the path to write the results.

        Return:
            None
        """

        _logger.info("Saving bidding results to disk.")
        pd.concat(self.bids_result_list).to_csv(
            os.path.join(path, "bidder_detail.csv"), index=False
        )
        self.bidding_model_object.write_results(
            path=os.path.join(path, "bidding_model_detail.csv")
        )

        return

    @property
    def generator(self):
        return self._generator

    @generator.setter
    def generator(self, name):
        self._generator = name


[docs] class SelfScheduler(StochasticProgramBidder): """ Wrap a model object to self schedule into the market using stochastic programming. """ def __init__( self, bidding_model_object, day_ahead_horizon, real_time_horizon, n_scenario, solver, forecaster, real_time_underbid_penalty=10000, fixed_to_schedule=False, ): """ Initializes the stochastic self-scheduler object. Arguments: bidding_model_object: the model object for bidding day_ahead_horizon: number of time periods in the day-ahead bidding problem real_time_horizon: number of time periods in the real-time bidding problem n_scenario: number of uncertain LMP scenarios solver: a Pyomo mathematical programming solver object forecaster: an initialized LMP forecaster object fixed_to_schedule: If True, force market simulator to give the same schedule. Returns: None """ super().__init__( bidding_model_object, day_ahead_horizon, real_time_horizon, n_scenario, solver, forecaster, real_time_underbid_penalty, ) self.fixed_to_schedule = fixed_to_schedule def _add_DA_bidding_constraints(self, model): """ Add bidding constraints to the model, i.e., power outputs in the first stage need to be the same across all the scenarios. Arguments: model: bidding model Returns: None """ # nonanticipativity constraints def day_ahead_bidding_constraints_rule(model, s1, s2, t): if s1 == s2: return pyo.Constraint.Skip return model.fs[s1].day_ahead_power[t] == model.fs[s2].day_ahead_power[t] time_index = model.fs[model.SCENARIOS.first()].power_output_ref.index_set() model.day_ahead_bidding_constraints = pyo.Constraint( model.SCENARIOS, model.SCENARIOS, time_index, rule=day_ahead_bidding_constraints_rule, ) return def _add_RT_bidding_constraints(self, model): """ Add bidding constraints to the model, i.e., power outputs in the first stage need to be the same across all the scenarios. Arguments: model: bidding model Returns: None """ # nonanticipativity constraints def real_time_bidding_constraints_rule(model, s1, s2, t): if s1 == s2: return pyo.Constraint.Skip return model.fs[s1].power_output_ref[t] == model.fs[s2].power_output_ref[t] time_index = model.fs[model.SCENARIOS.first()].power_output_ref.index_set() model.real_time_bidding_constraints = pyo.Constraint( model.SCENARIOS, model.SCENARIOS, time_index, rule=real_time_bidding_constraints_rule, ) return def _assemble_bids(self, model, power_var_name, energy_price_param_name, hour): """ This methods extract the bids out of the stochastic programming model and organize them into self-schedule bids. For thermal generators, startup times and costs are set to 0. And the bid price for power outside of p_min and p_max are 0. Arguments: model: bidding model power_var_name: the name of the power output (str) energy_price_param_name: the name of the energy price forecast params (str) hour: current simulation hour Returns: dict: the bid we computed. """ bids = {} is_thermal = self.bidding_model_object.model_data.generator_type == "thermal" power_output_var = getattr(model.fs[0], power_var_name) time_index = power_output_var.index_set() for t_idx in time_index: t = t_idx + hour bids[t] = {} bids[t][self.generator] = { "p_max": round(pyo.value(power_output_var[t_idx]), 4), "p_min": self.bidding_model_object.model_data.p_min, } if self.fixed_to_schedule: bids[t][self.generator]["p_min"] = bids[t][self.generator]["p_max"] bids[t][self.generator]["fixed_commitment"] = ( 1 if bids[t][self.generator]["p_min"] > 0 else 0 ) if is_thermal: bids[t][self.generator]["min_up_time"] = 0 bids[t][self.generator]["min_down_time"] = 0 bids[t][self.generator]["startup_fuel"] = [ (bids[t][self.generator]["min_down_time"], 0) ] bids[t][self.generator]["startup_cost"] = [ (bids[t][self.generator]["min_down_time"], 0) ] if is_thermal: bids[t][self.generator]["p_cost"] = [ (bids[t][self.generator]["p_min"], 0), (bids[t][self.generator]["p_max"], 0), ] bids[t][self.generator]["startup_capacity"] = bids[t][self.generator][ "p_min" ] bids[t][self.generator]["shutdown_capacity"] = bids[t][self.generator][ "p_min" ] return bids def _record_bids(self, bids, date, hour, **kwargs): """ This function records the bids (schedule) we computed for the given date into a DataFrame and temporarily stores the DataFrame in an instance attribute list called bids_result_list. Arguments: bids: the obtained bids (schedule) for this date. date: the date we bid into hour: the hour we bid into Returns: None """ df_list = [] for t in bids: for g in bids[t]: result_dict = {} result_dict["Generator"] = g result_dict["Date"] = date if hour is not None: result_dict["Hour"] = hour result_dict["Horizon"] = t result_dict["Bid Power [MW]"] = bids[t][g].get("p_max") result_dict["Bid Min Power [MW]"] = bids[t][g].get("p_min") for k, v in kwargs.items(): result_dict[k] = v result_df = pd.DataFrame.from_dict(result_dict, orient="index") df_list.append(result_df.T) # save the result to object property # wait to be written when simulation ends self.bids_result_list.append(pd.concat(df_list))
[docs] class Bidder(StochasticProgramBidder): """ Wrap a model object to bid into the market using stochastic programming. """ def __init__( self, bidding_model_object, day_ahead_horizon, real_time_horizon, n_scenario, solver, forecaster, real_time_underbid_penalty=10000, ): """ Initializes the bidder object. Arguments: bidding_model_object: the model object for bidding day_ahead_horizon: number of time periods in the day-ahead bidding problem real_time_horizon: number of time periods in the real-time bidding problem n_scenario: number of uncertain LMP scenarios solver: a Pyomo mathematical programming solver object forecaster: an initialized LMP forecaster object real_time_underbid_penalty: penalty for RT power bid that's less than DA power bid, non-negative Returns: None """ super().__init__( bidding_model_object, day_ahead_horizon, real_time_horizon, n_scenario, solver, forecaster, real_time_underbid_penalty, ) def _add_DA_bidding_constraints(self, model): """ Add bidding constraints to the model, i.e., the bid curves need to be nondecreasing. Arguments: model: bidding model Returns: None """ def day_ahead_bidding_constraints_rule(model, s1, s2, t): if s1 == s2: return pyo.Constraint.Skip return ( model.fs[s1].day_ahead_power[t] - model.fs[s2].day_ahead_power[t] ) * ( model.fs[s1].day_ahead_energy_price[t] - model.fs[s2].day_ahead_energy_price[t] ) >= 0 time_index = model.fs[model.SCENARIOS.first()].power_output_ref.index_set() model.day_ahead_bidding_constraints = pyo.Constraint( model.SCENARIOS, model.SCENARIOS, time_index, rule=day_ahead_bidding_constraints_rule, ) return def _add_RT_bidding_constraints(self, model): """ Add bidding constraints to the model, i.e., the bid curves need to be nondecreasing. Arguments: model: bidding model Returns: None """ def real_time_bidding_constraints_rule(model, s1, s2, t): if s1 == s2: return pyo.Constraint.Skip return ( model.fs[s1].power_output_ref[t] - model.fs[s2].power_output_ref[t] ) * ( model.fs[s1].real_time_energy_price[t] - model.fs[s2].real_time_energy_price[t] ) >= 0 time_index = model.fs[model.SCENARIOS.first()].power_output_ref.index_set() model.real_time_bidding_constraints = pyo.Constraint( model.SCENARIOS, model.SCENARIOS, time_index, rule=real_time_bidding_constraints_rule, ) return def _assemble_bids(self, model, power_var_name, energy_price_param_name, hour): """ This methods extract the bids out of the stochastic programming model and organize them into ( MWh, $ ) pairs. Arguments: model: bidding model power_var_name: the name of the power output (str) energy_price_param_name: the name of the energy price forecast params (str) hour: current simulation hour Returns: bids: the bid we computed. """ bids = {} gen = self.generator for i in model.SCENARIOS: power_output_var = getattr(model.fs[i], power_var_name) energy_price_param = getattr(model.fs[i], energy_price_param_name) time_index = power_output_var.index_set() for t in time_index: if t not in bids: bids[t] = {} if gen not in bids[t]: bids[t][gen] = {} power = round(pyo.value(power_output_var[t]), 2) marginal_cost = round(pyo.value(energy_price_param[t]), 2) # if power lower than pmin, e.g., power = 0, we need to skip this # solution, because Prescient is not expecting any power output lower # than pmin in the bids if power < self.bidding_model_object.model_data.p_min: continue elif power in bids[t][gen]: bids[t][gen][power] = min(bids[t][gen][power], marginal_cost) else: bids[t][gen][power] = marginal_cost for t in time_index: # always include pmin in the cost curve, but include the other points if required if self.bidding_model_object.model_data.include_default_p_cost: p_cost_add = self.bidding_model_object.model_data.p_cost else: p_cost_add = self.bidding_model_object.model_data.p_cost[0:1] for power, marginal_cost in p_cost_add: if round(power, 2) not in bids[t][gen]: bids[t][gen][power] = marginal_cost pmin = self.bidding_model_object.model_data.p_min # sort the curves by power bids[t][gen] = dict(sorted(bids[t][gen].items())) # make sure the curve is nondecreasing pre_power = pmin for power, marginal_cost in bids[t][gen].items(): # ignore pmin, because min load cost is special if pre_power == pmin: pre_power = power continue bids[t][gen][power] = max(bids[t][gen][power], bids[t][gen][pre_power]) pre_power = power # calculate the actual cost bids[t][gen] = convert_marginal_costs_to_actual_costs( list(bids[t][gen].items()) ) # check if bids are convex for t in bids: for gen in bids[t]: temp_curve = { "data_type": "cost_curve", "cost_curve_type": "piecewise", "values": bids[t][gen], } try: tx_utils.validate_and_clean_cost_curve( curve=temp_curve, curve_type="cost_curve", p_min=min([p[0] for p in bids[t][gen]]), p_max=max([p[0] for p in bids[t][gen]]), gen_name=gen, t=t, ) except NameError: raise RuntimeError( "'egret' must be installed to use this functionality" ) # create full bids: this includes info in addition to costs full_bids = {} for t_idx in bids: t = t_idx + hour full_bids[t] = {} for gen in bids[t_idx]: full_bids[t][gen] = {} full_bids[t][gen]["p_cost"] = bids[t_idx][gen] full_bids[t][gen]["p_min"] = min([p[0] for p in bids[t_idx][gen]]) full_bids[t][gen]["p_max"] = max([p[0] for p in bids[t_idx][gen]]) full_bids[t][gen]["p_min_agc"] = min([p[0] for p in bids[t_idx][gen]]) full_bids[t][gen]["p_max_agc"] = max([p[0] for p in bids[t_idx][gen]]) full_bids[t][gen]["startup_capacity"] = full_bids[t][gen]["p_min"] full_bids[t][gen]["shutdown_capacity"] = full_bids[t][gen]["p_min"] fixed_commitment = getattr( self.bidding_model_object.model_data, "fixed_commitment", None ) if fixed_commitment is not None: full_bids[t][gen]["fixed_commitment"] = fixed_commitment return full_bids def _record_bids(self, bids, date, hour, **kwargs): """ This method records the bids we computed for the given date into a DataFrame. This DataFrame has the following columns: gen, date, hour, power 1, ..., power n, price 1, ..., price n. And concatenate the DataFrame into a class property 'bids_result_list'. The methods then temporarily stores the DataFrame in an instance attribute list called bids_result_list. Arguments: bids: the obtained bids for this date. date: the date we bid into hour: the hour we bid into Returns: None """ df_list = [] for t in bids: for gen in bids[t]: result_dict = {} result_dict["Generator"] = gen result_dict["Date"] = date result_dict["Hour"] = t for k, v in kwargs.items(): result_dict[k] = v pair_cnt = len(bids[t][gen]["p_cost"]) for idx, (power, cost) in enumerate(bids[t][gen]["p_cost"]): result_dict[f"Power {idx} [MW]"] = power result_dict[f"Cost {idx} [$]"] = cost # place holder, in case different len of bids while pair_cnt < self.n_scenario: result_dict[f"Power {pair_cnt} [MW]"] = None result_dict[f"Cost {pair_cnt} [$]"] = None pair_cnt += 1 result_df = pd.DataFrame.from_dict(result_dict, orient="index") df_list.append(result_df.T) # save the result to object property # wait to be written when simulation ends self.bids_result_list.append(pd.concat(df_list)) return
class ParametrizedBidder(AbstractBidder): """ Create a parameterized bidder. Bid the resource at different prices. """ def __init__( self, bidding_model_object, day_ahead_horizon, real_time_horizon, solver, forecaster, ): """ Arguments: bidding_model_object: pyomo model object, the IES model object for bidding. day_ahead_horizon: int, number of time periods in the day-ahead bidding problem. real_time_horizon: int, number of time periods in the real-time bidding problem. solver: a Pyomo mathematical programming solver object, solver for solving the bidding problem. In this class we do not need a solver. forecaster: forecaster object, the forecaster to predict the generator/IES capacity factor. """ self.bidding_model_object = bidding_model_object self.day_ahead_horizon = day_ahead_horizon self.real_time_horizon = real_time_horizon self.solver = solver self.forecaster = forecaster # Because ParameterizedBidder is inherited from the AbstractBidder, and we want to # use the _check_inputs() function to check the solver and bidding_model_object. # We must have the self.scenario attribute. In this case, I set the self.n_scenario = 1 when initializing. # However, self.n_scenario will never be used in this class. self.n_scenario = 1 self._check_inputs() self.generator = self.bidding_model_object.model_data.gen_name self.bids_result_list = [] @property def generator(self): return self._generator @generator.setter def generator(self, name): self._generator = name def formulate_DA_bidding_problem(self): """ No need to formulate a DA bidding problem here. Arguments: None Returns: None """ pass def formulate_RT_bidding_problem(self): """ No need to formulate a RT bidding problem here. Arguments: None Returns: None """ pass def compute_day_ahead_bids(self, date, hour=0): raise NotImplementedError def compute_real_time_bids( self, date, hour, realized_day_ahead_prices, realized_day_ahead_dispatches, tracker_profile, ): raise NotImplementedError def update_day_ahead_model(self, **kwargs): """ No need to update the RT bidding problem here. Arguments: None Returns: None """ pass def update_real_time_model(self, **kwargs): """ No need to update the RT bidding problem here. Arguments: None Returns: None """ pass def record_bids(self, bids: dict, model, date: str, hour: int, market): """ This function records the bids and the details in the underlying bidding model. The detailed bids of each time step at day-ahead and real-time planning horizon will be recorded at bidder_detail.csv Arguments: bids: dictionary, the obtained bids for this date. Keys are time step t, example as following: bids = {t: {gen: {p_cost: float, p_max: float, p_min: float, startup_capacity: float, shutdown_capacity: float}}} model: pyomo model object, our bidding model. date: str, the date we bid into. hour: int, the hour we bid into. market: str, the market we participate. Returns: None """ # record bids self._record_bids(bids, date, hour, Market=market) return def _record_bids(self, bids: dict, date: str, hour: int, **kwargs): """ Record the bis of each time period. Arguments: bids: dictionary, the obtained bids for this date. Keys are time step t, example as following: bids = {t: {gen: {p_cost: float, p_max: float, p_min: float, startup_capacity: float, shutdown_capacity: float}}} date: str, the date we bid into. hour: int, the hour we bid into. Returns: None """ df_list = [] for t in bids: for gen in bids[t]: result_dict = {} result_dict["Generator"] = gen result_dict["Date"] = date result_dict["Hour"] = t for k, v in kwargs.items(): result_dict[k] = v num_bid_pairs = len(bids[t][gen]["p_cost"]) for idx, (power, cost) in enumerate(bids[t][gen]["p_cost"]): result_dict[f"Power {idx} [MW]"] = power result_dict[f"Cost {idx} [$]"] = cost result_df = pd.DataFrame.from_dict(result_dict, orient="index") df_list.append(result_df.T) # save the result to object property # wait to be written when simulation ends self.bids_result_list.append(pd.concat(df_list)) return def write_results(self, path): """ This methods writes the saved operation stats into an csv file. Arguments: path: str or Pathlib object, the path to write the results. Return: None """ _logger.info("Saving bidding results to disk.") pd.concat(self.bids_result_list).to_csv( os.path.join(path, "bidder_detail.csv"), index=False ) return
[docs] class PEMParametrizedBidder(ParametrizedBidder): """ Renewable (PV or Wind) + PEM bidder that uses parameterized bid curves. """ def __init__( self, bidding_model_object, day_ahead_horizon, real_time_horizon, solver, forecaster, renewable_mw, pem_marginal_cost, pem_mw, real_time_bidding_only=False, ): """ Arguments: bidding_model_object: pyomo model object, the IES model object for bidding. day_ahead_horizon: int, number of time periods in the day-ahead bidding problem. real_time_horizon: int, number of time periods in the real-time bidding problem. solver: a Pyomo mathematical programming solver object, solver for solving the bidding problem. In this class we do not need a solver. forecaster: forecaster object, the forecaster to predict the generator/IES capacity factor. renewable_mw: int or float, maximum renewable energy system capacity. pem_marginal_cost: int or float, cost/MW, above which all available wind energy will be sold to grid; below which, make hydrogen and sell remainder of wind to grid. pem_mw: int or float, maximum PEM capacity limits how much energy is bid at the `pem_marginal_cost`. real_time_bidding_only: bool, if True, do real-time bidding only. """ super().__init__( bidding_model_object, day_ahead_horizon, real_time_horizon, solver, forecaster, ) self.renewable_mw = renewable_mw self.renewable_marginal_cost = 0 self.pem_marginal_cost = pem_marginal_cost self.pem_mw = pem_mw self.real_time_bidding_only = real_time_bidding_only self._check_power() def _check_power(self): """ Check the power of PEM should not exceed the power of renewables """ if self.pem_mw >= self.renewable_mw: raise ValueError(f"The power of PEM is greater than the renewable power.")
[docs] def compute_day_ahead_bids(self, date: str, hour=0): """ DA Bid: from 0 MW to (Wind Resource - PEM capacity) MW, bid $0/MWh. from (Wind Resource - PEM capacity) MW to Wind Resource MW, bid 'pem_marginal_cost' If Wind resource at some time is less than PEM capacity, then reduce to available resource Arguments: date: str, the date we bid into. hour: int, the hour we bid into. Returns: full_bids: dictionary, the obtained bids. Keys are time step t, example as following: bids = {t: {gen: {p_cost: float, p_max: float, p_min: float, startup_capacity: float, shutdown_capacity: float}}} """ gen = self.generator # Forecast the day-ahead wind generation forecast = self.forecaster.forecast_day_ahead_capacity_factor( date, hour, gen, self.day_ahead_horizon ) full_bids = {} for t_idx in range(self.day_ahead_horizon): da_wind = forecast[t_idx] * self.renewable_mw grid_wind = max(0, da_wind - self.pem_mw) # grid wind are bidded at marginal cost = 0 # The rest of the power is bidded at the pem marginal cost if grid_wind == 0: bids = [(0, 0), (da_wind, self.pem_marginal_cost)] else: bids = [(0, 0), (grid_wind, 0), (da_wind, self.pem_marginal_cost)] cost_curve = convert_marginal_costs_to_actual_costs(bids) temp_curve = { "data_type": "cost_curve", "cost_curve_type": "piecewise", "values": cost_curve, } tx_utils.validate_and_clean_cost_curve( curve=temp_curve, curve_type="cost_curve", p_min=0, p_max=max([p[0] for p in cost_curve]), gen_name=gen, t=t_idx, ) t = t_idx + hour full_bids[t] = {} full_bids[t][gen] = {} full_bids[t][gen]["p_cost"] = cost_curve full_bids[t][gen]["p_min"] = 0 full_bids[t][gen]["p_max"] = da_wind full_bids[t][gen]["startup_capacity"] = da_wind full_bids[t][gen]["shutdown_capacity"] = da_wind self._record_bids(full_bids, date, hour, Market="Day-ahead") return full_bids
[docs] def compute_real_time_bids( self, date, hour, realized_day_ahead_dispatches, realized_day_ahead_prices ): """ RT Bid: from 0 MW to (Wind Resource - PEM capacity) MW, bid $0/MWh. from (Wind Resource - PEM capacity) MW to Wind Resource MW, bid 'pem_marginal_cost' Arguments: date: str, the date we bid into hour: int, the hour we bid into Returns: full_bids: dictionary, the obtained bids. Keys are time step t, example as following: bids = {t: {gen: {p_cost: float, p_max: float, p_min: float, startup_capacity: float, shutdown_capacity: float}}} """ gen = self.generator forecast = self.forecaster.forecast_real_time_capacity_factor( date, hour, gen, self.real_time_horizon ) full_bids = {} for t_idx in range(self.real_time_horizon): rt_wind = forecast[t_idx] * self.renewable_mw # if we participate in both DA and RT market if not self.real_time_bidding_only: try: da_dispatch = realized_day_ahead_dispatches[t_idx + hour] except IndexError: # When having indexerror, it must be the period that we are looking ahead. It is ok to set da_dispatch to 0 da_dispatch = 0 # if we only participates in the RT market, then we do not consider the DA commitment else: da_dispatch = 0 avail_rt_wind = max(0, rt_wind - da_dispatch) grid_wind = max(0, avail_rt_wind - self.pem_mw) if avail_rt_wind == 0: bids = [(0, 0), (0, 0)] else: if grid_wind == 0: bids = [(0, 0), (avail_rt_wind, self.pem_marginal_cost)] else: bids = [ (0, 0), (grid_wind, 0), (avail_rt_wind, self.pem_marginal_cost), ] cost_curve = convert_marginal_costs_to_actual_costs(bids) temp_curve = { "data_type": "cost_curve", "cost_curve_type": "piecewise", "values": cost_curve, } tx_utils.validate_and_clean_cost_curve( curve=temp_curve, curve_type="cost_curve", p_min=0, p_max=max([p[0] for p in cost_curve]), gen_name=gen, t=t_idx, ) t = t_idx + hour full_bids[t] = {} full_bids[t][gen] = {} full_bids[t][gen]["p_cost"] = cost_curve full_bids[t][gen]["p_min"] = 0 full_bids[t][gen]["p_max"] = max([p[0] for p in cost_curve]) full_bids[t][gen]["startup_capacity"] = rt_wind full_bids[t][gen]["shutdown_capacity"] = rt_wind self._record_bids(full_bids, date, hour, Market="Real-time") return full_bids