Hydro valleys

This tutorial was generated using Literate.jl. Download the source as a .jl file. Download the source as a .ipynb file.

This problem is a version of the hydro-thermal scheduling problem. The goal is to operate two hydro-dams in a valley chain over time in the face of inflow and price uncertainty.

Turbine response curves are modelled by piecewise linear functions which map the flow rate into a power. These can be controlled by specifying the breakpoints in the piecewise linear function as the knots in the Turbine struct.

The model can be created using the hydro_valley_model function. It has a few keyword arguments to allow automated testing of the library. hasstagewiseinflows determines if the RHS noise constraint should be added. hasmarkovprice determines if the price uncertainty (modelled by a Markov chain) should be added.

In the third stage, the Markov chain has some unreachable states to test some code-paths in the library.

We can also set the sense to :Min or :Max (the objective and bound are flipped appropriately).

using SDDP, HiGHS, Test, Random

struct Turbine
    flowknots::Vector{Float64}
    powerknots::Vector{Float64}
end

struct Reservoir
    min::Float64
    max::Float64
    initial::Float64
    turbine::Turbine
    spill_cost::Float64
    inflows::Vector{Float64}
end

function hydro_valley_model(;
    hasstagewiseinflows::Bool = true,
    hasmarkovprice::Bool = true,
    sense::Symbol = :Max,
)
    valley_chain = [
        Reservoir(
            0,
            200,
            200,
            Turbine([50, 60, 70], [55, 65, 70]),
            1000,
            [0, 20, 50],
        ),
        Reservoir(
            0,
            200,
            200,
            Turbine([50, 60, 70], [55, 65, 70]),
            1000,
            [0, 0, 20],
        ),
    ]

    turbine(i) = valley_chain[i].turbine

    # Prices[t, Markov state]
    prices = [
        1 2 0
        2 1 0
        3 4 0
    ]

    # Transition matrix
    if hasmarkovprice
        transition =
            Array{Float64,2}[[1.0]', [0.6 0.4], [0.6 0.4 0.0; 0.3 0.7 0.0]]
    else
        transition = [ones(Float64, (1, 1)) for t in 1:3]
    end

    flipobj = (sense == :Max) ? 1.0 : -1.0
    lower = (sense == :Max) ? -Inf : -1e6
    upper = (sense == :Max) ? 1e6 : Inf

    N = length(valley_chain)

    # Initialise SDDP Model
    return m = SDDP.MarkovianPolicyGraph(
        sense = sense,
        lower_bound = lower,
        upper_bound = upper,
        transition_matrices = transition,
        optimizer = HiGHS.Optimizer,
    ) do subproblem, node
        t, markov_state = node

        # ------------------------------------------------------------------
        #   SDDP State Variables
        # Level of upper reservoir
        @variable(
            subproblem,
            valley_chain[r].min <= reservoir[r = 1:N] <= valley_chain[r].max,
            SDDP.State,
            initial_value = valley_chain[r].initial
        )

        # ------------------------------------------------------------------
        #   Additional variables
        @variables(
            subproblem,
            begin
                outflow[r = 1:N] >= 0
                spill[r = 1:N] >= 0
                inflow[r = 1:N] >= 0
                generation_quantity >= 0 # Total quantity of water
                # Proportion of levels to dispatch on
                0 <=
                dispatch[r = 1:N, level = 1:length(turbine(r).flowknots)] <=
                1
                rainfall[i = 1:N]
            end
        )

        # ------------------------------------------------------------------
        # Constraints
        @constraints(
            subproblem,
            begin
                # flow from upper reservoir
                reservoir[1].out ==
                reservoir[1].in + inflow[1] - outflow[1] - spill[1]

                # other flows
                flow[i = 2:N],
                reservoir[i].out ==
                reservoir[i].in + inflow[i] - outflow[i] - spill[i] +
                outflow[i-1] +
                spill[i-1]

                # Total quantity generated
                generation_quantity == sum(
                    turbine(r).powerknots[level] * dispatch[r, level] for
                    r in 1:N for level in 1:length(turbine(r).powerknots)
                )

                # ------------------------------------------------------------------
                # Flow out
                turbineflow[r = 1:N],
                outflow[r] == sum(
                    turbine(r).flowknots[level] * dispatch[r, level] for
                    level in 1:length(turbine(r).flowknots)
                )

                # Dispatch combination of levels
                dispatched[r = 1:N],
                sum(
                    dispatch[r, level] for
                    level in 1:length(turbine(r).flowknots)
                ) <= 1
            end
        )

        # rainfall noises
        if hasstagewiseinflows && t > 1 # in future stages random inflows
            @constraint(subproblem, inflow_noise[i = 1:N], inflow[i] <= rainfall[i])

            SDDP.parameterize(
                subproblem,
                [
                    (valley_chain[1].inflows[i], valley_chain[2].inflows[i]) for i in 1:length(transition)
                ],
            ) do ω
                for i in 1:N
                    JuMP.fix(rainfall[i], ω[i])
                end
            end
        else # in the first stage deterministic inflow
            @constraint(
                subproblem,
                initial_inflow_noise[i = 1:N],
                inflow[i] <= valley_chain[i].inflows[1]
            )
        end

        # ------------------------------------------------------------------
        #   Objective Function
        if hasmarkovprice
            @stageobjective(
                subproblem,
                flipobj * (
                    prices[t, markov_state] * generation_quantity -
                    sum(valley_chain[i].spill_cost * spill[i] for i in 1:N)
                )
            )
        else
            @stageobjective(
                subproblem,
                flipobj * (
                    prices[t, 1] * generation_quantity -
                    sum(valley_chain[i].spill_cost * spill[i] for i in 1:N)
                )
            )
        end
    end
end

function test_hydro_valley_model()

    # For repeatability
    Random.seed!(11111)

    # deterministic
    deterministic_model =
        hydro_valley_model(hasmarkovprice = false, hasstagewiseinflows = false)
    SDDP.train(
        deterministic_model,
        iteration_limit = 10,
        cut_deletion_minimum = 1,
        print_level = 0,
    )
    @test SDDP.calculate_bound(deterministic_model) ≈ 835.0 atol = 1e-3

    # stagewise inflows
    stagewise_model = hydro_valley_model(hasmarkovprice = false)
    SDDP.train(stagewise_model, iteration_limit = 20, print_level = 0)
    @test SDDP.calculate_bound(stagewise_model) ≈ 838.33 atol = 1e-2

    # Markov prices
    markov_model = hydro_valley_model(hasstagewiseinflows = false)
    SDDP.train(markov_model, iteration_limit = 10, print_level = 0)
    @test SDDP.calculate_bound(markov_model) ≈ 851.8 atol = 1e-2

    # stagewise inflows and Markov prices
    markov_stagewise_model =
        hydro_valley_model(hasstagewiseinflows = true, hasmarkovprice = true)
    SDDP.train(markov_stagewise_model, iteration_limit = 10, print_level = 0)
    @test SDDP.calculate_bound(markov_stagewise_model) ≈ 855.0 atol = 1.0

    # risk averse stagewise inflows and Markov prices
    riskaverse_model = hydro_valley_model()
    SDDP.train(
        riskaverse_model,
        risk_measure = SDDP.EAVaR(lambda = 0.5, beta = 0.66),
        iteration_limit = 10,
        print_level = 0,
    )
    @test SDDP.calculate_bound(riskaverse_model) ≈ 828.157 atol = 1.0

    # stagewise inflows and Markov prices
    worst_case_model = hydro_valley_model(sense = :Min)
    SDDP.train(
        worst_case_model,
        risk_measure = SDDP.EAVaR(lambda = 0.5, beta = 0.0),
        iteration_limit = 10,
        print_level = 0,
    )
    @test SDDP.calculate_bound(worst_case_model) ≈ -780.867 atol = 1.0

    # stagewise inflows and Markov prices
    cutselection_model = hydro_valley_model()
    SDDP.train(
        cutselection_model,
        iteration_limit = 10,
        print_level = 0,
        cut_deletion_minimum = 2,
    )
    @test SDDP.calculate_bound(cutselection_model) ≈ 855.0 atol = 1.0

    # Distributionally robust Optimization
    dro_model = hydro_valley_model(hasmarkovprice = false)
    SDDP.train(
        dro_model,
        risk_measure = SDDP.ModifiedChiSquared(sqrt(2 / 3) - 1e-6),
        iteration_limit = 10,
        print_level = 0,
    )
    @test SDDP.calculate_bound(dro_model) ≈ 835.0 atol = 1.0

    dro_model = hydro_valley_model(hasmarkovprice = false)
    SDDP.train(
        dro_model,
        risk_measure = SDDP.ModifiedChiSquared(1 / 6),
        iteration_limit = 20,
        print_level = 0,
    )
    @test SDDP.calculate_bound(dro_model) ≈ 836.695 atol = 1.0
    # (Note) radius ≈ sqrt(2/3), will set all noise probabilities to zero except the worst case noise
    # (Why?):
    # The distance from the uniform distribution (the assumed "true" distribution)
    # to a corner of a unit simplex is sqrt(S-1)/sqrt(S) if we have S scenarios. The corner
    # of a unit simplex is just a unit vector, i.e.: [0 ... 0 1 0 ... 0]. With this probability
    # vector, only one noise has a non-zero probablity.
    # In the worst case rhsnoise (0 inflows) the profit is:
    #  Reservoir1: 70 * $3 + 70 * $2 + 65 * $1 +
    #  Reservoir2: 70 * $3 + 70 * $2 + 70 * $1
    ###  = $835
end

test_hydro_valley_model()
Test Passed