Stochastic Programming

This example shows that investment capacities can be shared across two snapshots, while operation adapts to each future separately.

  • two snapshots in one simulation: High PV CF and Low PV CF
  • the same demand in both
  • an existing 100 MW CCGT fleet in both, with no new CCGT capacity
  • additional PV and battery capacity shared by both snapshots
  • a lower PV capacity factor in the Low PV CF snapshot

The two snapshots are solved together with equal probability, so the objective minimises their average cost.

This is a deliberately simplified example. The same framework can consider multiple weather time series or snapshots representing other uncertain events.

using Posy2
using Nosy
using HiGHS
import JuMP: @variable, set_silent

# Simulation and Posy2 input configuration
sim = Sim(Model(HiGHS.Optimizer); mesh=TimeMesh())
set_silent(model(sim))
opts = Dict(:posy => Posy2Options(
    tech_mode=:arguments,
    timeseries_mode=:arguments,
))
snap_high_cf = Snapshot(sim, opts)
snap_low_cf = Snapshot(sim, opts)

# Typical daily demand (daytime bump, evening peak)
day = [58.0, 55.0, 53.0, 52.0, 54.0, 60.0, 72.0, 82.0, 88.0, 86.0, 84.0, 82.0, 80.0, 78.0, 77.0, 80.0, 88.0, 96.0, 100.0, 97.0, 90.0, 78.0, 68.0, 62.0]
pv_high_cf = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.05, 0.15, 0.35, 0.6, 0.82, 0.95, 1.0, 0.95, 0.82, 0.6, 0.35, 0.15, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0]
pv_low_cf = 0.8 * pv_high_cf

# Shared PV and battery capacity decisions
@variable(uppermodel(sim), pv_cap >= 0)
@variable(uppermodel(sim), battery_cap >= 0)

# Build both cases
cases = (
    (name="High PV CF", snapshot=snap_high_cf, pv_profile=pv_high_cf),
    (name="Low PV CF", snapshot=snap_low_cf, pv_profile=pv_low_cf),
)
for case in cases
    elec = Node(case.name, EnergyCarrier("electricity $(case.name)", sim), rule=:curtailed, tags=[:electricity])
    co2 = Node("CO2", CO2Carrier("CO2 $(case.name)", sim), rule=:curtailed, tags=[:co2])

    makedemand("Demand", case.name, elec, case.snapshot; profile=repeat(day, 365))
    makeintermittentsource(
        "Solar", elec, case.snapshot; co2_node=co2, tech_column="PV",
        cap=pv_cap,
        profile=repeat(case.pv_profile, 365),
        overnight_cost=500.0,
        lifetime=25,
        construction_profile=1.0,
    )
    makedispatchable(
        "CCGT", elec, case.snapshot; co2_node=co2, tech_column="CCGT",
        cap=100.0,
        fuel_cost=50.0,
    )
    makebatterystorage(
        "Battery", elec, case.snapshot; tech_column="Battery",
        power_cap=battery_cap,
        roundtrip_eff=0.85,
        duration=4.0,
        overnight_cost=200.0,
        lifetime=15,
        construction_profile=1.0,
    )
end

# Minimise the equally weighted expected cost
optimize!([snap_high_cf, snap_low_cf], 0.5 * (cost(snap_high_cf) + cost(snap_low_cf)))
result_high_cf = extract(snap_high_cf)
result_low_cf = extract(snap_low_cf)

No new CCGT capacity can be built: both snapshots use the existing 100 MW fleet. Additional PV and battery capacity is allowed, and both investment decisions are shared across the snapshots.

The PV and battery builders share explicit JuMP variables; no extra coupling constraint is required. Alternatively, a builder can create a capacity decision in snap1 with cap=nothing, and a builder in snap2 can reuse its JuMP AffExpr:

makeintermittentsource(
    "PV", elec2, snap2; co2_node=co2_2, tech_column="PV",
    cap=capacity(snap1, "PV"),
    profile=pv2,
    overnight_cost=500.0,
    lifetime=25,
    construction_profile=1.0,
)

This sketch assumes the component in snap1 is named "PV". Use the complete generated component name in capacity. See Capacity Semantics for the values accepted by builders' cap arguments.

julia> capacity(result_high_cf, "CCGT High PV CF"), capacity(result_low_cf, "CCGT Low PV CF")
(100.0, 100.0)

julia> capacity(result_high_cf, "Solar High PV CF"), capacity(result_low_cf, "Solar Low PV CF")
(353.23242938710064, 353.23242938710064)

julia> capacity(result_high_cf, "Battery High PV CF"), capacity(result_low_cf, "Battery Low PV CF")
(226.5140341444653, 226.5140341444653)

The High PV CF snapshot needs no CCGT generation. In the Low PV CF snapshot, the existing CCGT fleet supplies the small amount of demand not met by PV and storage. The shared battery capacity shifts solar generation in both snapshots.

julia> balance(result_high_cf, "CCGT High PV CF", :output, energy; collapse=true, aggregate=true) / 1_000
-1.1681322575896046e-14

julia> balance(result_low_cf, "CCGT Low PV CF", :output, energy; collapse=true, aggregate=true) / 1_000
17.753661255809924

julia> balance(result_high_cf, "Battery High PV CF", :input, energy; collapse=true, aggregate=true) / 1_000
406.30948612660904

julia> balance(result_low_cf, "Battery Low PV CF", :input, energy; collapse=true, aggregate=true) / 1_000
393.0515188138517
High PV CFLow PV CF
CCGT capacity100 MW100 MW
PV capacity353 MW353 MW
Battery capacity227 MW227 MW
CCGT generation0 GWh18 GWh
Battery charging406 GWh393 GWh