#################################################################################
# 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


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))


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


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.")

    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

    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
