JuMP: which solver should I use for my problem?

Hello,

I could use some guidance on this. I have the following model:

COP(ΔT) = 20.3595 - 3.2061 * log2(1 + ΔT)

function baseline_model(
    df::DataFrame;
    A_d::Matrix{Float64},
    B_d::Matrix{Float64},
)
    T_a::Vector{Float64} = df.T_a
    Φ_s::Vector{Float64} = df.Φ_s
    z_u::Vector{Float64} = df.z_u
    z_l::Vector{Float64} = df.z_l
    λ_e::Vector{Float64} = df.λ_e

    # MILP formulation
    model = Model(Gurobi.Optimizer)

    # check that all input vectors have the same length
    N = length(T_a)
    @assert length(Φ_s) == N
    @assert length(z_u) == N
    @assert length(z_l) == N
    @assert length(λ_e) == N

    # heating powers
    @variable(model, Φ_CV[1:N] >= 0)
    @variable(model, Φ_HP[1:N] >= 0)
    @variable(model, Φ_h[1:N] >= 0)
    @variable(model, z_CV[1:N], Bin)
    @variable(model, z_HP[1:N], Bin)

    # heatpump electrical power
    @variable(model, P_HP[1:N] >= 0)

    # gas flow
    @variable(model, g[1:N] >= 0)

    # slack variables for thermal comfort violations
    @variable(model, t_u[1:N] >= 0)
    @variable(model, t_l[1:N] >= 0)

    # State variables [T_i, T_e, T_h] - [°C]
    m = size(A_d, 1)
    @variable(model, T[1:N, 1:m])

    # named state variables
    T_i = T[:, i]
    T_e = T[:, e]
    T_h = T[:, h]

    # initial condition
    T_init = [df.T_i[1], df.T_i[1], df.T_i[1]]
    @constraint(model, T[1, :] == T_init)

    # COP efficiency of the heat pump
    @variable(model, η_COP[1:N] >= 1.0)

    # make sure the envelope thermal energy is not emptied 
    @constraint(model, T_e[1] <= T_e[N])

    for k in 1:N
        # thermal efficiency
        ΔT = T_h[k] - T_a[k]
        # ΔT = T_h_fixed - T_a[k]
        @constraint(model, η_COP[k] == COP(ΔT))
        @constraint(model, Φ_HP[k] == η_COP[k] * P_HP[k])
        @constraint(model, Φ_CV[k] == η_g * H_g * g[k])

        # power limits
        @constraint(model, Φ_HP[k] >= Φ_HP_min * z_HP[k])
        @constraint(model, Φ_HP[k] <= Φ_HP_max * z_HP[k])

        @constraint(model, Φ_CV[k] >= Φ_CV_min * z_CV[k])
        @constraint(model, Φ_CV[k] <= Φ_CV_max * z_CV[k])
        @constraint(model, 0 <= P_HP[k] <= P_HP_max)

        # temp limit for T_h 
        @constraint(model, 15 <= T_h[k] <= 75)

        # comfort violation
        @constraint(model, T_i[k] - z_u[k] <= t_u[k])
        @constraint(model, z_l[k] - T_i[k] <= t_l[k])
    end

    # heat balance
    for k in 1:N-1
        u_k = [T_a[k], Φ_h[k], Φ_s[k]]
        @constraint(model, Φ_h[k] == Φ_CV[k] + Φ_HP[k])
        @constraint(model, T[k+1, :] == A_d * T[k, :] + B_d * u_k)
    end

    # energy cost
    @expression(model, J_c, sum(λ_e[k] * P_HP[k] * Δt + λ_g * g[k] for k in 1:N))

    # discomfort cost
    @expression(model, J_d, sum(c_u * t_u[k] + c_l * t_l[k] for k in 1:N))

    # multi-objective 
    @objective(model, Min, [J_c, J_d])

    optimize!(model)
    @assert is_solved_and_feasible(model)

    return model
end

I can solve the MILP formulation of this problem in 0.06 seconds.

I am now trying to set a baseline by solving the MINLP version of this problem. The problematic part is this formula:

COP(ΔT) = 20.3595 - 3.2061 * log2(1 + ΔT)

Which I use in the NL formulation like this:

# NL form
ΔT = T_h[k] - T_a[k]

# Linear form
# ΔT = T_h_fixed - T_a[k]

@constraint(model, η_COP[k] == COP(ΔT))
@constraint(model, Φ_HP[k] == η_COP[k] * P_HP[k])

I have been trying to solve the NL form but all the solvers I have tried seem to run forever. So either my problem formulation is wrong or I’m not using the right solver. I tried some of the suggestions in this topic: Multi-objective MINLP problem in JuMP - #12 by ashefa

Could someone please advise on what solver I should use? Thanks!

1 Like

Can you provide a reproducible example? Including data inputs etc? For example, I don’t know that the MILP formulation of this problem is.

You have a multi-objective MINLP. What do you expect the solution to be? Do you want the full Pareto frontier? Or do you just want a single point?

A simple solution would be to convert your problem into a single-objective problem by choosing appropriate trade-off weights of the energy and discomfort costs.

You also have a number of syntax errors:

What is i, e, and h?

    T_i = T[:, i]
    T_e = T[:, e]
    T_h = T[:, h]

I think T_init incorrect?

    T_init = [df.T_i[1], df.T_i[1], df.T_i[1]]
    @constraint(model, T[1, :] == T_init)
1 Like

Yes ofcourse, I tested this and it runs standalone:

using JuMP
using Gurobi
# using Ipopt
# using Alpine
# using HiGHS
# using Juniper
# import MultiObjectiveAlgorithms as MOA

using DataFrames


# Indices for state variables
i = 1                   # Index of T_i in the state vector T
e = 2                   # Index of T_e in the state vector T
h = 3                   # Index of T_h in the state vector T

# time horizon
Δt = 0.25               # Time step [h]

# Parameters for costs
λ_g = 1.286             # Gas price [€/m³] (https://www.energievergelijk.nl/energieprijzen/gasprijs)
c_u = 1.0               # Cost coefficient for upper comfort violation
c_l = 5.0               # Cost coefficient for lower comfort violation

# Operational constraints
Φ_CV_min = 3.8          # Minimum thermal output power of the boiler [kW]
Φ_CV_max = 14.8         # Maximum thermal output power of the boiler [kW]
Φ_HP_min = 1.8          # Minimum thermal output power of the heat pump [kW]
Φ_HP_max = 5.0          # Maximum thermal output power of the heat pump [kW]
P_HP_max = 1.8          # Maximum compressor power of the heat pump [kW]

# Efficiency parameters
η_g = 0.95
H_g = 9.77

# COP formula for the heat pump
COP(ΔT) = 20.3595 - 3.2061 * log2(1 + ΔT)

# Fixed temperature of the heating system for linear formulation [°C]
T_h_fixed = 40.0

# index to state mapping
i = 1
e = 2
h = 3


"""
    baseline_model()

Baseline model for the thermal control optimization problem.

Minimizes cost and uses asymmetric soft zone tracking constraints.
"""
function baseline_model(
    df::DataFrame;
    A_d::Matrix{Float64},
    B_d::Matrix{Float64},
)
    T_a::Vector{Float64} = df.T_a
    Φ_s::Vector{Float64} = df.Φ_s
    z_u::Vector{Float64} = df.z_u
    z_l::Vector{Float64} = df.z_l
    λ_e::Vector{Float64} = df.λ_e

    # MILP formulation
    model = Model(Gurobi.Optimizer)

    # check that all input vectors have the same length
    N = length(T_a)
    @assert length(Φ_s) == N
    @assert length(z_u) == N
    @assert length(z_l) == N
    @assert length(λ_e) == N

    # heating powers
    @variable(model, Φ_CV[1:N] >= 0)
    @variable(model, Φ_HP[1:N] >= 0)
    @variable(model, Φ_h[1:N] >= 0)
    @variable(model, z_CV[1:N], Bin)
    @variable(model, z_HP[1:N], Bin)

    # heatpump electrical power
    @variable(model, P_HP[1:N] >= 0)

    # gas flow
    @variable(model, g[1:N] >= 0)

    # slack variables for thermal comfort violations
    @variable(model, t_u[1:N] >= 0)
    @variable(model, t_l[1:N] >= 0)

    # State variables [T_i, T_e, T_h] - [°C]
    m = size(A_d, 1)
    @variable(model, T[1:N, 1:m])

    # named state variables
    T_i = T[:, i]
    T_e = T[:, e]
    T_h = T[:, h]

    # initial condition
    T_init = [df.T_i[1], 0.95 * df.T_i[1], df.T_h[1]]
    @constraint(model, T[1, :] == T_init)

    # COP efficiency of the heat pump
    @variable(model, η_COP[1:N] >= 1.0)

    # make sure the envelope thermal energy is not emptied 
    @constraint(model, 0.9 * T_e[1] <= T_e[N])

    for k in 1:N
      # MINLP formulation
      # ΔT = T_h[k] - T_a[k]

      # MILP formulation
      ΔT = T_h_fixed - T_a[k]

        @constraint(model, η_COP[k] == COP(ΔT))
        @constraint(model, Φ_HP[k] == η_COP[k] * P_HP[k])
        @constraint(model, Φ_CV[k] == η_g * H_g * g[k])

        # power limits
        @constraint(model, Φ_HP[k] >= Φ_HP_min * z_HP[k])
        @constraint(model, Φ_HP[k] <= Φ_HP_max * z_HP[k])

        @constraint(model, Φ_CV[k] >= Φ_CV_min * z_CV[k])
        @constraint(model, Φ_CV[k] <= Φ_CV_max * z_CV[k])
        @constraint(model, 0 <= P_HP[k] <= P_HP_max)

        # temp limit for T_h 
        @constraint(model, 0 <= T_h[k] <= 75)

        # comfort violation
        @constraint(model, T_i[k] - z_u[k] <= t_u[k])
        @constraint(model, z_l[k] - T_i[k] <= t_l[k])
    end

    # heat balance
    for k in 1:N-1
        u_k = [T_a[k], Φ_h[k], Φ_s[k]]
        @constraint(model, Φ_h[k] == Φ_CV[k] + Φ_HP[k])
        @constraint(model, T[k+1, :] == A_d * T[k, :] + B_d * u_k)
    end

    # energy cost
    @expression(model, J_c, sum(λ_e[k] * P_HP[k] * Δt + λ_g * g[k] for k in 1:N))

    # discomfort cost
    @expression(model, J_d, sum(c_u * t_u[k] + c_l * t_l[k] for k in 1:N))

    # multi-objective 
    @objective(model, Min, [J_c, J_d])

    optimize!(model)
    @assert is_solved_and_feasible(model)

    return model
end

A_d = [
    0.927295 0.0641121 0.0054676
    0.0264427 0.97342 7.81985e-5
    0.125957 0.00436776 0.869463
]

B_d = [
    0.00312517 0.00144172 0.0745064
    5.91665e-5 1.35302e-5 0.0013016
    0.000211911 0.474613 0.0050522
]

data = DataFrame(
    T_a=rand(4:8, 96),
    Φ_s=rand(0.1:0.5, 96),
    z_u=repeat([21.5], 96),
    z_l=repeat([19.5], 96),
    λ_e=rand(0.1:0.5, 96),
    T_i=rand(20:30, 96),
    T_h=rand(20:30, 96),
)

model = baseline_model(data, A_d=A_d, B_d=B_d)
solution_summary(model; result=1)

The objective contains both cost and comfort, these are competing objectives. Better tracking means you consumer more energy. So ideally I would like to see a Pareto front weighing both of them.

Do you mean for the MINLP formulation?

Apologies, I have added it to the code now. They are just indices that map elements in the state vector to specific states that I then use in the model.

The T_init used in the code is a simplification. I have three temperature states, I only know the initial condition of state 1 and 3 (because I measured it). State 2 is somewhere in between state 1 and 3, but unknown.

The ‘non-linear’ part is here:

# MINLP formulation
# ΔT = T_h[k] - T_a[k]

# MILP formulation
ΔT = T_h_fixed - T_a[k]

To make it linear I just fix the state T_h[k] to some value which makes sense. Which is more like a simplification than a linearization but 99% of the literature on this topic does it that way. I am still looking for a good linearization of P_HP[k] = Φ_HP[k]/η_COP[k] (electrical power used = thermal power needed / efficiency).

In order to benchmark whatever linearization I come up with I want to solve the non-linear problem directly first :slight_smile:

(for the linearization I think I will need jump-dev/PiecewiseLinearOpt.jl · JuMP)

Do you constraint ΔT to be larger than -1? The COP function approaches infinity in this case. Maybe a taylor series expansion would help too.

This is what Wolfram Alpha returns for an expansion around 30.
17292791648821368540959612510004

Not specifically, but COP is constrained to be => 1.

I’m not sure how the Taylor series helps in this case, since that would still result in a non linear model?

COP >= 1 only implicitly constrains ΔT to be < 64.7. I think you should bound ΔT>=0.

BTW, do you use max time or max iterations parameters for the solver? I think Ipopt will otherwise solve it to to optimality with 1e20s as limit.

I can’t do that, since ΔT can be < 0 often in practice. For example a cold night followed by a mild day. In that case the system will have to decide when heating makes sense. I need to figure out a proper way to model this.

I didn’t try any limits, but the way I understand it is that if it doesn’t terminate it hasn’t converged. If I limit it and it stops at the limit then it hasn’t converged?

Hi @langestefan, this should point you in the right direction:

using JuMP
import DataFrames
import Ipopt
import Juniper
import MultiObjectiveAlgorithms as MOA

function baseline_model(
    df::DataFrames.DataFrame;
    A_d::Matrix{Float64},
    B_d::Matrix{Float64},
)
    Δt = 0.25               # Time step [h]
    λ_g = 1.286             # Gas price [€/m³] (https://www.energievergelijk.nl/energieprijzen/gasprijs)
    c_u = 1.0               # Cost coefficient for upper comfort violation
    c_l = 5.0               # Cost coefficient for lower comfort violation
    Φ_CV_min = 3.8          # Minimum thermal output power of the boiler [kW]
    Φ_CV_max = 14.8         # Maximum thermal output power of the boiler [kW]
    Φ_HP_min = 1.8          # Minimum thermal output power of the heat pump [kW]
    Φ_HP_max = 5.0          # Maximum thermal output power of the heat pump [kW]
    P_HP_max = 1.8          # Maximum compressor power of the heat pump [kW]
    η_g = 0.95
    H_g = 9.77
    T_h_fixed = 40.0
    COP(ΔT) = 20.3595 - 3.2061 * log2(1 + ΔT)
    m, N = size(A_d, 1), size(df, 1)
    juniper = optimizer_with_attributes(
        Juniper.Optimizer,
        "nl_solver" =>
            optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0),
    )
    model = Model(() -> MOA.Optimizer(juniper))
    set_silent(model)
    set_attribute(model, MOA.Algorithm(), MOA.Dichotomy())
    set_attribute(model, MOA.SolutionLimit(), 4)
    @variables(model, begin
        0 <= Φ_CV[1:N] <= Φ_CV_max
        0 <= z_CV[1:N] <= 1, Bin
        0 <= Φ_HP[1:N] <= Φ_HP_max
        0 <= z_HP[1:N] <= 1, Bin
        0 <= P_HP[1:N] <= P_HP_max
        t_u[1:N] >= 0
        t_l[1:N] >= 0
        T[1:N, [:i, :e, :h]]
        η_COP[1:N] >= 1.0
    end)
    set_lower_bound.(T[:, :h], 0.0)
    set_upper_bound.(T[:, :h], 75)
    set_start_value.(T[:, :h], 40)  # <-- important. Provide a starting point that log2(1 + ΔT) is defined for
    for (k, v) in (:i => df.T_i[1], :e => 0.95 * df.T_i[1], :h => df.T_h[1])
        fix(T[1, k], v; force = true)
    end
    @expressions(model, begin
        ΔT[k in 1:N], T[k, :h] - df.T_a[k]
        Φ_h[k in 1:N-1], Φ_CV[k] + Φ_HP[k]
        g[k in 1:N], (1 / η_g * H_g) * Φ_CV[k]
        u[k in 1:N-1], [df.T_a[k], Φ_h[k], df.Φ_s[k]]
        J_c, sum(df.λ_e[k] * P_HP[k] * Δt + λ_g * g[k] for k in 1:N)
        J_d, c_u * sum(t_u) + c_l * sum(t_l)
    end)
    @constraints(model, begin
        [k in 1:N], Φ_CV[k] <= Φ_CV_max * z_CV[k]
        [k in 1:N], Φ_CV[k] >= Φ_CV_min * z_CV[k]
        [k in 1:N], Φ_HP[k] <= Φ_HP_max * z_HP[k]
        [k in 1:N], Φ_HP[k] >= Φ_HP_min * z_HP[k]
        0.9 * T[1, :e] <= T[N, :e]
        [k in 1:N], η_COP[k] == COP(ΔT[k])
        [k in 1:N], Φ_HP[k] == η_COP[k] * P_HP[k]
        [k in 1:N], T[k, :i] - t_u[k] <= df.z_u[k]
        [k in 1:N], T[k, :i] + t_l[k] >= df.z_l[k]
        [k in 1:N-1], T[k+1, :].data .== A_d * T[k, :].data + B_d * u[k]
    end)
    @objective(model, Min, [J_c, J_d])
    optimize!(model)
    @assert is_solved_and_feasible(model)
    return model
end

A_d = [
    0.927295 0.0641121 0.0054676
    0.0264427 0.97342 7.81985e-5
    0.125957 0.00436776 0.869463
]
B_d = [
    0.00312517 0.00144172 0.0745064
    5.91665e-5 1.35302e-5 0.0013016
    0.000211911 0.474613 0.0050522
]

data = DataFrames.DataFrame(
    T_a = rand(4:8, 96),
    Φ_s = rand(0.1:0.5, 96),
    z_u = repeat([21.5], 96),
    z_l = repeat([19.5], 96),
    λ_e = rand(0.1:0.5, 96),
    T_i = rand(20:30, 96),
    T_h = rand(20:30, 96),
)

model = baseline_model(data; A_d = A_d, B_d = B_d)
solution_summary(model; result = 1)

Thanks! Lots of very useful things here I did not know about (this is my first real JuMP project).

Just a few small questions:

Why did you use .data here?

If I want to make the COP function more ‘well-behaved’, for example do:

function COP(ΔT, COP_max=12.658297676254248)
    a = -0.3390084254197603
    x_max = 15.6810934268123
    y(x) = a * x + COP_max

    if ΔT <= 0.0
        return COP_max
    elseif 0.0 < ΔT <= x_max
        return y(ΔT)
    else
        return clamp(20.3595 - 3.2061 * log2(1 + ΔT), 1, COP_max)
    end
end

But this gives:

MethodError: no method matching isless(::AffExpr, ::Float64)

Which happens because of this statement ofcourse: if ΔT <= 0.0
Is there a way around that?

I don’t want to enforce ΔT via constraints because that will lead to strange situations like the heater turning on simply because ΔT < 0.

(for context, this function is a regression based on data collected in the field. Unfortunately I do not have any reference points at low or high dT so I have to extrapolate somewhat)

I think you want to use Approximating nonlinear functions · JuMP

Do something like:

using JuMP
import DataFrames
import Gurobi
import MultiObjectiveAlgorithms as MOA

function baseline_model(
    df::DataFrames.DataFrame;
    A_d::Matrix{Float64},
    B_d::Matrix{Float64},
)
    Δt = 0.25               # Time step [h]
    λ_g = 1.286             # Gas price [€/m³] (https://www.energievergelijk.nl/energieprijzen/gasprijs)
    c_u = 1.0               # Cost coefficient for upper comfort violation
    c_l = 5.0               # Cost coefficient for lower comfort violation
    Φ_CV_min = 3.8          # Minimum thermal output power of the boiler [kW]
    Φ_CV_max = 14.8         # Maximum thermal output power of the boiler [kW]
    Φ_HP_min = 1.8          # Minimum thermal output power of the heat pump [kW]
    Φ_HP_max = 5.0          # Maximum thermal output power of the heat pump [kW]
    P_HP_max = 1.8          # Maximum compressor power of the heat pump [kW]
    η_g = 0.95
    H_g = 9.77
    T_h_fixed = 40.0
    m, N = size(A_d, 1), size(df, 1)
    model = Model(() -> MOA.Optimizer(Gurobi.Optimizer))
    set_silent(model)
    set_attribute(model, MOA.Algorithm(), MOA.Dichotomy())
    set_attribute(model, MOA.SolutionLimit(), 4)
    @variables(model, begin
        Φ_CV[1:N] in Semicontinuous(Φ_CV_min, Φ_CV_max)
        Φ_HP[1:N] in Semicontinuous(Φ_HP_min, Φ_HP_max)
        0 <= P_HP[1:N] <= P_HP_max
        t_u[1:N] >= 0
        t_l[1:N] >= 0
        T[1:N, [:i, :e, :h]]
        η_COP[1:N] >= 1.0
    end)
    set_lower_bound.(T[:, :h], 0.0)
    set_upper_bound.(T[:, :h], 75)
    set_start_value.(T[:, :h], 40)
    for (k, v) in (:i => df.T_i[1], :e => 0.95 * df.T_i[1], :h => df.T_h[1])
        fix(T[1, k], v; force = true)
    end
    @expressions(model, begin
        ΔT[k in 1:N], T[k, :h] - df.T_a[k]
        Φ_h[k in 1:N-1], Φ_CV[k] + Φ_HP[k]
        g[k in 1:N], (1 / η_g * H_g) * Φ_CV[k]
        u[k in 1:N-1], [df.T_a[k], Φ_h[k], df.Φ_s[k]]
        J_c, sum(df.λ_e[k] * P_HP[k] * Δt + λ_g * g[k] for k in 1:N)
        J_d, c_u * sum(t_u) + c_l * sum(t_l)
    end)
    @constraints(model, begin
        0.9 * T[1, :e] <= T[N, :e]
        [k in 1:N], Φ_HP[k] == η_COP[k] * P_HP[k]
        [k in 1:N], T[k, :i] - t_u[k] <= df.z_u[k]
        [k in 1:N], T[k, :i] + t_l[k] >= df.z_l[k]
        [k in 1:N-1], T[k+1, :].data .== A_d * T[k, :].data + B_d * u[k]
    end)
    @objective(model, Min, [J_c, J_d])
    function COP(ΔT, COP_max = 12.658297676254248)
        a = -0.3390084254197603
        x_max = 15.6810934268123
        y(x) = a * x + COP_max
        if ΔT <= 0.0
            return COP_max
        elseif 0.0 < ΔT <= x_max
            return y(ΔT)
        else
            return clamp(20.3595 - 3.2061 * log2(1 + ΔT), 1, COP_max)
        end
    end
    for k in 1:N
        COP_x = df.T_a[k]:(75 - df.T_a[k])
        COP_y = COP.(COP_x)
        n = length(COP_x)
        λ = @variable(model, [1:n], lower_bound = 0, upper_bound = 1)
        @constraint(model, COP_x' * λ == ΔT[k])
        @constraint(model, COP_y' * λ == η_COP[k])
        @constraint(model, sum(λ) == 1)
        @constraint(model, λ in SOS2())
    end
    optimize!(model)
    @assert is_solved_and_feasible(model)
    return model
end

A_d = [
    0.927295 0.0641121 0.0054676
    0.0264427 0.97342 7.81985e-5
    0.125957 0.00436776 0.869463
]
B_d = [
    0.00312517 0.00144172 0.0745064
    5.91665e-5 1.35302e-5 0.0013016
    0.000211911 0.474613 0.0050522
]

data = DataFrames.DataFrame(
    T_a = rand(4:8, 96),
    Φ_s = rand(0.1:0.5, 96),
    z_u = repeat([21.5], 96),
    z_l = repeat([19.5], 96),
    λ_e = rand(0.1:0.5, 96),
    T_i = rand(20:30, 96),
    T_h = rand(20:30, 96),
)

model = baseline_model(data; A_d = A_d, B_d = B_d)
solution_summary(model; result = 1)
1 Like

Thank you, very appreciated.

I have applied this technique to my problem and made the entire COP function piece-wise linear. This also changed the problem type from non-linear to non-convex MIQCP. I found the non-linear form produced a lot of local minima which were not interesting.

The MIQCP form works very well in this regard, all the solutions produced are really interesting. But it is somewhat slow to converge. I would really appreciate it if you could look at the code below if you spot anything off/wrong, since I am really in unknown waters here…

using JuMP
import DataFrames
import Ipopt
import Juniper
import MultiObjectiveAlgorithms as MOA
import MathOptInterface as MOI


# time horizon
Δt = 0.25               # Time step [h]

# Parameters for costs
λ_g = 1.286             # Gas price [€/m³] (https://www.energievergelijk.nl/energieprijzen/gasprijs)
c_u = 0.5               # Cost coefficient for upper comfort violation
c_l = 3.0               # Cost coefficient for lower comfort violation

# Operational constraints
Φ_CV_min = 3.8          # Minimum thermal output power of the boiler [kW]
Φ_CV_max = 14.8         # Maximum thermal output power of the boiler [kW]
Φ_HP_min = 1.8          # Minimum thermal output power of the heat pump [kW]
Φ_HP_max = 5.0          # Maximum thermal output power of the heat pump [kW]
P_HP_max = 1.8          # Maximum compressor power of the heat pump [kW]

# Efficiency parameters
η_g = 0.95
H_g = 9.77

T_e_lookup = Dict(
    "H1" => 0.92,
    "H2" => 1.0,
    "H10" => 1.0,
    "H12" => 0.92,
    "H14" => 1.12,
    "H15" => 0.95,
    "H18" => 1.02,
    "H19" => 1.00,
    "H21" => 1.00,
    "H24" => 0.85,
    "H26" => 0.92,
    "H27" => 0.92
)




function baseline_model(
    df::DataFrames.DataFrame;
    A_d::Matrix{Float64},
    B_d::Matrix{Float64},
    id::String,
    mode::Symbol=:simplified,
    use_boiler::Bool=true
)
    m, N = size(A_d, 1), size(df, 1)
    model = Model()

    # default COP model 
    COP(ΔT) = 20.3595 - 3.2061 * log2(1 + ΔT)

    if mode == :nonlinear
        juniper = optimizer_with_attributes(
            Juniper.Optimizer,
            "nl_solver" =>
                optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0),
        )

        model = Model(() -> MOA.Optimizer(juniper))
        set_attribute(model, MOA.Algorithm(), MOA.Dichotomy())
        set_attribute(model, MOA.SolutionLimit(), 4)
    elseif mode == :MIQCP
        model = Model(Gurobi.Optimizer)
        MOI.set(model, MOI.RelativeGapTolerance(), 5e-2)
        MOI.set(model, MOI.TimeLimitSec(), 180.0)
    else
        error("Invalid mode: $mode")
    end

    # set_silent(model)

    @variables(model, begin
        0 <= Φ_CV[1:N] <= Φ_CV_max
        0 <= z_CV[1:N] <= 1, Bin
        0 <= Φ_HP[1:N] <= Φ_HP_max
        0 <= z_HP[1:N] <= 1, Bin
        0 <= P_HP[1:N] <= P_HP_max
        t_u[1:N] >= 0
        t_l[1:N] >= 0
        T[1:N, [:i, :e, :h]]
        η_COP[1:N] >= 1.0
        ΔT[1:N]
    end)
    set_lower_bound.(T[:, :h], 0.0)
    set_upper_bound.(T[:, :h], 75)
    set_start_value.(T[:, :h], 40)  # <-- important. Provide a starting point that log2(1 + ΔT) is defined for

    # set initial values for the state variables
    for (k, v) in (
        :i => df.T_i[1],
        :e => T_e_lookup[id] * df.T_i[1],
        :h => df.T_h[1]
    )
        fix(T[1, k], v; force=true)
    end

    @expressions(model, begin
        # temperature delta between emission and ambient temperature
        Φ_h[k in 1:N-1], Φ_CV[k] + Φ_HP[k]

        # gas consumption s.t. Φ_CV[k] = g[k] * η_g * H_g / Δt
        g[k in 1:N], Φ_CV[k] * Δt / (η_g * H_g)

        # input vector
        u[k in 1:N-1], [df.T_a[k], Φ_h[k], df.Φ_s[k]]

        # cost functions
        J_c, sum(df.λ_e[k] * P_HP[k] * Δt + λ_g * g[k] for k in 1:N)
        J_d, c_u * sum(t_u) + c_l * sum(t_l)
    end)

    # COP model 
    if mode == :nonlinear
        @constraint(model, [k in 1:N], η_COP[k] == COP(ΔT[k]))
    elseif mode == :MIQCP
        pwlCOP = pwl_COP(model, N, COP=η_COP, ΔT=ΔT)
        @constraint(model, [k in 1:N], η_COP[k] == pwlCOP[k])
    else
        error("Invalid mode: $mode")
    end

    @constraints(model, begin
        # operational constraints
        [k in 1:N], Φ_CV[k] <= Φ_CV_max * z_CV[k]
        [k in 1:N], Φ_CV[k] >= Φ_CV_min * z_CV[k]
        [k in 1:N], Φ_HP[k] <= Φ_HP_max * z_HP[k]
        [k in 1:N], Φ_HP[k] >= Φ_HP_min * z_HP[k]
        [k in 1:N], ΔT[k] == T[k, :h] - df.T_a[k]

        # to make sure envelope thermal store is not emptied at the end of the time horizon
        0.9 * T[1, :e] <= T[N, :e]

        [k in 1:N], Φ_HP[k] == η_COP[k] * P_HP[k]

        # comfort constraints
        [k in 1:N], T[k, :i] - t_u[k] <= df.z_u[k]
        [k in 1:N], T[k, :i] + t_l[k] >= df.z_l[k]

        # state-space model dynamics
        [k in 1:N-1], T[k+1, :].data .== A_d * T[k, :].data + B_d * u[k]
    end)

    @objective(model, Min, [J_c, 2 * J_d])
    optimize!(model)
    # @assert is_solved_and_feasible(model)
    return model
end



# piecewise linear approximation for COP(ΔT) over multiple timesteps [1:N]
function pwl_COP(model::Model,
    N::Int=96;
    COP::Vector{VariableRef},
    ΔT::Vector{VariableRef},
    COP_max::Float64=12.658297676254248
)
    ΔT̂ = [-100.0, 0.0, 15.6810934268123, 24.296261628549118, 37.36084577953766, 57.17280476181883, 63.0]
    COP̂ = COP_function.(ΔT̂, COP_max)
    n = length(ΔT̂)

    # define decision variables for each timestep t ∈ [1:N]
    @variable(model, 0 <= λ[1:N, 1:n] <= 1)                 # λ variables for interpolation at each timestep

    # apply the piecewise linear constraints for each timestep
    for k in 1:N
        @constraints(model, begin
            ΔT[k] == sum(λ[k, i] * ΔT̂[i] for i in 1:n)      # Interpolating ΔT for timestep t
            COP[k] == sum(λ[k, i] * COP̂[i] for i in 1:n)    # Interpolating COP for timestep t
            sum(λ[k, :]) == 1                               # Convex combination of λ for timestep t
            λ[k, :] in SOS2()                               # Special ordered set 2 constraint
        end
        )
    end

    return COP
end


# define the COP function with breakpoints
function COP_function(ΔT, COP_max=12.658297676254248)
    a = -0.3390084254197603
    x_max = 15.6810934268123
    y(x) = a * x + COP_max

    if ΔT <= 0.0
        return COP_max
    elseif 0.0 < ΔT <= x_max
        return y(ΔT)
    else
        return clamp(20.3595 - 3.2061 * log2(1 + ΔT), 1, COP_max)
    end
end

There’s nothing inherently “wrong” with this. It’s difficult problem to solve to optimality.

1 Like

Thanks for the feedback, it’s very appreciated.

1 Like