Bids using Consumer Unit Commitment (Workaround)

In this tutorial we will learn how to create bids using consumer unit commitment. Bids are not explicitly supported in Tulipa (yet), but they can be modeled with a few workarounds.

This is an advanced tutorial. It assumes some basic knowledge of Tulipa, so going through some of the earlier tutorials might be beneficial.

Introduction

In our context, a bid is a proposal to buy energy at a given price at one or more time steps. If the proposal is for a single time step, then we are going to call it a "simple bid", as opposed to a "profile bid", when it involves more than one time step. The price is constant in both cases, which allow us to always use vectors to represent the required quantities. In a "simple bid", the vectors of time steps and quantities both have 1 element.

Furthermore, the bid can be part of an "exclusive group". Inside each exclusive group, a single bid is accepted. Finally, some bids also have a curtailment possibility, i.e., they can be supplied with less energy than the maximum desired, but no less than a given percentage of the maximum (given by curtailment_minimum).

Here are some example bids:

bid_blocks = [
    (
        customer = "A",
        exclusive_group = 1,
        profile_block = 1,
        timestep = 4:4,
        quantity = [10],
        price = 5.0,
        curtailment_minimum = 1.0,
    ),
    (
        customer = "A",
        exclusive_group = 2,
        profile_block = 1,
        timestep = 2:3,
        quantity = [40, 30],
        price = 2.5,
        curtailment_minimum = 1.0,
    ),
    (
        customer = "A",
        exclusive_group = 2,
        profile_block = 2,
        timestep = 2:3,
        quantity = [20, 20],
        price = 1.5,
        curtailment_minimum = 0.8,
    ),
    (
        customer = "B",
        exclusive_group = 1,
        profile_block = 1,
        timestep = 1:6,
        quantity = [5, 10, 15, 25, 30, 15],
        price = 0.8,
        curtailment_minimum = 1.0,
    ),
]
4-element Vector{@NamedTuple{customer::String, exclusive_group::Int64, profile_block::Int64, timestep::UnitRange{Int64}, quantity::Vector{Int64}, price::Float64, curtailment_minimum::Float64}}:
 (customer = "A", exclusive_group = 1, profile_block = 1, timestep = 4:4, quantity = [10], price = 5.0, curtailment_minimum = 1.0)
 (customer = "A", exclusive_group = 2, profile_block = 1, timestep = 2:3, quantity = [40, 30], price = 2.5, curtailment_minimum = 1.0)
 (customer = "A", exclusive_group = 2, profile_block = 2, timestep = 2:3, quantity = [20, 20], price = 1.5, curtailment_minimum = 0.8)
 (customer = "B", exclusive_group = 1, profile_block = 1, timestep = 1:6, quantity = [5, 10, 15, 25, 30, 15], price = 0.8, curtailment_minimum = 1.0)

Each bid has the following data:

  • customer, identifying who is the asking party;
  • exclusive_group, identifying each group of exclusive bids;
  • profile_block, identifying each block of bids;
  • timestep, indicating the time steps of a bid;
  • quantity, indicating the vector of requested quantities;
  • price, indicating the price;
  • curtailment_minimum, indicating the minimum percentage of energy that can be delivered under curtailment.

Notice that (customer, exclusive_group, profile_block) form a unique identifier for this bid.

In words, we can say:

  • The first bid, (A, 1, 1), requests 10 KW at time step 4 and is willing to pay $5.0 per KW. No curtailment allowed.
  • The second bid, (A, 2, 1), requests 40 KW at time step 2 and 30 KW at time step 3 and is willing to pay $2.5 per KW. No curtailment allowed.
  • The third bid, (A, 2, 2), requests 20 KW at time step 2 and 20 KW at time step 3 and is willing to pay $1.5 per KW. At least 80% of the requested quantity per day must be satisfied.
  • The fourth bid, (B, 1, 1), requests 5 KW, 10 KW, 15 KW, 25 KW, 30 KW, and 15 KW from time steps 1 to 6, in order. It is willing to pay $0.8 per KW, and no curtailment is allowed.

Finally, notice that the second and third bids share the same exclusive_group, so at most one of them can be accepted.

Modeling

We don't have an underlying energy system to make these bids, so let's create a fake scenario with

  • One generator, with 1 initial unit, where the capacity is given by us;
  • One consumer, with no demand (because we don't care for this problem);
  • A flow between the generator and the consumer, with an operational cost given by us;

Creating initial problem with TulipaBuilder

We will use TulipaBuilder.jl to create the Tulipa problem for this problem:

using TulipaBuilder

year = 2030 # We don't need the year for anything, but we need to set it
num_timesteps = 6

function create_new_problem(;capacity = 60.0, operational_cost = 0.5)
    tulipa = TulipaData()

    add_asset!(tulipa, "Generator", :producer; capacity, operational_cost, initial_units = 1.0)
    add_asset!(tulipa, "Consumer", :consumer; peak_demand = 0.0)
    add_flow!(tulipa, "Generator", "Consumer"; operational_cost)
    # Because we need at least one profile, we explicitly set demand to 0
    attach_profile!(tulipa, "Consumer", :demand, year, zeros(num_timesteps))
end
create_new_problem (generic function with 1 method)

Notice that this is already a valid Tulipa problem, but the solution is to have no flow.

using TulipaClustering: TulipaClustering as TC
using TulipaEnergyModel: TulipaEnergyModel as TEM

tulipa = create_new_problem()

# Convert TulipaBuilder's data to TulipaEnergyModel format in the connection
connection = create_connection(tulipa, TEM.schema)

# (Fake) cluster the profiles to generate representative periods
TC.dummy_cluster!(connection; layout = TC.ProfilesTableLayout(year = :milestone_year))

# Solve the scenario
TEM.populate_with_defaults!(connection)
energy_problem = TEM.run_scenario(connection, show_log=false)

energy_problem
EnergyProblem:
  - Model created!
    - Number of variables: 6
    - Number of constraints for variable bounds: 6
    - Number of structural constraints: 12
  - Model solved!
    - Termination status: OPTIMAL
    - Objective value: 0.0
    - Objective breakdown:
      - assets_fixed_cost_aggregated_vintage_method: 0.0
      - assets_fixed_cost_compact_vintage_method: 0.0
      - assets_investment_cost: 0.0
      - flows_fixed_cost: 0.0
      - flows_investment_cost: 0.0
      - flows_operational_cost: 0.0
      - storage_assets_energy_fixed_cost: 0.0
      - storage_assets_energy_investment_cost: 0.0
      - units_on_operational_cost: 0.0
      - vintage_flows_operational_cost: 0.0

Input modification for bids

The trick to have bids is to create a new asset for each of the bids. Each of these bid assets is a consumer asset requesting the profile bid as a "demand" bid. In Tulipa, the :consumer assets can also serve as balance nodes, i.e., they are allowed to provide energy to other assets connected via outgoing flows. So, to satisfy the "demand" of the bid assets, we create a flow from the "Consumer" asset to these bid assets. To simulate the price willing to be paid by a bid, we use the operational_cost between the "Consumer" and the bid asset. In summary:

  • For each (consumer, exclusive_group, profile_block) bid, create a new ':consumer' asset.
  • Attach a profile with the quantities per time steps of the bid to this asset.
    • The profiles in Tulipa have to be complete, so the remaining hours are simply completed with 0.
  • Create a flow between an existing :consumer and this bid asset and set operational_cost = -price.

However, this by itself is not sufficient, because there is nothing yet forcing this bid to accepted or not.

If the bid is accepted, the requested quantity is treated as a demand to be satisfied for every time step. If the bid is not accepted, there should be no flow to this asset for every time step.

The missing link is to have some kind of variable that indicates whether the bid is accepted or not. For that, we will use an existing feature of TulipaEnergyModel, Unit Commitment, but we will apply it to consumers.

By itself, however, this is not enough, because the consumer balance constraint still forces the requested bid to be satisfied, and there is nothing tying that to the unit commitment variables. Therefore, we use a special condition inside the consumer balance constraint created specifically for this case, which is to create a loop flow in the bid asset. This existence of a loop flow changes the balance constraint tying the incoming flow to the loop flow, and the loop flow is tied to the unit commitment variables by the minimum and maximum output flow ramping constraints.

These are the modifications:

  • For each bid, create a new asset. We'll name it "Bid". Set
    • capacity = 1.0
    • consumer_balance_sense = "==" (which is the default)
    • initial_units = 1.0
    • peak_demand as anything positive (1.0 makes it easier to understand the results, maximum(bid_block.profile) is the common normalized way)
    • type = :consumer
    • unit_commitment = "basic"
    • unit_commitment_integer = true
  • Set the time resolution of the asset to the full length of the profile (assets_rep_periods_partitions.partition = rep_periods_data.num_timesteps)
  • Find an existing consumer, we'll name it "Bid Manager".
  • Connect a flow from the "Bid Manager" to "Bid", with flow_milestone.operational_cost = -price.
  • Create a loop flow, connecting the asset "Bid" to itself.
  • Create a profile in profiles_rep_periods or profiles, depending on whether you still have to cluster or not.
    • Use the bid's quantities, normalized by peak_demand, as value, for the corresponding time steps as timestep.
    • Use 0 as value for the missing timestep.
    • Choose a profile_name
  • Relate the profile above to the asset "Bid" in assets_profiles, with profile_type = 'demand'.

We can create a function to help us create a bid with the above characteristics based on a given bid_block:

function add_new_bid!(tulipa, bid_id, bid_block)
    bid_name = "bid$bid_id"
    bid_manager = "Consumer"
    peak_demand = 1.0
    add_asset!(
        tulipa,
        bid_name,
        :consumer,
        capacity = 1.0,
        consumer_balance_sense = "==",
        initial_units = 1.0,
        min_operating_point = bid_block.curtailment_minimum,
        peak_demand = peak_demand,
        unit_commitment = "basic",
        unit_commitment_integer = true,
    )
    set_partition!(tulipa, bid_name, year, 1, num_timesteps) # 1 = rep_period, there is only one
    add_flow!(tulipa, bid_manager, bid_name, operational_cost = -bid_block.price)
    add_flow!(tulipa, bid_name, bid_name)
    profile = zeros(num_timesteps)
    profile[bid_block.timestep] = bid_block.quantity / peak_demand
    attach_profile!(tulipa, bid_name, :demand, year, profile)

    return tulipa
end
add_new_bid! (generic function with 1 method)

With this function, we can go back to our initial problem and add the bid blocks from the beginning:

tulipa = create_new_problem(capacity = 60, operational_cost = 0.5)
for (bid_id, bid_block) in enumerate(bid_blocks)
    add_new_bid!(tulipa, bid_id, bid_block)
end

# Convert TulipaBuilder's data to TulipaEnergyModel format in the connection
connection = create_connection(tulipa, TEM.schema)

# (Fake) cluster the profiles to generate representative periods
TC.dummy_cluster!(connection; layout = TC.ProfilesTableLayout(year = :milestone_year))

# Solve the scenario
TEM.populate_with_defaults!(connection)
energy_problem = TEM.run_scenario(connection, show_log=false)

energy_problem
EnergyProblem:
  - Model created!
    - Number of variables: 58
    - Number of constraints for variable bounds: 58
    - Number of structural constraints: 88
  - Model solved!
    - Termination status: OPTIMAL
    - Objective value: -225.0
    - Objective breakdown:
      - assets_fixed_cost_aggregated_vintage_method: 0.0
      - assets_fixed_cost_compact_vintage_method: 0.0
      - assets_investment_cost: 0.0
      - flows_fixed_cost: 0.0
      - flows_investment_cost: 0.0
      - flows_operational_cost: -225.0
      - storage_assets_energy_fixed_cost: 0.0
      - storage_assets_energy_investment_cost: 0.0
      - units_on_operational_cost: 0.0
      - vintage_flows_operational_cost: 0.0

We can see that the objective value is different, but let's investigate the solution in more details. First, we can check which flows are not 0:

using DuckDB, DataFrames

# Helper function
df_sql(con, s) = DataFrame(DuckDB.query(con, s))

df_sql(
    connection,
    """
    SELECT from_asset, to_asset, time_block_start AS timestep, solution,
    FROM var_flow
    WHERE solution != 0
    """,
)
13×4 DataFrame
Rowfrom_assetto_assettimestepsolution
StringStringInt32Float64
1GeneratorConsumer260.0
2GeneratorConsumer350.0
3GeneratorConsumer410.0
4Consumerbid1410.0
5Consumerbid2240.0
6Consumerbid2330.0
7Consumerbid3220.0
8Consumerbid3320.0
9bid1bid1410.0
10bid2bid2240.0
11bid2bid2330.0
12bid3bid3220.0
13bid3bid3320.0

Second, we can also check the unit commitment variables:

df_sql(
    connection,
    """
    SELECT asset, solution,
    FROM var_units_on
    WHERE solution != 0
    """,
)
3×2 DataFrame
Rowassetsolution
StringFloat64
1bid11.0
2bid21.0
3bid31.0

We can see from these two tables that bids 1, 2, and 3 were accepted. This mostly makes sense, except that bids 2 and 3 should not be accepted at the same time, since they are in the same exclusivity group (same exclusive_group for a given customer). This means that we have one least modification to make.

Model modification for exclusivity of the bids

This modification has to be done directly in the underlying JuMP model. The required change is to add a constraint $\displaystyle \sum_{i: i \in G_k} u_i \leq 1$, where $u_i$ are the unit commitment variables (i.e., the bid-acceptance variables), and $G_k$ are the exclusive groups.

The function below modifies a model with this constraint:

using JuMP

function add_exclusive_groups!(energy_problem, bid_blocks)
    exclusive_groups = Dict{Tuple{String,Int},Vector{Int}}() # (customer, exclusive_group) -> [bid_ids...]
    for (bid_id, bid) in enumerate(bid_blocks)
        key = (bid.customer, bid.exclusive_group)
        if !haskey(exclusive_groups, key)
            exclusive_groups[key] = Int[]
        end
        push!(exclusive_groups[key], bid_id)
    end

    for ((customer, exclusive_group), bid_ids) in exclusive_groups
        if length(bid_ids) == 1 # There is only one bid in this group, there is no need to further constrain
            continue
        end
        var = energy_problem.variables[:units_on].container
        JuMP.@constraint(
            energy_problem.model,
            sum(var[id] for id in bid_ids) <= 1,
            base_name = "exclusive_bid_group[$(customer),$(exclusive_group)]",
        )
    end
end
add_exclusive_groups! (generic function with 1 method)

We now modify our little script with this additional step:

tulipa = create_new_problem(capacity = 60, operational_cost = 0.5)
for (bid_id, bid_block) in enumerate(bid_blocks)
    add_new_bid!(tulipa, bid_id, bid_block)
end

# Convert TulipaBuilder's data to TulipaEnergyModel format in the connection
connection = create_connection(tulipa, TEM.schema)

# (Fake) cluster the profiles to generate representative periods
TC.dummy_cluster!(connection; layout = TC.ProfilesTableLayout(year = :milestone_year))

# Create the mode
TEM.populate_with_defaults!(connection)
energy_problem = TEM.EnergyProblem(connection)
TEM.create_model!(energy_problem)

# Modify the model
add_exclusive_groups!(energy_problem, bid_blocks)

# Solve the model
TEM.solve_model!(energy_problem)
TEM.save_solution!(energy_problem; compute_duals = true)

energy_problem
EnergyProblem:
  - Model created!
    - Number of variables: 58
    - Number of constraints for variable bounds: 58
    - Number of structural constraints: 89
  - Model solved!
    - Termination status: OPTIMAL
    - Objective value: -215.0
    - Objective breakdown:
      - assets_fixed_cost_aggregated_vintage_method: 0.0
      - assets_fixed_cost_compact_vintage_method: 0.0
      - assets_investment_cost: 0.0
      - flows_fixed_cost: 0.0
      - flows_investment_cost: 0.0
      - flows_operational_cost: -215.0
      - storage_assets_energy_fixed_cost: 0.0
      - storage_assets_energy_investment_cost: 0.0
      - units_on_operational_cost: 0.0
      - vintage_flows_operational_cost: 0.0

Once again, we investigate the flow and unit commitment solution:

using DuckDB, DataFrames

# Helper function
df_sql(con, s) = DataFrame(DuckDB.query(con, s))

df_sql(
    connection,
    """
    SELECT from_asset, to_asset, time_block_start AS timestep, solution,
    FROM var_flow
    WHERE solution != 0
    """,
)
24×4 DataFrame
Rowfrom_assetto_assettimestepsolution
StringStringInt32Float64
1GeneratorConsumer15.0
2GeneratorConsumer250.0
3GeneratorConsumer345.0
4GeneratorConsumer435.0
5GeneratorConsumer530.0
6GeneratorConsumer615.0
7Consumerbid1410.0
8Consumerbid2240.0
9Consumerbid2330.0
10Consumerbid415.0
11Consumerbid4210.0
12Consumerbid4315.0
13Consumerbid4425.0
14Consumerbid4530.0
15Consumerbid4615.0
16bid1bid1410.0
17bid2bid2240.0
18bid2bid2330.0
19bid4bid415.0
20bid4bid4210.0
21bid4bid4315.0
22bid4bid4425.0
23bid4bid4530.0
24bid4bid4615.0

and

df_sql(
    connection,
    """
    SELECT asset, solution,
    FROM var_units_on
    WHERE solution != 0
    """,
)
3×2 DataFrame
Rowassetsolution
StringFloat64
1bid11.0
2bid21.0
3bid41.0

Now, we can see that bids 1, 2, and 4 are accepted.

Testing more cases

To play around a little more, we can wrap this is a function and try a few cases:

function full_bid_run(bid_blocks; capacity, operational_cost)
    tulipa = create_new_problem(; capacity, operational_cost)
    for (bid_id, bid_block) in enumerate(bid_blocks)
        add_new_bid!(tulipa, bid_id, bid_block)
    end

    # Convert TulipaBuilder's data to TulipaEnergyModel format in the connection
    connection = create_connection(tulipa, TEM.schema)

    # (Fake) cluster the profiles to generate representative periods
    TC.dummy_cluster!(connection; layout = TC.ProfilesTableLayout(year = :milestone_year))

    # Create the mode
    TEM.populate_with_defaults!(connection)
    energy_problem = TEM.EnergyProblem(connection)
    TEM.create_model!(energy_problem)

    # Modify the model
    add_exclusive_groups!(energy_problem, bid_blocks)

    # Solve the model
    TEM.solve_model!(energy_problem)
    TEM.save_solution!(energy_problem; compute_duals = true)

    flow_solution = Dict(
        (row.from_asset, row.to_asset, row.timestep) => row.solution
        for row in DuckDB.query(
            connection,
            """
            SELECT from_asset, to_asset, time_block_start AS timestep, solution,
            FROM var_flow
            WHERE solution != 0
            """,
        )
    )

    bid_id_lookup = Dict("bid$bid_id" => bid_id for bid_id = 1:length(bid_blocks))

    accepted_bids = [
        round(Int, bid_id_lookup[row.asset]) # The solution is returned as float
        for row in DuckDB.query(
            connection,
            """
            SELECT asset, solution,
            FROM var_units_on
            WHERE solution != 0
            """,
        )
    ]

    return energy_problem.objective_value, flow_solution, accepted_bids
end

energy_problem
EnergyProblem:
  - Model created!
    - Number of variables: 58
    - Number of constraints for variable bounds: 58
    - Number of structural constraints: 89
  - Model solved!
    - Termination status: OPTIMAL
    - Objective value: -215.0
    - Objective breakdown:
      - assets_fixed_cost_aggregated_vintage_method: 0.0
      - assets_fixed_cost_compact_vintage_method: 0.0
      - assets_investment_cost: 0.0
      - flows_fixed_cost: 0.0
      - flows_investment_cost: 0.0
      - flows_operational_cost: -215.0
      - storage_assets_energy_fixed_cost: 0.0
      - storage_assets_energy_investment_cost: 0.0
      - units_on_operational_cost: 0.0
      - vintage_flows_operational_cost: 0.0

Now that we have a function that runs the whole process based on the given bid blocks, the generator capacity, and the operational cost to deliver the generated energy, we can verify these cases:

  • There is enough capacity to accept all bids and there is no generation cost, so we expect the all bids to be accepted. Notice that bids 2 and 3 are exclusive, so only bids 1, 2, and 4 are accepted.
_, _, accepted_bids = full_bid_run(bid_blocks; capacity = 999.9, operational_cost = 0.0)
@assert accepted_bids == [1, 2, 4]
[ Info: [2026-09-16T10:52:09.118 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:09.464 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:09.500 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:09.590 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:09.596 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:09.596 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:09.600 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:09.600 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:09.603 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:09.604 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:09.605 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:10.025 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:10.025 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:10.039 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:10.043 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:10.069 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:10.090 UTC] Adding objective
[ Info: [2026-09-16T10:52:10.102 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:10.104 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:10.122 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:10.124 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:10.128 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:10.158 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:10.159 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:10.161 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:10.170 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:10.173 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:10.184 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:10.188 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:10.190 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:10.193 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:10.195 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:10.197 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:10.198 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:10.200 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:10.207 UTC] Optimization model creation finished
  • By restricting the capacity, we accepted bids will eventually change. The first breakpoint is at capacity = 50, because bids 2 and 4 requires 50 KW at time steps 2 and 3.

When capacity is slightly less than 50, bid 3 is dropped:

_, _, accepted_bids = full_bid_run(bid_blocks; capacity = 49.9, operational_cost = 0.0)
@assert accepted_bids == [1, 2]
[ Info: [2026-09-16T10:52:10.641 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:10.981 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:11.018 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:11.106 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:11.112 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:11.112 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:11.116 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:11.117 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:11.119 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:11.120 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:11.120 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:11.120 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:11.120 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:11.121 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:11.121 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:11.121 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:11.121 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:11.537 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:11.537 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:11.551 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:11.554 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:11.579 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:11.601 UTC] Adding objective
[ Info: [2026-09-16T10:52:11.613 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:11.614 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:11.631 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:11.633 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:11.637 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:11.666 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:11.667 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:11.669 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:11.677 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:11.680 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:11.691 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:11.694 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:11.697 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:11.699 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:11.701 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:11.703 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:11.704 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:11.706 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:11.713 UTC] Optimization model creation finished
  • We also expect bid 4 to be dropped if the price is not higher than the operational cost:
_, _, accepted_bids = full_bid_run(bid_blocks; capacity = 999.9, operational_cost = 1.0)
@assert accepted_bids == [1, 2]
[ Info: [2026-09-16T10:52:11.872 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:12.234 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:12.271 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:12.357 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:12.363 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:12.363 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:12.366 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:12.367 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:12.369 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:12.370 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:12.371 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:12.778 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:12.778 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:12.791 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:12.794 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:12.821 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:12.842 UTC] Adding objective
[ Info: [2026-09-16T10:52:12.853 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:12.855 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:12.872 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:12.874 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:12.877 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:12.907 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:12.908 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:12.910 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:12.917 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:12.921 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:12.931 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:12.934 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:12.937 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:12.939 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:12.941 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:12.943 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:12.944 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:13.103 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:13.113 UTC] Optimization model creation finished
  • Decreasing the capacity to slightly less than 40 KW, also makes us drop bid 2, but allows us to have more space to accept bids 3 and 4:
_, _, accepted_bids = full_bid_run(bid_blocks; capacity = 39.9, operational_cost = 0.0)
@assert accepted_bids == [1, 3, 4]
[ Info: [2026-09-16T10:52:13.303 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:13.686 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:13.726 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:13.821 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:13.828 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:13.828 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:13.832 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:13.832 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:13.835 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:13.836 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:13.836 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:13.836 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:13.836 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:13.836 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:13.837 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:13.837 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:13.837 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:14.302 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:14.303 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:14.320 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:14.323 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:14.354 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:14.380 UTC] Adding objective
[ Info: [2026-09-16T10:52:14.394 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:14.395 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:14.418 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:14.421 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:14.425 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:14.465 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:14.466 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:14.469 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:14.479 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:14.484 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:14.497 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:14.500 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:14.504 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:14.507 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:14.509 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:14.512 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:14.512 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:14.515 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:14.523 UTC] Optimization model creation finished
  • In fact, because bid 3 can be curtailed to 80%, we can further decrease the capacity. Up to 35 KW, the same bids are still accepted:
a, b, accepted_bids = full_bid_run(bid_blocks; capacity = 35.0, operational_cost = 0.0)
@assert accepted_bids == [1, 3, 4]
[ Info: [2026-09-16T10:52:14.707 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:15.071 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:15.111 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:15.207 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:15.213 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:15.213 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:15.218 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:15.219 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:15.221 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:15.222 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:15.223 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:15.674 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:15.675 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:15.690 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:15.694 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:15.723 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:15.746 UTC] Adding objective
[ Info: [2026-09-16T10:52:15.759 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:15.760 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:15.780 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:15.783 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:15.786 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:15.819 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:15.820 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:15.822 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:15.831 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:15.834 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:15.846 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:15.849 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:15.852 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:15.855 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:15.857 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:15.859 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:15.860 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:15.862 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:15.870 UTC] Optimization model creation finished
  • Slight less capacity forces the model to drop another bid. Although bid 4 is cheaper per KW, is requests more energy, to it is better.
a, b, accepted_bids = full_bid_run(bid_blocks; capacity = 34.9, operational_cost = 0.0)
@assert accepted_bids == [3, 4]
[ Info: [2026-09-16T10:52:16.037 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:16.394 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:16.433 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:16.526 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:16.532 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:16.533 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:16.536 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:16.537 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:16.540 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:16.541 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:16.982 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:16.982 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:16.997 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:17.000 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:17.028 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:17.051 UTC] Adding objective
[ Info: [2026-09-16T10:52:17.063 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:17.064 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:17.083 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:17.086 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:17.090 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:17.121 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:17.122 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:17.124 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:17.133 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:17.136 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:17.147 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:17.151 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:17.154 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:17.156 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:17.158 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:17.161 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:17.161 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:17.164 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:17.171 UTC] Optimization model creation finished
  • But if the generation cost is too high, then bid 4 is dropped in favour of bid 1.
a, b, accepted_bids = full_bid_run(bid_blocks; capacity = 34.9, operational_cost = 1.0)
@assert accepted_bids == [1, 3]
[ Info: [2026-09-16T10:52:17.338 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-16T10:52:17.706 UTC] Computing variable indices
[ Info: [2026-09-16T10:52:17.743 UTC] Computing constraint indices
[ Info: [2026-09-16T10:52:17.833 UTC] Preparing profiles
[ Info: [2026-09-16T10:52:17.839 UTC] Creating optimization model
[ Info: [2026-09-16T10:52:17.839 UTC] Preparing optimization model
[ Info: [2026-09-16T10:52:17.842 UTC] Preparing model parameters
[ Info: [2026-09-16T10:52:17.843 UTC] Adding flow variables
[ Info: [2026-09-16T10:52:17.846 UTC] Adding vintage flow variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding investment variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding decommission variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding unit commitment variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding power flow variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding storage variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-16T10:52:17.847 UTC] Adding expressions to constraints
[ Info: [2026-09-16T10:52:18.272 UTC] Creating model expressions
[ Info: [2026-09-16T10:52:18.272 UTC] Creating multi-year expressions
[ Info: [2026-09-16T10:52:18.286 UTC] Adding storage expressions
[ Info: [2026-09-16T10:52:18.290 UTC] Preparing objective tables
[ Info: [2026-09-16T10:52:18.317 UTC] Adding operational cost expressions
[ Info: [2026-09-16T10:52:18.339 UTC] Adding objective
[ Info: [2026-09-16T10:52:18.352 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-16T10:52:18.353 UTC] Adding capacity constraints
[ Info: [2026-09-16T10:52:18.372 UTC] Adding energy constraints
[ Info: [2026-09-16T10:52:18.374 UTC] Adding consumer constraints
[ Info: [2026-09-16T10:52:18.378 UTC] Adding storage constraints
[ Info: [2026-09-16T10:52:18.410 UTC] Adding conversion constraints
[ Info: [2026-09-16T10:52:18.411 UTC] Adding transport constraints
[ Info: [2026-09-16T10:52:18.413 UTC] Adding investment group constraints
[ Info: [2026-09-16T10:52:18.422 UTC] Adding available asset units constraints
[ Info: [2026-09-16T10:52:18.425 UTC] Adding ramping constraints
[ Info: [2026-09-16T10:52:18.436 UTC] Adding minimum output flow constraints
[ Info: [2026-09-16T10:52:18.439 UTC] Adding flow relationship constraints
[ Info: [2026-09-16T10:52:18.442 UTC] Adding DC power flow constraints
[ Info: [2026-09-16T10:52:18.445 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-16T10:52:18.447 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-16T10:52:18.449 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-16T10:52:18.450 UTC] Adding minimum up time constraints
[ Info: [2026-09-16T10:52:18.453 UTC] Adding minimum down time constraints
[ Info: [2026-09-16T10:52:18.459 UTC] Optimization model creation finished

Visualization of the results

To help visualize the use of bids, we will vary the value of generator's capacity and the operational cost to get from the generator to the bid and create a few plots of the solutions. We are doing the same as in the previous section, but systematically.

We will use some longer code that we'll hide, but that can be inspected in the code for this file. The code simply loops over many cases, like the section above, and saves data to be used in the plots below:

# plts and dim2 are defined in the hidden code
plot(
    plts...;
    size = (300 * dim2, 3 * 200),
    layout = grid(3, dim2),
    leftmargin = 5Plots.mm,
    bottommargin = 4Plots.mm,
)
Example block output

The plot has three columns and three rows. The columns vary in operational cost, and the rows show three different kinds of plots. The x-axis of all plots is the capacity.

The first row of plots show the profit made accepting these bids, per capacity. The second row of plots show the accepted bids per capacity. The plots in the third row show the profit made per capacity, but grouped per bid.

Some noteworthy points in the plots above:

  • For operational cost = $ 0.1 / KW, around capacity 35, bid 1 is slightly less profitable than bid 4, and there is only capacity for one of them (and bid 3), so the accepted bids change accordingly.
  • For operational cost = $ 0.6 / KW, this is not the case anymore, and thus bid 1 is always accepted.
  • For operational cost = $ 1.1 / KW, then it is never profitable to accept bid 4.
  • The profit generated by bid 3 around capacity 16 to 20, and 31 to 35 is linearly increasing, since the bid 3 can be curtailed.