Using multiple objectives in your model#

emulate_multiobjective.ipynb Open In Colab Open In Deepnote Open In Kaggle Open In Gradient Open In SageMaker Studio Lab Powered by AMPL

Description: We show how to use multiple objectives with Amplpy using a nonlinear Unit Commitment problem. We won’t be using native or emulated features from the solver interface, but emulating manually a lexicographic multiobjective problem.

Tags: warm-start, mp, multi-objective, gurobi, xpress, knitro, mp2nl, electric-power-industry, unit-commitment

Notebook author: Marcos Dominguez Velad <marcos@ampl.com>

# Install dependencies
%pip install -q amplpy pandas numpy
# Google Colab & Kaggle integration
from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
    modules=["mp2nl", "knitro", "gurobi", "xpress"],  # modules to install
    license_uuid="default",  # license to use
)  # instantiate AMPL object and register magics

The problem#

We are solving a version of Unit Commitment with generators with minimum and maximum outputs and ramp limits. There are linear and quadratic costs, so the problem becomes a MINLP, and we aim to minimize CO2 emissions while minimizing production costs, so it is a multi-objective problem.

We will focus on the nonlinear and multi-objective part of the problem for this case. The model is written in AMPL for it to keep easy to read.

Warning: this notebook uses commercial solvers, so when running on Cloud you may need a license for these solvers (Knitro, Gurobi, Xpress), or run locally.

MINLP#

One of the 2 objectives is minimizing total cost, which is a nonlinear expression.

  • Total cost: $$ \min ; \sum_{g \in \text{GEN}} \sum_{t \in \text{T}} \left( c_g^{\text{lin}} , p_{g,t}

  • c_g^{\text{quad}} , p_{g,t}^2 \right) ;+; \sum_{g \in \text{GEN}} \sum_{t \in \text{T}} c_g^{\text{start}} , y_{g,t}$$

Where \(p_{g,t}\) is the amount of energy produced by the generator \(g\) in time \(t\), and \(y_{g,t}\) is 1 if the generator started at period \(t\) (startup cost).

There are also some logical constraints to make the model clear. We have some binary variables to model easier if a generator is turned on or has just started:

  • Producing if and only if commited (binaries \(x_{g,t}\)): $\( p_{g,t} > 0 \;\Longleftrightarrow\; x_{g,t} = 1 \qquad \forall g \in \text{GEN},\; t \in \text{T} \)$

  • Commited if and only if producing: $\( y_{g,t} \;\Longleftrightarrow\; \big( x_{g,t} = 1 \;\land\; x_{g,t-1} = 0 \big) \qquad \forall g \in \text{GEN} \)$

  • Amount of energy produced is either 0 or at least over a threshold (minimum input). $\( p_{g,t} = 0 \;\;\text{or}\;\; p_{g,t} \ge \underline{P}_g \qquad \forall g \in \text{GEN},\; t \in \text{T} \)$

  • Ramp limits:

\[ p_{g,t} - p_{g,t-1} \;\le\; R_g^{\uparrow} \qquad \forall g \in \text{GEN} \]
\[ p_{g,t-1} - p_{g,t} \;\le\; R_g^{\downarrow} \qquad \forall g \in \text{GEN} \]

Solvers don’t necessarily handle logical expressions, but the AMPL/MP interface takes care of reformulating efficiently these expressions.

Multi-objective#

We have 2 objectives, the first is to minimize cost, and the second is to minimize carbon emissions.

We are going to do hierarchical optimization minimizing first the total cost minimization problem, and later the emissions, allowing a degradation of the total cost of 5%.

We will emulate the multi-objective process by adding a constraint that ensure bounds for the already found objectives. The idea is to iterate over the different objectives (2 in this case), solve for a single objective, and add a constraint that ensures that the current objective won’t become worse in the following runs. Then, solve for the next objective.

This can be handled natively with suffixes in the objectives assigning priorities or degradation coefficients. Look at this example for such a version: https://colab.ampl.com/notebooks/unit-commitment-minlp-with-knitro.html

MINLP Unit Commitment model#

%%writefile unit_commitment.mod
set GENERATORS;
set TIME ordered;

param demand {TIME} >= 0;                   # Power demand at each time
param min_output {GENERATORS} >= 0;         # Minimum power output
param max_output {g in GENERATORS} >= min_output[g];  # Maximum power output
param ramp_up_limit {GENERATORS} >= 0;
param ramp_down_limit {GENERATORS} >= 0;

param linear_cost {GENERATORS};             # Linear cost coefficient
param quadratic_cost {GENERATORS} >= 0;     # Quadratic cost coefficient
param startup_cost {GENERATORS} >= 0;       # Startup cost

param emission_rate {GENERATORS} >= 0;      # Tons CO2 per MW produced

var is_committed {GENERATORS, TIME} binary;       # 1 if generator is ON
var power_generated {gen in GENERATORS, TIME} >= 0 <= max_output[gen];      # MW produced
var is_startup {GENERATORS, TIME} binary;         # 1 if generator starts up

# Generation only if committed
subject to Generation_Commitment {gen in GENERATORS, t in TIME}:
    power_generated[gen,t] > 0 <==> is_committed[gen,t] = 1;

# Meet demand in each period
subject to Demand_Satisfaction {t in TIME}:
    sum {gen in GENERATORS} power_generated[gen,t] >= demand[t];

# Startup in first period
subject to Startup_First {gen in GENERATORS}:
    is_startup[gen, first(TIME)] == is_committed[gen, first(TIME)];

# Startup logic in subsequent periods
subject to Startup_Transition {gen in GENERATORS, t in TIME: ord(t) > 1}:
    is_startup[gen,t] <==> (is_committed[gen,t] and !is_committed[gen,prev(t)]);

subject to Min_Gen_If_On {gen in GENERATORS, t in TIME}:
    power_generated[gen,t] == 0 or power_generated[gen,t] >= min_output[gen];

# Ramp limits
subject to Ramp_limits {gen in GENERATORS, t in TIME: ord(t) > 1}:
    -ramp_down_limit[gen] <= power_generated[gen,t] - power_generated[gen,prev(t)] <= ramp_up_limit[gen];

# Objective 1: Minimize total operating + startup cost
var Total_Cost = sum {gen in GENERATORS, t in TIME}
        (linear_cost[gen] * power_generated[gen,t] +
         quadratic_cost[gen] * power_generated[gen,t]^2)
  + sum {gen in GENERATORS, t in TIME} startup_cost[gen] * is_startup[gen,t];

minimize Total_Cost_Obj:
    Total_Cost;

param Total_Cost_Bound default 1e21;
s.t. Ensure_Total_Cost_Obj:
    Total_Cost <= Total_Cost_Bound;
    
# Objective 2: Minimize total emissions
var Total_Emissions = sum {gen in GENERATORS, t in TIME} emission_rate[gen] * power_generated[gen,t];

minimize Total_Emissions_Obj:
    Total_Emissions;

param Total_Emissions_Bound default 1e21;
s.t. Ensure_Total_Emissions_Obj:
    Total_Emissions <= Total_Emissions_Bound;

Solving the model#

Generate the problem data#

The AMPL model is isolated from the input data for more readability and maintainance

import pandas as pd
import numpy as np

# Unit Commitment data
generators = ["G1", "G2", "G3", "G4", "G5", "G6", "G7"]

# Generators data
generators_data = pd.DataFrame(
    {
        "min_output": [20, 30, 25, 15, 10, 40, 0],
        "max_output": [100, 120, 90, 60, 50, 150, 30],
        "ramp_up_limit": [40, 50, 30, 25, 20, 60, 10],
        "ramp_down_limit": [40, 50, 30, 25, 20, 60, 10],
        "linear_cost": [20, 16, 18, 22, 24, 14, 12],
        "quadratic_cost": [0.04, 0.05, 0.06, 0.03, 0.04, 0.036, 0.1],
        "startup_cost": [400, 300, 360, 200, 160, 600, 160],
        "emission_rate": [0.7, 0.5, 0.6, 0.4, 0.3, 0.8, 0.0],
    },
    index=generators,
)

# Generate random demand
num_time_periods = 24 * 3
time_periods = list(range(1, num_time_periods + 1))

np.random.seed(42)
base_demand = 150 + 40 * np.sin(np.linspace(0, 3 * np.pi, num_time_periods))
noise = np.random.normal(0, 10, num_time_periods)
demand = (base_demand + noise).clip(min=100).round().astype(int)

Assign the UC data#

# Optimization model and data
ampl = AMPL()
ampl.read("unit_commitment.mod")

ampl.set["TIME"] = time_periods
ampl.set["GENERATORS"] = generators
ampl.param["demand"] = demand
ampl.set_data(generators_data, "GENERATORS")

Running the solver#

For nonlinear solvers like Knitro, we need the mp2nl interface to make it handle logical constraints and multi-objective.

def run_solver(model, solver, max_seconds, objective):
    if solver == "knitro":
        model.solve(
            solver="mp2nl",
            problem=objective,
            knitro_options="maxtime=" + str(max_seconds),
            verbose=True,
            mp2nl_options="solver=knitro obj:multi=2",
        )
    else:
        model.solve(
            solver=solver,
            problem=objective,
            mp_options="obj:multi=2 outlev=1 timelim=" + str(max_seconds),
            verbose=True,
        )
    return model


# We don't need these constraints for the first solve
ampl.con["Ensure_Total_Cost_Obj"].drop()
ampl.con["Ensure_Total_Emissions_Obj"].drop()
ampl = run_solver(
    model=ampl, solver="knitro", max_seconds=30, objective="Total_Cost_Obj"
)

# Second objective
# We need to ensure Total Cost keeps low
objective_value = ampl.obj["Total_Cost_Obj"].value()
ampl.param["Total_Cost_Bound"] = objective_value  # add the bound from the previous run
ampl.con[
    "Ensure_Total_Cost_Obj"
].restore()  # restore the constraint that ensures the bound
ampl = run_solver(
    model=ampl, solver="knitro", max_seconds=30, objective="Total_Emissions_Obj"
)  # minimize Total Emissions
print("=== Objective Values ===")
total_cost = ampl.obj["Total_Cost_Obj"].value()
total_emissions = ampl.obj["Total_Emissions_Obj"].value()

print(f"Total cost: {total_cost:.2f}$")
print(f"Total emissions: {total_emissions:.2f} tons CO₂")