Ejemplo n.º 1
0
    def construct_model(self):
        """Construct subproblem model components"""

        # Used to define sets and parameters common to both master and subproblem
        common_components = CommonComponents()

        # Initialise base model object
        m = ConcreteModel()

        # Define sets - common to both master and subproblem
        m = common_components.define_sets(m)

        # Define parameters
        m = common_components.define_parameters(m)

        # Define variables
        m = common_components.define_variables(m)

        # Define parameters
        m = self.define_parameters(m)

        # Define expressions
        m = self.define_expressions(m)

        # Define investment constraints
        m = self.define_investment_constraints(m)

        return m
Ejemplo n.º 2
0
    def __init__(self, first_year=2016, final_year=2040, scenarios_per_year=5):
        self.dual = Dual(first_year, final_year, scenarios_per_year)
        self.data = ModelData()
        self.analysis = AnalyseResults()

        # Common model components. May need to update these values
        self.common = CommonComponents(first_year=first_year,
                                       final_year=final_year,
                                       scenarios_per_year=scenarios_per_year)

        # Model sets
        self.sets = self.get_model_sets()
Ejemplo n.º 3
0
class PriceSetter:
    def __init__(self, first_year=2016, final_year=2040, scenarios_per_year=5):
        self.dual = Dual(first_year, final_year, scenarios_per_year)
        self.data = ModelData()
        self.analysis = AnalyseResults()

        # Common model components. May need to update these values
        self.common = CommonComponents(first_year=first_year,
                                       final_year=final_year,
                                       scenarios_per_year=scenarios_per_year)

        # Model sets
        self.sets = self.get_model_sets()

    @staticmethod
    def convert_to_frame(results, index_name, variable_name):
        """Convert dict to pandas DataFrame"""

        # Convert dictionary to DataFrame
        df = pd.Series(
            results[variable_name]).rename_axis(index_name).to_frame(
                name=variable_name)

        return df

    def get_model_sets(self):
        """Define sets used in model"""

        # Get all sets used within the model
        m = ConcreteModel()
        m = self.common.define_sets(m)

        return m

    def get_eligible_generators(self):
        """Find generators which are eligible for rebates / penalties"""

        # Eligible generators
        eligible_generators = [
            g for g in self.sets.G_THERM.union(self.sets.G_C_WIND,
                                               self.sets.G_C_SOLAR)
        ]

        return eligible_generators

    def get_generator_cost_parameters(self, results_dir, filename,
                                      eligible_generators):
        """
        Get parameters affecting generator marginal costs, and compute the net marginal cost for a given policy
        """

        # Model results
        results = self.analysis.load_results(results_dir, filename)

        # Price setting algorithm
        costs = pd.Series(results['C_MC']).rename_axis(
            ['generator', 'year']).to_frame(name='marginal_cost')

        # Add emissions intensity baseline
        costs = costs.join(pd.Series(
            results['baseline']).rename_axis('year').to_frame(name='baseline'),
                           how='left')

        # Add permit price
        costs = (costs.join(pd.Series(
            results['permit_price']).rename_axis('year').to_frame(
                name='permit_price'),
                            how='left'))

        # Emissions intensities for existing and candidate units
        existing_emissions = self.analysis.data.existing_units.loc[:, (
            'PARAMETERS', 'EMISSIONS')]
        candidate_emissions = self.analysis.data.candidate_units.loc[:, (
            'PARAMETERS', 'EMISSIONS')]

        # Combine emissions intensities into a single DataFrame
        emission_intensities = (pd.concat([
            existing_emissions, candidate_emissions
        ]).rename_axis('generator').to_frame('emissions_intensity'))

        # Join emissions intensities
        costs = costs.join(emission_intensities, how='left')

        # Total marginal cost (taking into account net cost under policy)
        costs['net_marginal_cost'] = (
            costs['marginal_cost'] +
            (costs['emissions_intensity'] - costs['baseline']) *
            costs['permit_price'])

        def correct_for_ineligible_generators(row):
            """Update costs so only eligible generators have new costs (ineligible generators have unchanged costs)"""

            if row.name[0] in eligible_generators:
                return row['net_marginal_cost']
            else:
                return row['marginal_cost']

        # Correct for ineligible generators
        costs['net_marginal_cost'] = costs.apply(
            correct_for_ineligible_generators, axis=1)

        return costs

    def get_price_setting_generators(self, results_dir, filename,
                                     eligible_generators):
        """Find price setting generators"""

        # Prices
        prices = self.analysis.parse_prices(results_dir, filename)

        # Generator SRMC and cost parameters (emissions intensities, baselines, permit prices)
        generator_costs = self.get_generator_cost_parameters(
            results_dir, filename, eligible_generators)

        def get_price_setting_generator(row):
            """Get price setting generator, price difference, and absolute real price"""

            # Year and average real price for a given row
            year, price = row.name[0], row['average_price_real']

            # Absolute difference between price and all generator SRMCs
            abs_price_difference = generator_costs.loc[(
                slice(None), year), 'net_marginal_cost'].subtract(price).abs()

            # Price setting generator and absolute price difference for that generator
            generator, difference = abs_price_difference.idxmin(
            )[0], abs_price_difference.min()

            # Generator SRMC
            srmc = generator_costs.loc[(generator, year), 'net_marginal_cost']

            # Update generator name to load shedding if price very high (indicative of load shedding)
            if difference > 9000:
                generator = 'LOAD-SHEDDING'
                difference = np.nan
                srmc = np.nan

            return pd.Series({
                'generator': generator,
                'difference': difference,
                'price': price,
                'srmc': srmc
            })

        # Find price setting generators
        price_setters = prices.apply(get_price_setting_generator, axis=1)

        # Combine output into single dictionary
        output = {
            'price_setters': price_setters,
            'prices': prices,
            'generator_costs': generator_costs
        }

        return output

    def get_dual_component_existing_thermal(self, results):
        """Get dual variable component of dual constraint for existing thermal units"""

        # def get_existing_thermal_unit_dual_information():
        dfs = []

        for v in ['SIGMA_1', 'SIGMA_2', 'SIGMA_20', 'SIGMA_23']:
            print(v)
            index = ('generator', 'year', 'scenario', 'interval')
            dfs.append(self.convert_to_frame(results, index, v))

        # Place all information in a single DataFrame
        df_c = pd.concat(dfs, axis=1).dropna()

        # Get offset values
        df_c['SIGMA_20_PLUS_1'] = df_c['SIGMA_20'].shift(-1)
        df_c['SIGMA_23_PLUS_1'] = df_c['SIGMA_23'].shift(-1)

        return df_c

    def k(self, g):
        """Mapping generator to the NEM zone to which it belongs"""

        if g in self.sets.G_E:
            return self.data.existing_units_dict[('PARAMETERS', 'NEM_ZONE')][g]

        elif g in self.sets.G_C.difference(self.sets.G_STORAGE):
            return self.data.candidate_units_dict[('PARAMETERS', 'ZONE')][g]

        elif g in self.sets.G_STORAGE:
            return self.data.battery_properties_dict['NEM_ZONE'][g]

        else:
            raise Exception(f'Unexpected generator: {g}')

    @staticmethod
    def merge_generator_node_prices(dual_component, zone_prices):
        """Get prices at the node to which a generator is connected"""

        # Merge price information
        df = (pd.merge(dual_component.reset_index(),
                       zone_prices.reset_index(),
                       how='left',
                       left_on=['zone', 'year', 'scenario', 'interval'],
                       right_on=['zone', 'year', 'scenario',
                                 'interval']).set_index([
                                     'generator', 'year', 'scenario',
                                     'interval', 'zone'
                                 ]))

        return df

    def get_generator_cost_information(self, results):
        """Merge generator cost information"""

        # Load results
        delta = self.convert_to_frame(results, 'year', 'DELTA')
        rho = self.convert_to_frame(results, ('year', 'scenario'), 'RHO')
        emissions_rate = self.convert_to_frame(results, 'generator',
                                               'EMISSIONS_RATE')
        baseline = self.convert_to_frame(results, 'year', 'baseline')
        permit_price = self.convert_to_frame(results, 'year', 'permit_price')
        marginal_cost = self.convert_to_frame(results, ('generator', 'year'),
                                              'C_MC')

        # Join information into single dataFrame
        df_c = marginal_cost.join(emissions_rate, how='left')
        df_c = df_c.join(baseline, how='left')
        df_c = df_c.join(permit_price, how='left')
        df_c = df_c.join(delta, how='left')
        df_c = df_c.join(rho, how='left')

        # Add a scaling factor for the final year
        final_year = df_c.index.levels[0][-1]
        df_c['scaling_factor'] = df_c.apply(
            lambda x: 1 if int(x.name[0]) < final_year else 1 + (1 / 0.06),
            axis=1)

        return df_c

    def merge_generator_cost_information(self, df, results):
        """Merge generator cost information from model"""

        # Get generator cost information
        generator_cost_info = self.get_generator_cost_information(results)

        df = (pd.merge(df.reset_index(),
                       generator_cost_info.reset_index(),
                       how='left').set_index([
                           'generator', 'year', 'scenario', 'interval', 'zone'
                       ]))

        return df

    def get_constraint_body_existing_thermal(self, results):
        """Get body of dual power output constraint for existing thermal generators"""

        # Components of dual power output constraint
        duals = self.get_dual_component_existing_thermal(results)

        # Map between generators and zones
        generators = duals.index.levels[0]
        generator_zone_map = (pd.DataFrame.from_dict(
            {g: self.k(g)
             for g in generators},
            orient='index',
            columns=['zone']).rename_axis('generator'))

        # Add NEM zone to index
        duals = duals.join(generator_zone_map,
                           how='left').set_index('zone', append=True)

        # Power balance dual variables
        var_index = ('zone', 'year', 'scenario', 'interval')
        prices = self.convert_to_frame(results, var_index, 'PRICES')

        # Merge price information
        c = self.merge_generator_node_prices(duals, prices)

        # Merge operating cost information
        c = price_setter.merge_generator_cost_information(c, results)

        return c

    def evaluate_constraint_body_existing_thermal(self, results):
        """Evaluate constraint body information for existing thermal units (should = 0)"""

        # Get values of terms constituting the constraint
        c = self.get_constraint_body_existing_thermal(results)

        # Correct for all intervals excluding the last interval of each scenario
        s_1 = (-c['SIGMA_1'].abs() + c['SIGMA_2'].abs() - c['PRICES'].abs() +
               (c['DELTA'] * c['RHO'] * c['scaling_factor'] *
                (c['C_MC'] +
                 (c['EMISSIONS_RATE'] - c['baseline']) * c['permit_price'])) +
               c['SIGMA_20'].abs() - c['SIGMA_20_PLUS_1'].abs() -
               c['SIGMA_23'].abs() + c['SIGMA_23_PLUS_1'].abs())

        # Set last interval to NaN
        s_1.loc[(slice(None), slice(None), slice(None), 24,
                 slice(None))] = np.nan

        # Last interval of each scenario
        s_2 = (-c['SIGMA_1'].abs() + c['SIGMA_2'].abs() - c['PRICES'].abs() +
               (c['DELTA'] * c['RHO'] * c['scaling_factor'] *
                (c['C_MC'] +
                 (c['EMISSIONS_RATE'] - c['baseline']) * c['permit_price'])) +
               c['SIGMA_20'].abs() - c['SIGMA_23'].abs())

        # Update so corrected values for last interval are accounted for
        s_3 = s_1.to_frame(name='body')
        s_3.update(s_2.to_frame(name='body'), overwrite=False)

        return s_3

    def evaluate_constraint_dual_component_existing_thermal(self, results):
        """Evaluate dual component of constraint"""

        # Get values of terms constituting the constraint
        c = self.get_constraint_body_existing_thermal(results)

        # Dual component - correct for intervals excluding the last interval of each scenario
        s_1 = (-c['SIGMA_1'].abs() + c['SIGMA_2'].abs() + c['SIGMA_20'].abs() -
               c['SIGMA_20_PLUS_1'].abs() - c['SIGMA_23'].abs() +
               c['SIGMA_23_PLUS_1'].abs())

        # Set last interval to NaN
        s_1.loc[(slice(None), slice(None), slice(None), 24,
                 slice(None))] = np.nan

        # Dual component - correct for last interval of each scenario
        s_2 = -c['SIGMA_1'].abs() + c['SIGMA_2'].abs() + c['SIGMA_20'].abs(
        ) - c['SIGMA_23'].abs()

        # Combine components
        s_3 = s_1.to_frame(name='body')
        s_3.update(s_2.to_frame(name='body'), overwrite=False)

        return s_3

    def get_price_setting_generators_from_model_results(self, results):
        """Find price setting generators"""

        # Generators eligible for a rebate / penalty under the scheme
        eligible_generators = self.get_eligible_generators()

        # Generator costs
        generator_costs = self.get_generator_cost_information(results)

        # Get prices in each zone for each dispatch interval
        index = ('zone', 'year', 'scenario', 'interval')
        zone_price = self.convert_to_frame(results, index, 'PRICES')

        def correct_permit_prices(row):
            """Only eligible generators face a non-zero permit price"""

            if row.name[1] in eligible_generators:
                return row['permit_price']
            else:
                return 0

        # Update permit prices
        generator_costs['permit_price'] = generator_costs.apply(
            correct_permit_prices, axis=1)

        # Net marginal costs
        generator_costs['net_marginal_cost'] = (
            generator_costs['scaling_factor'] * generator_costs['DELTA'] *
            generator_costs['RHO'] *
            (generator_costs['C_MC'] +
             (generator_costs['EMISSIONS_RATE'] - generator_costs['baseline'])
             * generator_costs['permit_price']))

        def get_price_setter(row):
            """Find generator whose marginal cost is closest to interval marginal cost"""

            # Extract zone, year, scenario, and interval information
            z, y, s, t = row.name

            # Power balance constraint marginal cost for given interval (related to price)
            p = abs(row['PRICES'])

            # Net marginal costs of all generators for the given interval
            generator_mc = generator_costs.loc[(y, slice(None), s), :]

            # Scenario duration and discount factor (arbitrarily selecting YWPS4 to get a single row)
            rho, delta = generator_costs.loc[(y, 'YWPS4', s),
                                             ['RHO', 'DELTA']].values

            # Difference between marginal cost in given interval and all generator marginal costs for that interval
            diff = generator_mc['net_marginal_cost'].subtract(p).abs()

            # Extract generator ID and absolute cost difference
            g, cost_diff = diff.idxmin(), diff.min()

            # Details of the price setting generator
            cols = [
                'EMISSIONS_RATE', 'baseline', 'permit_price', 'C_MC',
                'net_marginal_cost', 'scaling_factor'
            ]
            emissions_rate, baseline, permit_price, marginal_cost, net_marginal_cost, scaling_factor = generator_costs.loc[
                g, cols]

            # Compute normalised price and cost differences
            price_normalised = p / (delta * rho * scaling_factor)
            cost_diff_normalised = cost_diff / (delta * rho)
            net_marginal_cost_normalised = net_marginal_cost / (delta * rho *
                                                                scaling_factor)

            return (g[1], p, price_normalised, cost_diff, cost_diff_normalised,
                    emissions_rate, baseline, permit_price, marginal_cost,
                    net_marginal_cost, net_marginal_cost_normalised)

        # Get price setting generator information
        ps = zone_price.apply(get_price_setter, axis=1)

        # Convert column of tuples to DataFrame with separate columns
        columns = [
            'generator', 'price_abs', 'price_normalised', 'difference_abs',
            'difference_normalised', 'emissions_rate', 'baseline',
            'permit_price', 'marginal_cost', 'net_marginal_cost',
            'net_marginal_cost_normalised'
        ]
        ps = pd.DataFrame(ps.to_list(),
                          columns=columns,
                          index=zone_price.index)

        return ps
Ejemplo n.º 4
0
class InvestmentPlan:
    # Pre-processed data for model construction
    data = ModelData()

    # Common model components to investment plan and operating sub-problems (sets)
    components = CommonComponents()

    def __init__(self):
        # Solver options
        self.keepfiles = False
        self.solver_options = {}  # 'MIPGap': 0.0005
        self.opt = SolverFactory('gurobi', solver_io='mps')

    def define_parameters(self, m):
        """Investment plan model parameters"""

        def solar_build_limits_rule(_m, z):
            """Solar build limits for each NEM zone"""

            return float(self.data.candidate_unit_build_limits_dict[z]['SOLAR'])

        # Maximum solar capacity allowed per zone
        m.SOLAR_BUILD_LIMITS = Param(m.Z, rule=solar_build_limits_rule)

        def wind_build_limits_rule(_m, z):
            """Wind build limits for each NEM zone"""

            return float(self.data.candidate_unit_build_limits_dict[z]['WIND'])

        # Maximum wind capacity allowed per zone
        m.WIND_BUILD_LIMITS = Param(m.Z, rule=wind_build_limits_rule)

        def storage_build_limits_rule(_m, z):
            """Storage build limits for each NEM zone"""

            return float(self.data.candidate_unit_build_limits_dict[z]['STORAGE'])

        # Maximum storage capacity allowed per zone
        m.STORAGE_BUILD_LIMITS = Param(m.Z, rule=storage_build_limits_rule)

        def candidate_unit_build_costs_rule(_m, g, y):
            """
            Candidate unit build costs [$/MW]

            Note: build cost in $/MW. May need to scale if numerical conditioning problems.
            """

            if g in m.G_C_STORAGE:
                return float(self.data.battery_build_costs_dict[y][g] * 1000)
            else:
                return float(self.data.candidate_units_dict[('BUILD_COST', y)][g] * 1000)

        # Candidate unit build cost
        m.I_C = Param(m.G_C, m.Y, rule=candidate_unit_build_costs_rule)

        def candidate_unit_life_rule(_m, g):
            """Asset life of candidate units [years]"""
            # TODO: Use data from NTNPD. Just making an assumption for now.
            return float(25)

        # Candidate unit life
        m.A = Param(m.G_C, rule=candidate_unit_life_rule)

        def amortisation_rate_rule(_m, g):
            """Amortisation rate for a given investment"""

            # Numerator for amortisation rate expression
            num = self.data.WACC * ((1 + self.data.WACC) ** m.A[g])

            # Denominator for amortisation rate expression
            den = ((1 + self.data.WACC) ** m.A[g]) - 1

            # Amortisation rate
            amortisation_rate = num / den

            return amortisation_rate

        # Amortisation rate for a given investment
        m.GAMMA = Param(m.G_C, rule=amortisation_rate_rule)

        def fixed_operations_and_maintenance_cost_rule(_m, g):
            """Fixed FOM cost [$/MW/year]

            Note: Data in NTNDP is in terms of $/kW/year. Must multiply by 1000 to convert to $/MW/year
            """

            if g in m.G_E:
                return float(self.data.existing_units_dict[('PARAMETERS', 'FOM')][g] * 1000)

            elif g in m.G_C_THERM.union(m.G_C_WIND, m.G_C_SOLAR):
                return float(self.data.candidate_units_dict[('PARAMETERS', 'FOM')][g] * 1000)

            elif g in m.G_STORAGE:
                # TODO: Need to find reasonable FOM cost for storage units - setting = MEL-WIND for now
                return float(self.data.candidate_units_dict[('PARAMETERS', 'FOM')]['MEL-WIND'] * 1000)
            else:
                raise Exception(f'Unexpected generator encountered: {g}')

        # Fixed operations and maintenance cost
        m.C_FOM = Param(m.G, rule=fixed_operations_and_maintenance_cost_rule)

        def discount_factor_rule(_m, y):
            """Discount factor"""

            # Discount factor
            discount_factor = 1 / ((1 + self.data.WACC) ** (y - m.Y.first()))

            return discount_factor

        # Discount factor - used to take into account the time value of money and compute present values
        m.DISCOUNT_FACTOR = Param(m.Y, rule=discount_factor_rule)

        # Total emissions (obtained by computing emissions from all sub-problems). Updated each iteration.
        m.TOTAL_EMISSIONS = Param(initialize=0, mutable=True)

        # Cumulative emissions target
        m.CUMULATIVE_EMISSIONS_TARGET = Param(initialize=100e9, mutable=True)

        # Cost of violating cumulative emissions constraint (assumed) [$/tCO2]
        m.C_E = Param(initialize=10000)

        # Weighted cost of capital - interest rate assumed in discounting + amortisation calculations
        m.WACC = Param(initialize=float(self.data.WACC))

        # Candidate capacity dual variable obtained from sub-problem solution. Updated each iteration.
        m.PSI_FIXED = Param(m.G_C, m.Y, m.S, initialize=0, mutable=True)

        # Candidate capacity variable obtained from sub-problem
        m.CANDIDATE_CAPACITY_FIXED = Param(m.G_C, m.Y, m.S, initialize=0, mutable=True)

        return m

    @staticmethod
    def define_variables(m):
        """Define investment plan variables"""

        # Investment in each time period
        m.x_c = Var(m.G_C, m.Y, within=NonNegativeReals, initialize=0)

        # Binary variable to select capacity size
        m.d = Var(m.G_C_THERM, m.Y, m.G_C_THERM_SIZE_OPTIONS, within=Binary, initialize=0)

        # Auxiliary variable - total capacity available at each time period
        m.a = Var(m.G_C, m.Y, initialize=0)

        # Cumulative emissions constraint violation
        m.f_e = Var(within=NonNegativeReals, initialize=0)

        return m

    @staticmethod
    def define_expressions(m):
        """Define investment plan expressions"""

        def investment_cost_rule(_m, y):
            """Total amortised investment cost for a given year [$]"""
            return sum(m.GAMMA[g] * m.I_C[g, y] * m.x_c[g, y] for g in m.G_C)

        # Investment cost in a given year
        m.INV = Expression(m.Y, rule=investment_cost_rule)

        # Emissions constraint violation penalty
        m.PEN = Expression(expr=m.C_E * m.f_e)

        def fom_cost_rule(_m, y):
            """Total fixed operations and maintenance cost for a given year (not discounted)"""

            # FOM costs for candidate units
            candidate_fom = sum(m.C_FOM[g] * m.a[g, y] for g in m.G_C)

            # FOM costs for existing units - note no FOM cost paid if unit retires
            existing_fom = sum(m.C_FOM[g] * m.P_MAX[g] * (1 - m.F[g, y]) for g in m.G_E)

            # Expression for total FOM cost
            total_fom = candidate_fom + existing_fom

            return total_fom

        # Fixed operating cost for candidate existing generators for each year in model horizon
        m.FOM = Expression(m.Y, rule=fom_cost_rule)

        return m

    @staticmethod
    def define_constraints(m):
        """Define feasible investment plan constraints"""

        def discrete_thermal_size_rule(_m, g, y):
            """Discrete sizing rule for candidate thermal units"""

            # Discrete size options for candidate thermal units
            size_options = {0: 0, 1: 100, 2: 400, 3: 400}

            return m.x_c[g, y] - sum(m.d[g, y, n] * float(size_options[n]) for n in m.G_C_THERM_SIZE_OPTIONS) == 0

        # Discrete investment size for candidate thermal units
        m.DISCRETE_THERMAL_SIZE = Constraint(m.G_C_THERM, m.Y, rule=discrete_thermal_size_rule)

        def single_discrete_selection_rule(_m, g, y):
            """Can only select one size option per investment period"""

            return sum(m.d[g, y, n] for n in m.G_C_THERM_SIZE_OPTIONS) - float(1) == 0

        # Single size selection constraint per investment period
        m.SINGLE_DISCRETE_SELECTION = Constraint(m.G_C_THERM, m.Y, rule=single_discrete_selection_rule)

        def total_capacity_rule(_m, g, y):
            """Total installed capacity in a given year"""

            return m.a[g, y] - sum(m.x_c[g, j] for j in m.Y if j <= y) == 0

        # Total installed capacity for each candidate technology type at each point in model horizon
        m.TOTAL_CAPACITY = Constraint(m.G_C, m.Y, rule=total_capacity_rule)

        def solar_build_limits_cons_rule(_m, z, y):
            """Enforce solar build limits in each NEM zone"""

            # Solar generators belonging to zone 'z'
            gens = [g for g in m.G_C_SOLAR if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y] for g in gens) - m.SOLAR_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Storage build limit constraint for each NEM zone
        m.SOLAR_BUILD_LIMIT_CONS = Constraint(m.Z, m.Y, rule=solar_build_limits_cons_rule)

        def wind_build_limits_cons_rule(_m, z, y):
            """Enforce wind build limits in each NEM zone"""

            # Wind generators belonging to zone 'z'
            gens = [g for g in m.G_C_WIND if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y] for g in gens) - m.WIND_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Wind build limit constraint for each NEM zone
        m.WIND_BUILD_LIMIT_CONS = Constraint(m.Z, m.Y, rule=wind_build_limits_cons_rule)

        def wind_build_limits_cons_rule(_m, z, y):
            """Enforce storage build limits in each NEM zone"""

            # Storage generators belonging to zone 'z'
            gens = [g for g in m.G_C_STORAGE if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y] for g in gens) - m.STORAGE_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Storage build limit constraint for each NEM zone
        m.STORAGE_BUILD_LIMIT_CONS = Constraint(m.Z, m.Y, rule=wind_build_limits_cons_rule)

        # Cumulative emissions target
        m.EMISSIONS_CONSTRAINT = Constraint(expr=-m.TOTAL_EMISSIONS + m.CUMULATIVE_EMISSIONS_TARGET + m.f_e >= 0)

        return m

    @staticmethod
    def define_objective(m):
        """Define objective function"""

        def objective_function_rule(_m):
            """Investment plan objective function"""

            # Total investment cost over model horizon
            investment_cost = sum((m.DISCOUNT_FACTOR[y] / m.WACC) * m.INV[y] for y in m.Y)

            # Fixed operations and maintenance cost over model horizon
            fom_cost = sum(m.DISCOUNT_FACTOR[y] * m.FOM[y] for y in m.Y)

            # End of year operating costs (assumed to be paid in perpetuity to take into account end-of-year effects)
            end_of_year_cost = (m.DISCOUNT_FACTOR[m.Y.last()] / m.WACC) * m.FOM[m.Y.last()]

            # Sub-problem dual information
            dual_cost = sum(m.PSI_FIXED[g, y, s] * (m.CANDIDATE_CAPACITY_FIXED[g, y, s] - m.a[g, y])
                            for g in m.G_C for y in m.Y for s in m.S)

            # Objective function - note: also considers penalty (m.PEN) associated with emissions constraint violation
            objective_function = investment_cost + fom_cost + end_of_year_cost + m.PEN - dual_cost

            return objective_function

        # Investment plan objective function
        m.OBJECTIVE = Objective(rule=objective_function_rule, sense=minimize)

        return m

    def construct_model(self):
        """Construct investment plan model"""

        # Initialise model object
        m = ConcreteModel()

        # Prepare to import dual variables
        m.dual = Suffix(direction=Suffix.IMPORT)

        # Define sets
        m = self.components.define_sets(m)

        # Define parameters common to all sub-problems
        m = self.components.define_parameters(m)

        # Define parameters
        m = self.define_parameters(m)

        # Define variables
        m = self.define_variables(m)

        # Define expressions
        m = self.define_expressions(m)

        # Define constraints
        m = self.define_constraints(m)

        # Define objective
        m = self.define_objective(m)

        return m

    def solve_model(self, m):
        """Solve model instance"""

        # Solve model
        self.opt.solve(m, tee=False, options=self.solver_options, keepfiles=self.keepfiles)

        # Log infeasible constraints if they exist
        log_infeasible_constraints(m)

        return m

    @staticmethod
    def update_parameters(m, uc_solution_dir):
        """Update model parameters"""

        # All results from unit commitment subproblem solution
        result_files = [f for f in os.listdir(uc_solution_dir) if '.pickle' in f]

        # Initialise total emissions
        total_emissions = 0

        # For each unit commitment results file
        for f in result_files:

            # Get year and scenario from filename
            year, scenario = int(f.split('_')[-2]), int(f.split('_')[-1].replace('.pickle', ''))

            # Open scenario solution file
            with open(os.path.join(uc_solution_dir, f), 'rb') as g:

                # Load scenario solution
                scenario_solution = pickle.load(g)

                # Get total emissions
                total_emissions += scenario_solution['SCENARIO_EMISSIONS']

                # Extract dual variables associated with capacity sizing decision for each candidate generator
                for generator, val in scenario_solution['PSI_FIXED'].items():

                    # Compute update amount
                    increment = (val - m.PSI_FIXED[generator, year, scenario].value) / 2

                    # Update dual variables - add previously to previously computed value
                    m.PSI_FIXED[generator, year, scenario] += increment

                # Extract fixed capacity used in subproblems
                for generator, val in scenario_solution['CANDIDATE_CAPACITY_FIXED'].items():

                    # Update fixed candidate capacity
                    m.CANDIDATE_CAPACITY_FIXED[generator, year, scenario] = val

        # Update total emissions from all operating scenarios
        m.TOTAL_EMISSIONS = total_emissions

        return m

    @staticmethod
    def fix_binary_variables(m):
        """Fix all binary variables"""

        for g in m.G_C_THERM:
            for y in m.Y:
                for n in m.G_C_THERM_SIZE_OPTIONS:
                    # Fix binary variables related to discrete sizing decision for candidate thermal units
                    m.d[g, y, n].fix()

        return m

    @staticmethod
    def unfix_binary_variables(m):
        """Unfix all binary variables"""

        for g in m.G_C_THERM:
            for y in m.Y:
                for n in m.G_C_THERM_SIZE_OPTIONS:
                    # Unfix binary variables related to discrete sizing decision for candidate thermal units
                    m.d[g, y, n].unfix()

        return m

    @staticmethod
    def save_solution(m, solution_dir):
        """Save model solution"""

        # Save investment plan
        investment_plan_output = {'CAPACITY_FIXED': m.a.get_values(),
                                  'LAMBDA_FIXED': m.dual[m.EMISSIONS_CONSTRAINT]}

        # Save investment plan results
        with open(os.path.join(solution_dir, 'investment-results.pickle'), 'wb') as f:
            pickle.dump(investment_plan_output, f)
Ejemplo n.º 5
0
class InvestmentPlan:
    # Pre-processed data for model construction
    data = ModelData()

    # Common model components to investment plan and operating sub-problems (sets)
    components = CommonComponents()

    # Object used to perform common calculations. E.g. discount factors.
    calculations = Calculations()

    def __init__(self):
        # Solver options
        self.keepfiles = False
        self.solver_options = {}  # 'MIPGap': 0.0005
        self.opt = SolverFactory('cplex', solver_io='mps')

    def define_sets(self, m):
        """Define investment plan sets"""
        pass

    def define_parameters(self, m):
        """Investment plan model parameters"""
        def solar_build_limits_rule(_m, z):
            """Solar build limits for each NEM zone"""

            return float(
                self.data.candidate_unit_build_limits_dict[z]['SOLAR'])

        # Maximum solar capacity allowed per zone
        m.SOLAR_BUILD_LIMITS = Param(m.Z, rule=solar_build_limits_rule)

        def wind_build_limits_rule(_m, z):
            """Wind build limits for each NEM zone"""

            return float(self.data.candidate_unit_build_limits_dict[z]['WIND'])

        # Maximum wind capacity allowed per zone
        m.WIND_BUILD_LIMITS = Param(m.Z, rule=wind_build_limits_rule)

        def storage_build_limits_rule(_m, z):
            """Storage build limits for each NEM zone"""

            return float(
                self.data.candidate_unit_build_limits_dict[z]['STORAGE'])

        # Maximum storage capacity allowed per zone
        m.STORAGE_BUILD_LIMITS = Param(m.Z, rule=storage_build_limits_rule)

        def candidate_unit_build_costs_rule(_m, g, y):
            """
            Candidate unit build costs [$/MW]

            Note: build cost in $/MW. May need to scale if numerical conditioning problems.
            """

            if g in m.G_C_STORAGE:
                return float(self.data.battery_build_costs_dict[y][g] * 1000)

            else:
                return float(
                    self.data.candidate_units_dict[('BUILD_COST', y)][g] *
                    1000)

        # Candidate unit build cost
        m.I_C = Param(m.G_C, m.Y, rule=candidate_unit_build_costs_rule)

        def candidate_unit_life_rule(_m, g):
            """Asset life of candidate units [years]"""
            # TODO: Use data from NTNPD. Just making an assumption for now.
            return float(25)

        # Candidate unit life
        m.A = Param(m.G_C, rule=candidate_unit_life_rule)

        def amortisation_rate_rule(_m, g):
            """Amortisation rate for a given investment"""

            # Numerator for amortisation rate expression
            num = self.data.WACC * ((1 + self.data.WACC)**m.A[g])

            # Denominator for amortisation rate expression
            den = ((1 + self.data.WACC)**m.A[g]) - 1

            # Amortisation rate
            amortisation_rate = num / den

            return amortisation_rate

        # Amortisation rate for a given investment
        m.GAMMA = Param(m.G_C, rule=amortisation_rate_rule)

        def discount_factor_rule(_m, y):
            """Discount factor for each year in model horizon"""

            return self.calculations.discount_factor(y, m.Y.first())

        # Discount factor
        m.DISCOUNT_FACTOR = Param(m.Y, rule=discount_factor_rule)

        def fixed_operations_and_maintenance_cost_rule(_m, g):
            """Fixed FOM cost [$/MW/year]

            Note: Data in NTNDP is in terms of $/kW/year. Must multiply by 1000 to convert to $/MW/year
            """

            if g in m.G_E:
                return float(
                    self.data.existing_units_dict[('PARAMETERS', 'FOM')][g] *
                    1000)

            elif g in m.G_C_THERM.union(m.G_C_WIND, m.G_C_SOLAR):
                return float(
                    self.data.candidate_units_dict[('PARAMETERS', 'FOM')][g] *
                    1000)

            elif g in m.G_STORAGE:
                # TODO: Need to find reasonable FOM cost for storage units - setting = MEL-WIND for now
                return float(
                    self.data.candidate_units_dict[('PARAMETERS',
                                                    'FOM')]['MEL-WIND'] * 1000)

            else:
                raise Exception(f'Unexpected generator encountered: {g}')

        # Fixed operations and maintenance cost
        m.C_FOM = Param(m.G, rule=fixed_operations_and_maintenance_cost_rule)

        # Weighted cost of capital - interest rate assumed in discounting + amortisation calculations
        m.WACC = Param(initialize=float(self.data.WACC))

        # Candidate capacity dual variable obtained from sub-problem solution. Updated each iteration.
        m.PSI_FIXED = Param(m.G_C, m.Y, m.S, initialize=0, mutable=True)

        # Lower bound for Benders auxiliary variable
        m.ALPHA_LOWER_BOUND = Param(initialize=float(-10e9))

        return m

    @staticmethod
    def define_variables(m):
        """Define investment plan variables"""

        # Investment in each time period
        m.x_c = Var(m.G_C, m.Y, within=NonNegativeReals, initialize=0)

        # Binary variable to select capacity size
        m.d = Var(m.G_C_THERM,
                  m.Y,
                  m.G_C_THERM_SIZE_OPTIONS,
                  within=Binary,
                  initialize=0)

        # Auxiliary variable - total capacity available at each time period
        m.a = Var(m.G_C, m.Y, initialize=0)

        # Auxiliary variable - gives lower bound for subproblem solution
        m.alpha = Var()

        return m

    def define_expressions(self, m):
        """Define investment plan expressions"""
        def investment_cost_rule(_m, y):
            """Total amortised investment cost for a given year [$]"""
            return sum(m.GAMMA[g] * m.I_C[g, y] * m.x_c[g, y] for g in m.G_C)

        # Investment cost in a given year
        m.INV = Expression(m.Y, rule=investment_cost_rule)

        def fom_cost_rule(_m, y):
            """Total fixed operations and maintenance cost for a given year (not discounted)"""

            # FOM costs for candidate units
            candidate_fom = sum(m.C_FOM[g] * m.a[g, y] for g in m.G_C)

            # FOM costs for existing units - note no FOM cost paid if unit retires
            existing_fom = sum(m.C_FOM[g] * m.P_MAX[g] * (1 - m.F[g, y])
                               for g in m.G_E)

            # Expression for total FOM cost
            total_fom = candidate_fom + existing_fom

            return total_fom

        # Fixed operating cost for candidate existing generators for each year in model horizon
        m.FOM = Expression(m.Y, rule=fom_cost_rule)

        def total_fom_cost_rule(_m):
            """Total discounted FOM cost over all years in model horizon"""

            return sum(m.DISCOUNT_FACTOR[y] * m.FOM[y] for y in m.Y)

        # Total discounted FOM cost over model horizon
        m.FOM_TOTAL = Expression(rule=total_fom_cost_rule)

        def total_fom_end_of_horizon_rule(_m):
            """FOM cost assumed to propagate beyond end of model horizon"""

            # Assumes discounted FOM cost in final year paid in perpetuity
            total_cost = (m.DISCOUNT_FACTOR[m.Y.last()] /
                          m.WACC) * m.FOM[m.Y.last()]

            return total_cost

        # Accounting for FOM beyond end of model horizon (EOH)
        m.FOM_EOH = Expression(rule=total_fom_end_of_horizon_rule)

        def total_investment_cost_rule(_m):
            """Total discounted amortised investment cost"""

            # Total discounted amortised investment cost
            total_cost = sum(
                (m.DISCOUNT_FACTOR[y] / m.WACC) * m.INV[y] for y in m.Y)

            return total_cost

        # Total investment cost
        m.INV_TOTAL = Expression(rule=total_investment_cost_rule)

        # Total discounted investment and FOM cost (taking into account costs beyond end of model horizon)
        m.TOTAL_PLANNING_COST = Expression(expr=m.FOM_TOTAL + m.FOM_EOH +
                                           m.INV_TOTAL)

        return m

    @staticmethod
    def define_constraints(m):
        """Define feasible investment plan constraints"""
        def discrete_thermal_size_rule(_m, g, y):
            """Discrete sizing rule for candidate thermal units"""

            # Discrete size options for candidate thermal units
            size_options = {0: 0, 1: 100, 2: 400, 3: 400}

            return m.x_c[g, y] - sum(m.d[g, y, n] * float(size_options[n])
                                     for n in m.G_C_THERM_SIZE_OPTIONS) == 0

        # Discrete investment size for candidate thermal units
        m.DISCRETE_THERMAL_SIZE = Constraint(m.G_C_THERM,
                                             m.Y,
                                             rule=discrete_thermal_size_rule)

        def single_discrete_selection_rule(_m, g, y):
            """Can only select one size option per investment period"""

            return sum(m.d[g, y, n]
                       for n in m.G_C_THERM_SIZE_OPTIONS) - float(1) == 0

        # Single size selection constraint per investment period
        m.SINGLE_DISCRETE_SELECTION = Constraint(
            m.G_C_THERM, m.Y, rule=single_discrete_selection_rule)

        def total_capacity_rule(_m, g, y):
            """Total installed capacity in a given year"""

            return m.a[g, y] - sum(m.x_c[g, j] for j in m.Y if j <= y) == 0

        # Total installed capacity for each candidate technology type at each point in model horizon
        m.TOTAL_CAPACITY = Constraint(m.G_C, m.Y, rule=total_capacity_rule)

        def solar_build_limits_cons_rule(_m, z, y):
            """Enforce solar build limits in each NEM zone"""

            # Solar generators belonging to zone 'z'
            gens = [g for g in m.G_C_SOLAR if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y]
                           for g in gens) - m.SOLAR_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Storage build limit constraint for each NEM zone
        m.SOLAR_BUILD_LIMIT_CONS = Constraint(
            m.Z, m.Y, rule=solar_build_limits_cons_rule)

        def wind_build_limits_cons_rule(_m, z, y):
            """Enforce wind build limits in each NEM zone"""

            # Wind generators belonging to zone 'z'
            gens = [g for g in m.G_C_WIND if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y]
                           for g in gens) - m.WIND_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Wind build limit constraint for each NEM zone
        m.WIND_BUILD_LIMIT_CONS = Constraint(m.Z,
                                             m.Y,
                                             rule=wind_build_limits_cons_rule)

        def storage_build_limits_cons_rule(_m, z, y):
            """Enforce storage build limits in each NEM zone"""

            # Storage generators belonging to zone 'z'
            gens = [g for g in m.G_C_STORAGE if g.split('-')[0] == z]

            if gens:
                return sum(m.a[g, y]
                           for g in gens) - m.STORAGE_BUILD_LIMITS[z] <= 0
            else:
                return Constraint.Skip

        # Storage build limit constraint for each NEM zone
        m.STORAGE_BUILD_LIMIT_CONS = Constraint(
            m.Z, m.Y, rule=storage_build_limits_cons_rule)

        # Bound on Benders decomposition lower-bound variable
        m.ALPHA_LOWER_BOUND_CONS = Constraint(
            expr=m.alpha >= m.ALPHA_LOWER_BOUND)

        # Container for benders cuts
        m.BENDERS_CUTS = ConstraintList()

        return m

    @staticmethod
    def define_objective(m):
        """Define objective function"""
        def objective_function_rule(_m):
            """Investment plan objective function"""

            # Objective function
            objective_function = m.TOTAL_PLANNING_COST + m.alpha

            return objective_function

        # Investment plan objective function
        m.OBJECTIVE = Objective(rule=objective_function_rule, sense=minimize)

        return m

    def construct_model(self):
        """Construct investment plan model"""

        # Initialise model object
        m = ConcreteModel()

        # Prepare to import dual variables
        m.dual = Suffix(direction=Suffix.IMPORT)

        # Define sets
        m = self.components.define_sets(m)

        # Define parameters common to all sub-problems
        m = self.components.define_parameters(m)

        # Define parameters
        m = self.define_parameters(m)

        # Define variables
        m = self.define_variables(m)

        # Define expressions
        m = self.define_expressions(m)

        # Define constraints
        m = self.define_constraints(m)

        # Define objective
        m = self.define_objective(m)

        return m

    def solve_model(self, m):
        """Solve model instance"""

        # Solve model
        self.opt.solve(m,
                       tee=False,
                       options=self.solver_options,
                       keepfiles=self.keepfiles)

        # Log infeasible constraints if they exist
        log_infeasible_constraints(m)

        return m

    def get_cut_component(self, m, iteration, year, scenario,
                          investment_solution_dir, uc_solution_dir):
        """Construct cut component"""

        # Discount factor
        discount = self.calculations.discount_factor(year, base_year=2016)

        # Duration
        duration = self.calculations.scenario_duration_days(year, scenario)

        with open(
                os.path.join(
                    uc_solution_dir,
                    f'uc-results_{iteration}_{year}_{scenario}.pickle'),
                'rb') as f:
            uc_result = pickle.load(f)

        with open(
                os.path.join(investment_solution_dir,
                             f'investment-results_{iteration}.pickle'),
                'rb') as f:
            inv_result = pickle.load(f)

        # Construct cut component
        cut = discount * duration * sum(
            uc_result['PSI_FIXED'][g] *
            (m.a[g, year] - inv_result['a'][(g, year)]) for g in m.G_C)

        return cut

    def get_benders_cut(self, m, iteration, investment_solution_dir,
                        uc_solution_dir):
        """Construct a Benders optimality cut"""

        # Cost accounting for total operating cost for each scenario over model horizon
        scenario_cost = self.calculations.get_total_discounted_operating_scenario_cost(
            iteration, uc_solution_dir)

        # Cost account for operating costs beyond end of model horizon
        scenario_cost_eoh = self.calculations.get_end_of_horizon_operating_cost(
            m, iteration, uc_solution_dir)

        # Total (fixed) cost to include in Benders cut
        total_cost = scenario_cost + scenario_cost_eoh

        # Cut components containing dual variables
        cut_components = sum(
            self.get_cut_component(m, iteration, y, s, investment_solution_dir,
                                   uc_solution_dir) for y in m.Y for s in m.S)

        # Benders cut
        cut = m.alpha >= total_cost + cut_components

        return cut

    def add_benders_cut(self, m, iteration, investment_solution_dir,
                        uc_solution_dir):
        """Update model parameters"""

        # Construct Benders optimality cut
        cut = self.get_benders_cut(m, iteration, investment_solution_dir,
                                   uc_solution_dir)

        # Add Benders cut to constraint list
        m.BENDERS_CUTS.add(expr=cut)

        return m

    @staticmethod
    def fix_binary_variables(m):
        """Fix all binary variables"""

        for g in m.G_C_THERM:
            for y in m.Y:
                for n in m.G_C_THERM_SIZE_OPTIONS:
                    # Fix binary variables related to discrete sizing decision for candidate thermal units
                    m.d[g, y, n].fix()

        return m

    @staticmethod
    def unfix_binary_variables(m):
        """Unfix all binary variables"""

        for g in m.G_C_THERM:
            for y in m.Y:
                for n in m.G_C_THERM_SIZE_OPTIONS:
                    # Unfix binary variables related to discrete sizing decision for candidate thermal units
                    m.d[g, y, n].unfix()

        return m

    @staticmethod
    def save_solution(m, iteration, investment_solution_dir):
        """Save model solution"""

        # Save investment plan
        output = {
            'a': m.a.get_values(),
            'x_c': m.x_c.get_values(),
            'OBJECTIVE': m.OBJECTIVE.expr()
        }

        # Save investment plan results
        with open(
                os.path.join(investment_solution_dir,
                             f'investment-results_{iteration}.pickle'),
                'wb') as f:
            pickle.dump(output, f)

    @staticmethod
    def initialise_investment_plan(m, iteration, solution_dir):
        """Initial values for investment plan - used in first iteration. Assume candidate capacity = 0"""

        # Feasible investment plan for first iteration
        plan = {
            'a': {(g, y): float(0)
                  for g in m.G_C for y in m.Y},
            'x_c': {(g, y): float(0)
                    for g in m.G_C for y in m.Y},
            'OBJECTIVE': -1e9
        }

        # Save investment plan
        with open(
                os.path.join(solution_dir,
                             f'investment-results_{iteration}.pickle'),
                'wb') as f:
            pickle.dump(plan, f)

        return plan