Skip to main content

Expansion

nimopt.models.expansion is a two-stage stochastic program. The first stage builds capacity. The second stage dispatches it against a demand and a fuel price given by the scenario. The shape of the first-stage variable expresses the information available to it: cap is indexed by technology alone, and one capacity applies to every scenario. p and shed are indexed by scenario as well and may differ across it.

minimize Σ_g capital[g] · cap[g]
+ Σ_{s,g,t} weight[s] · cost[s,g] · p[s,g,t]
+ Σ_{s,t} weight[s] · voll[s] · shed[s,t]
subject to p[s,g,t] ≤ cap[g] for each s, g, t
Σ_g p[s,g,t] + shed[s,t] == demand[s,t] for each s, t
cap, p, shed ≥ 0

weight is the probability of a scenario. The second and third sums are therefore an expectation, and the objective is the committed capital plus the expected cost of the recourse. No row relates the capacity of one scenario to the capacity of another: the missing dimension expresses non-anticipativity.

from nimopt.models import expansion

print(expansion.definition().explain())
Output
expansion min not built
sets S · G · T
parameters capital (G) · cost (S,G) · demand (S,T) · weight (S) · voll (S)
variables cap (G) [0.0, inf] · p (S×G×T) [0.0, inf] · shed (S×T) [0.0, inf]
constraint capacity (S,G,T) p[S, G, T] - cap[G] <= 0
constraint balance (S,T) Sum(G, p[S, G, T]) + shed[S, T] == demand[S, T]
objective min Sum(G, capital[G] * cap[G]) + Sum(S, G, T, (weight[S] * cost[S, G]) * p[S, G, T]) + Sum(S, T, (weight[S] * voll[S]) * shed[S, T])

cap has one column per technology, and p has one per scenario, technology and hour. The capacity row is declared over all three dimensions, and the variable it bounds has one. The rows of every scenario read the same column, and the capacity is therefore a shared decision.

from nimopt.models import expansion

model = expansion.definition().build(expansion.data())
print(model.explain())
Output
expansion min 75 columns · 72 rows · 180 nonzeros
sets G 3 · S 3 · T 6
parameters demand (S,T) 18 · capital (G) 3 · weight (S) 3 · cost (S,G) 9 · voll (S) 3
variables cap (G) 3 cols [0.0, inf] · p (S×G×T) 54 cols [0.0, inf] · shed (S×T) 18 cols [0.0, inf]
constraint capacity (S,G,T) p[S, G, T] - cap[G] <= 0 54 rows 108 nz
constraint balance (S,T) Sum(G, p[S, G, T]) + shed[S, T] == demand[S, T] 18 rows 72 nz
objective min Sum(G, capital[G] * cap[G]) + Sum(S, G, T, (weight[S] * cost[S, G]) * p[S, G, T]) + Sum(S, T, (weight[S] * voll[S]) * shed[S, T])

A technology is available in full wherever it is built, and no row couples one hour to the next. The recourse in each scenario-hour is therefore the merit order of the built capacity against that demand, with the remainder unserved at voll. The capacity is written as bands: the band of the cheapest technology, then the next, and last the band between the most expensive technology and lost load. The total separates into one term per band. Each term is convex in the level of its band and changes slope only at a demand. reference minimizes the terms one at a time and adds them, and the result is arithmetic over the inputs, not a second solve.

from nimopt.models import expansion

inputs = expansion.data()
solution = expansion.definition().build(inputs).solve()
print(solution.objective, expansion.reference(inputs))
Output
29703.899999999994 29703.899999999994

The data describes a cost frontier: capital falls as marginal cost rises, and lost load is more expensive than the most expensive technology. All three technologies are built. The capacity stacks to 180 and leaves the peak hour of 225 in the cold scenario short. Shedding 45 costs less than a fourth band used in one hour of one scenario.

import numpy as np

from nimopt.models import expansion

inputs = expansion.data()
solution = expansion.definition().build(inputs).solve()
print(np.cumsum(np.asarray(solution.primal("cap").values())))
print(np.asarray(solution.primal("shed").values()).reshape(3, 6))
Output
[119. 153. 180.]
[[ 0. 0. 0. 0. 0. 0.]
[ 0. 0. 0. 0. 0. 0.]
[ 0. 0. 0. 45. 0. 0.]]

The separation applies only to data whose technologies form a frontier and whose bands stack. reference raises on any other data, and it returns no number that is not the optimum.

from nimopt.models import expansion

inputs = expansion.data()
inputs["capital"] = inputs["capital"][::-1]
print(expansion.reference(inputs))
Raises ValueError
ValueError: capital does not fall from base to what follows it; pass a capital cost that falls across the merit order