PV, Battery, And Upward Reserve
Storage can provide upward reserve either by increasing discharge or by reducing charging. This example forces a combined upward reserve requirement of 600 MW.
using Nosy
using HiGHS
import JuMP: @constraint, set_silent # Avoid `using JuMP`; both JuMP and Nosy export optimize!.
s = Sim(Model(HiGHS.Optimizer); mesh=TimeMesh())
set_silent(model(s))
elec_carrier = EnergyCarrier("power", s)
# Synthetic data for load
hours = 1:8760
day_angle = 2pi .* ((hours .- 1) .% 24) ./ 24
season_angle = 2pi .* (hours .- 1) ./ 8760
load_profile = 3000 .+ 1500 .* sin.(day_angle .- pi / 2) .+
120 .* sin.(season_angle .- pi / 2)
# Synthetic data for PV
cf_pv = [
x < 1e-6 ? 0.0 : x for x in [
max(0, cos((h % 24 - 12) / 12 * pi) * (0.6 + 0.4 * sin(2pi * (h / 24) / 365)))
for h in 1:8760
]
]
# Snapshot initialisation
snapshot = Snapshot(s)
# One electricity node
grid = Node("grid", elec_carrier, rule=:curtailed)
# Component: electricity consumption.
consumption = Component("consumption", Demand(elec_carrier, load_profile))
connect!(snapshot, consumption, grid)
# Component: PV.
pv = Component(
"PV",
ProfileSource(elec_carrier, cf_pv),
[
VariableCapacity("output", energy),
FixedCost(:capex, "output", energy, 50_000.0),
],
)
connect!(snapshot, pv, grid)
# Component: battery with fixed capacities and ramping.
# ReserveUp on output with :up means more discharge.
# ReserveUp on input with :down means less charging.
battery = Component(
"battery",
BasicStorage(elec_carrier, elec_carrier, elec_carrier, energy; eff_i=0.85),
[
FixedCapacity("output", energy, 5_000.0),
FixedCapacity("input", energy, 5_000.0),
FixedCapacity("level", energy, 30_000.0),
Ramping("output", :up, 5_000.0; modifier=energy),
Ramping("output", :down, 5_000.0; modifier=energy),
Ramping("input", :up, 5_000.0; modifier=energy),
Ramping("input", :down, 5_000.0; modifier=energy),
ReserveUp("reserve_up_discharge_15min", "output", :up, 0.25; modifier=energy),
ReserveUp("reserve_up_charge_15min", "input", :down, 0.25; modifier=energy),
],
)
connect!(snapshot, battery, grid)
# Minimum combined upward reserve at node "grid" (600 MW per timestep).
@constraint(
model(sim(snapshot)),
reserve(snapshot, "grid", :up, "reserve_up_discharge_15min").data .+
reserve(snapshot, "grid", :up, "reserve_up_charge_15min").data .>= 600.0,
)
# Optimisation
optimize!(snapshot, cost(snapshot))
result = extract(snapshot)When the level is high, discharge upward reserve often supplies the 600 MW. When it is low, more reserve shifts to charge reduction. Only the battery provides these reserves here, so totals at the grid match the battery.
julia> balance(result, "battery", :level, energy, collapse=false, aggregate=true)
8760-element Nosy.Hourly{Float64}:
8942.300011154877
7536.744365437882
6030.707710824039
4330.556648912984
2355.886348958197
0.0
0.0
720.4430510088084
150.0
150.0
⋮
19321.523156402218
23340.120236061968
23599.602275640373
20525.4866799432
17839.600022309754
15528.713673351558
13554.043373396771
11853.892311485715
10347.855656871872
julia> reserve(result, "battery", :up, "reserve_up_discharge_15min")
8760-element Nosy.Stepwise{Float64}:
600.0
600.0
600.0
600.0
600.0
0.0
0.0
600.0
600.0
600.0
⋮
600.0
55.52254037588318
600.0
600.0
600.0
600.0
600.0
600.0
600.0
julia> reserve(result, "battery", :up, "reserve_up_charge_15min")
8760-element Nosy.Stepwise{Float64}:
0.0
0.0
0.0
0.0
0.0
600.0
600.0
0.0
0.0
0.0
⋮
0.0
544.4774596241168
0.0
0.0
0.0
0.0
0.0
0.0
0.0The battery can satisfy the same upward reserve requirement through different physical actions depending on its state of charge.