Several scenarios¶
So far every plan was built on a best guess: one forecast of tomorrow, treated as certain. But tomorrow may be calm or windy, and the prices move with it. Planning against several scenarios, each with a probability, gives you the plan that does best across all of them, instead of one that only works if the forecast is right.
From a best guess to several futures¶
Let's take the complete site, as the EnergyMarket page put it together, and replace its single forecast with two futures. Tomorrow is calm with probability 0.3, or windy with probability 0.7, and the prices on both markets follow the wind. The site bids on the day-ahead market the day before delivery, before the market clears: it doesn't know yet which future will happen, nor the prices its bids will clear at. Once it knows, it can rebalance on an intraday market:
from datetime import timedelta
from odys import (
AssetPortfolio,
AvailableCapacityProfile,
Battery,
EnergyMarket,
EnergySystem,
FixedLoad,
FlexibleLoad,
FlexibleLoadProfile,
Generator,
LoadProfile,
PriceProfile,
Scenario,
BatteryStorage,
StorageInitialState,
)
ccgt = Generator(name="ccgt", nominal_power=100.0, variable_cost=50.0)
wind = Generator(name="wind", nominal_power=80.0, variable_cost=0.0)
factory = FixedLoad(name="factory")
process = FlexibleLoad(name="process", value_of_consumption=60.0)
bess = BatteryStorage(
name="bess",
battery=Battery(
capacity=50.0,
max_charge_power=25.0,
max_discharge_power=25.0,
efficiency_charging=0.95,
efficiency_discharging=0.95,
),
)
day_ahead = EnergyMarket(name="day_ahead", max_trading_volume_per_step=150.0, stage_fixed=True)
intraday = EnergyMarket(name="intraday", max_trading_volume_per_step=50.0)
calm = Scenario(
name="calm",
probability=0.3,
profiles=(
LoadProfile(load=factory, values=[60, 90, 40, 70]),
FlexibleLoadProfile(load=process, lower_bound=[20, 20, 20, 20], upper_bound=[50, 50, 50, 50]),
AvailableCapacityProfile(generator=wind, values=[20, 5, 30, 15]),
PriceProfile(market=day_ahead, values=[45, 80, 35, 68]),
PriceProfile(market=intraday, values=[55, 90, 45, 70]),
),
)
windy = Scenario(
name="windy",
probability=0.7,
profiles=(
LoadProfile(load=factory, values=[60, 90, 40, 70]),
FlexibleLoadProfile(load=process, lower_bound=[20, 20, 20, 20], upper_bound=[50, 50, 50, 50]),
AvailableCapacityProfile(generator=wind, values=[60, 40, 80, 50]),
PriceProfile(market=day_ahead, values=[35, 65, 25, 55]),
PriceProfile(market=intraday, values=[30, 58, 20, 45]),
),
)
Each scenario holds a full set of profiles, and any of them can differ: here the wind and the prices of both markets do. Wind pushes prices down, so both markets are cheaper when it's windy. Then pass the list of scenarios where a single Scenario went before:
energy_system = EnergySystem(
portfolio=AssetPortfolio([ccgt, wind, bess, factory, process]),
markets=[day_ahead, intraday],
scenarios=[calm, windy],
timestep=timedelta(hours=1),
number_of_steps=4,
initial_conditions=(StorageInitialState(storage=bess, soc=0.5),),
)
result = energy_system.optimize()
print(result.markets.net_volume.unstack("scenario").round(2))
scenario calm windy
time market
0 day_ahead -4.09 -4.09
intraday 10.00 -50.00
1 day_ahead 70.00 70.00
intraday -50.00 -45.00
2 day_ahead -35.00 -35.00
intraday -50.00 0.00
3 day_ahead 100.00 100.00
intraday -50.00 -50.00
The day-ahead trades are the same in both scenarios: the site sends one set of bids before it knows which future happens. On its own, a calm day would buy day-ahead at steps 0 to 2, where day-ahead is cheaper than intraday, and a windy day would sell at steps 1 to 3. The one bid has to work for both, so it buys at steps 0 and 2 and sells at steps 1 and 3. Intraday, the site rebalances to the future that happens:
- Calm: intraday power is dear, so the CCGT runs at full output whenever intraday costs more than its 50 $/MWh, at steps 0, 1 and 3, and the site buys back on intraday the rest of what it sold day-ahead. At step 0, intraday pays 55 $/MWh, so the site sells 10 MW there too. At step 2 it costs only 45, so the CCGT stays off and the site buys the full 50 MW.
- Windy: the wind and cheap intraday power replace the CCGT: it's off at step 0, and the site buys on intraday at steps 0, 1 and 3. At step 2, the wind and the day-ahead purchase cover the site, so it doesn't trade intraday.
Scenario names have to be unique, and the probabilities have to add up to 1, within floating-point tolerance: 49 scenarios of 1 / 49 each are accepted. A generator without an AvailableCapacityProfile in a scenario can produce up to its nominal_power in that scenario.
What's decided before the uncertainty¶
The day-ahead bids go in before the market clears, and the wind and the prices are known only later. Everything you can still change once you know them adapts to each scenario. The decisions you can't wait on are the same in all of them:
| Decision | Same in every scenario |
|---|---|
Trades on a market with stage_fixed=True |
Yes: decided before the uncertainty |
| Trades on any other market | No |
| Generator output and on/off status | No |
| Battery and vehicle charging and discharging | No |
| Flexible load adjustments | No |
Mark a market stage_fixed when you commit to it before you know the outcome, like day-ahead; see Stage-fixed markets and NA-1. Initial and final conditions are the same in every scenario too, because they're inputs.
Three consequences to keep in mind:
- Without a stage-fixed market or a risk term, the scenarios don't interact. Each scenario gets the plan that's best for it alone, as if the optimizer knew in advance which future would happen. The result is the same as solving each one on its own. See When scenarios are solved on their own.
- Intraday is traded knowing the whole day. Once a scenario happens, its intraday trades and dispatch are planned with all of that scenario's wind and prices known. In practice, intraday is traded while forecasts keep improving, so the plan's intraday profit is optimistic. See Two stages.
- Generator commitment adapts to each scenario. The CCGT above is on at step 0 when it's calm and off when it's windy. A unit that has to be scheduled in advance isn't modelled as such today; see NA-1.
Results per scenario¶
With several scenarios, every series gains a scenario level. Select one scenario with pandas' xs:
print(result.generators.power.xs("windy", level="scenario"))
time generator
0 ccgt 0.0
wind 60.0
1 ccgt 100.0
wind 40.0
2 ccgt 0.0
wind 80.0
3 ccgt 95.0
wind 50.0
Name: power, dtype: float64
To get the expected value of a series, weigh each scenario by its probability:
import pandas as pd
probabilities = pd.Series({calm.name: calm.probability, windy.name: windy.probability})
print(result.generators.power.unstack("scenario").mul(probabilities).sum(axis=1).round(2))
time generator
0 ccgt 30.0
wind 48.0
1 ccgt 100.0
wind 29.5
2 ccgt 0.0
wind 65.0
3 ccgt 96.5
wind 39.5
dtype: float64
result.objective_value is the expected profit, weighted the same way. With a single scenario, the scenario level is dropped, as on every other page.
Next steps¶
The plan above maximizes the expected profit, so a bad calm day is fine as long as the windy days make up for it. To weigh bad outcomes more heavily, see Risk (CVaR).