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-08T20:38:27.917 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:28.309 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:28.351 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:28.455 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:28.463 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:28.463 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:28.468 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:28.469 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:28.472 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:28.474 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:28.474 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:28.474 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:28.474 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:28.475 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:28.475 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:28.475 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:28.475 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:29.006 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:29.006 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:29.024 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:29.028 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:29.061 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:29.090 UTC] Adding objective
[ Info: [2026-09-08T20:38:29.105 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:29.106 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:29.130 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:29.133 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:29.138 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:29.178 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:29.179 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:29.181 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:29.191 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:29.195 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:29.209 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:29.213 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:29.217 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:29.221 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:29.223 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:29.226 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:29.226 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:29.229 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:29.237 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-08T20:38:29.725 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:30.110 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:30.152 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:30.252 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:30.259 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:30.259 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:30.263 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:30.264 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:30.267 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:30.268 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:30.269 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:30.269 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:30.718 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:30.718 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:30.734 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:30.737 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:30.765 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:30.788 UTC] Adding objective
[ Info: [2026-09-08T20:38:30.801 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:30.802 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:30.821 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:30.824 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:30.828 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:30.860 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:30.861 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:30.863 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:30.872 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:30.875 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:30.887 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:30.890 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:30.893 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:30.896 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:30.898 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:30.900 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:30.901 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:30.904 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:30.911 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-08T20:38:31.088 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:31.462 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:31.502 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:31.598 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:31.605 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:31.605 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:31.609 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:31.610 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:31.613 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:31.614 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:31.614 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:31.614 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:31.614 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:31.614 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:31.615 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:31.615 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:31.615 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:32.061 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:32.061 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:32.076 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:32.079 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:32.109 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:32.133 UTC] Adding objective
[ Info: [2026-09-08T20:38:32.146 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:32.147 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:32.168 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:32.170 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:32.174 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:32.210 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:32.211 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:32.213 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:32.222 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:32.226 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:32.239 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:32.242 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:32.245 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:32.249 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:32.251 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:32.254 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:32.254 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:32.257 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:32.265 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-08T20:38:32.442 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:32.807 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:32.846 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:32.941 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:32.948 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:32.948 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:32.953 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:32.953 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:32.956 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:32.957 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:32.957 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:32.958 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:33.398 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:33.399 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:33.414 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:33.418 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:33.446 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:33.470 UTC] Adding objective
[ Info: [2026-09-08T20:38:33.482 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:33.483 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:33.503 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:33.505 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:33.509 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:33.543 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:33.544 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:33.546 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:33.554 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:33.557 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:33.569 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:33.572 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:33.575 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:33.578 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:33.580 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:33.582 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:33.583 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:33.586 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:33.593 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-08T20:38:33.767 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:34.128 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:34.168 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:34.262 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:34.269 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:34.269 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:34.273 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:34.274 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:34.276 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:34.278 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:34.719 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:34.720 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:34.735 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:34.739 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:34.767 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:34.791 UTC] Adding objective
[ Info: [2026-09-08T20:38:34.804 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:34.805 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:34.825 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:34.828 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:34.832 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:34.865 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:34.866 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:34.868 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:34.876 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:34.880 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:34.891 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:34.895 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:34.898 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:34.901 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:34.903 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:34.905 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:34.905 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:34.908 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:34.916 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-08T20:38:35.088 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:35.445 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:35.484 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:35.576 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:35.582 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:35.583 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:35.587 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:35.588 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:35.590 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:35.592 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:35.593 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:35.593 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:36.023 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:36.023 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:36.038 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:36.042 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:36.069 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:36.091 UTC] Adding objective
[ Info: [2026-09-08T20:38:36.103 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:36.105 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:36.123 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:36.125 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:36.129 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:36.159 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:36.160 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:36.162 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:36.170 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:36.173 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:36.184 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:36.188 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:36.190 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:36.193 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:36.195 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:36.197 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:36.198 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:36.200 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:36.207 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-08T20:38:36.376 UTC] Creating EnergyProblem internal tables
[ Info: [2026-09-08T20:38:36.733 UTC] Computing variable indices
[ Info: [2026-09-08T20:38:36.772 UTC] Computing constraint indices
[ Info: [2026-09-08T20:38:36.866 UTC] Preparing profiles
[ Info: [2026-09-08T20:38:36.873 UTC] Creating optimization model
[ Info: [2026-09-08T20:38:36.873 UTC] Preparing optimization model
[ Info: [2026-09-08T20:38:36.877 UTC] Preparing model parameters
[ Info: [2026-09-08T20:38:36.878 UTC] Adding flow variables
[ Info: [2026-09-08T20:38:36.881 UTC] Adding vintage flow variables
[ Info: [2026-09-08T20:38:36.882 UTC] Adding investment variables
[ Info: [2026-09-08T20:38:36.882 UTC] Adding decommission variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding unit commitment variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding start-up and shut-down variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding power flow variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding storage variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding conditional value-at-risk variables
[ Info: [2026-09-08T20:38:36.883 UTC] Adding expressions to constraints
[ Info: [2026-09-08T20:38:37.320 UTC] Creating model expressions
[ Info: [2026-09-08T20:38:37.320 UTC] Creating multi-year expressions
[ Info: [2026-09-08T20:38:37.335 UTC] Adding storage expressions
[ Info: [2026-09-08T20:38:37.338 UTC] Preparing objective tables
[ Info: [2026-09-08T20:38:37.365 UTC] Adding operational cost expressions
[ Info: [2026-09-08T20:38:37.387 UTC] Adding objective
[ Info: [2026-09-08T20:38:37.399 UTC] Adding scenario tail excess expressions
[ Info: [2026-09-08T20:38:37.400 UTC] Adding capacity constraints
[ Info: [2026-09-08T20:38:37.418 UTC] Adding energy constraints
[ Info: [2026-09-08T20:38:37.421 UTC] Adding consumer constraints
[ Info: [2026-09-08T20:38:37.425 UTC] Adding storage constraints
[ Info: [2026-09-08T20:38:37.454 UTC] Adding conversion constraints
[ Info: [2026-09-08T20:38:37.455 UTC] Adding transport constraints
[ Info: [2026-09-08T20:38:37.457 UTC] Adding investment group constraints
[ Info: [2026-09-08T20:38:37.465 UTC] Adding available asset units constraints
[ Info: [2026-09-08T20:38:37.468 UTC] Adding ramping constraints
[ Info: [2026-09-08T20:38:37.479 UTC] Adding minimum output flow constraints
[ Info: [2026-09-08T20:38:37.482 UTC] Adding flow relationship constraints
[ Info: [2026-09-08T20:38:37.485 UTC] Adding DC power flow constraints
[ Info: [2026-09-08T20:38:37.487 UTC] Adding vintage flow sum constraints
[ Info: [2026-09-08T20:38:37.489 UTC] Adding unit commitment logic constraints
[ Info: [2026-09-08T20:38:37.491 UTC] Adding scenario tail excess constraints
[ Info: [2026-09-08T20:38:37.492 UTC] Adding minimum up time constraints
[ Info: [2026-09-08T20:38:37.494 UTC] Adding minimum down time constraints
[ Info: [2026-09-08T20:38:37.501 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.