Tutorials
Here are some tutorials on how to use Tulipa.
Basic example
For our first example, let's use a tiny existing dataset. Inside the code for this package, you can find the folder test/inputs/Tiny
, which includes all the files necessary to create a model and solve it.
The files inside the "Tiny" folder define the assets and flows data, their profiles, and their time resolution, as well as define the representative periods and which periods in the full problem formulation they represent.¹
For more details about these files, see Input.
¹ Ignore bad-assets-data.csv, which is used for testing.
Run scenario
To read all data from the Tiny folder, perform all necessary steps to create a model, and solve the model, run the following in a Julia terminal:
using TulipaEnergyModel
# input_dir should be the path to Tiny as a string (something like "test/inputs/Tiny")
energy_problem = run_scenario(input_dir)
EnergyProblem:
- Time for reading the data (in seconds): 0.097166277
- Model created!
- Time for creating the model (in seconds): 0.296669415
- Number of variables: 364
- Number of constraints for variable bounds: 364
- Number of structural constraints: 432
- Model solved!
- Time for solving the model (in seconds): 0.710980233
- Termination status: OPTIMAL
- Objective value: 269238.4382375954
The energy_problem
variable is of type EnergyProblem
. For more details, see the documentation for that type or the section Structures.
That's all it takes to run a scenario! To learn about the data required to run your own scenario, see the Input section of How to Use.
Manually running each step
If we need more control, we can create the energy problem first, then the optimization model inside it, and finally ask for it to be solved.
using TulipaEnergyModel
# input_dir should be the path to Tiny
energy_problem = create_energy_problem_from_csv_folder(input_dir)
EnergyProblem:
- Time for reading the data (in seconds): NaN
- Model not created!
- Model not solved!
The energy problem does not have a model yet:
energy_problem.model === nothing
true
To create the internal model, we call the function create_model!
.
create_model!(energy_problem)
energy_problem.model
A JuMP Model
Minimization problem with:
Variables: 364
Objective function type: JuMP.AffExpr
`JuMP.AffExpr`-in-`MathOptInterface.EqualTo{Float64}`: 72 constraints
`JuMP.AffExpr`-in-`MathOptInterface.LessThan{Float64}`: 360 constraints
`JuMP.VariableRef`-in-`MathOptInterface.GreaterThan{Float64}`: 364 constraints
`JuMP.VariableRef`-in-`MathOptInterface.LessThan{Float64}`: 1 constraint
`JuMP.VariableRef`-in-`MathOptInterface.Integer`: 4 constraints
Model mode: AUTOMATIC
CachingOptimizer state: NO_OPTIMIZER
Solver name: No optimizer attached.
Names registered in the model: assets_investment, assets_profile_times_capacity_in, assets_profile_times_capacity_out, consumer_balance, conversion_balance, energy_limit, flow, flows_investment, hub_balance, incoming_flow_highest_in_out_resolution, incoming_flow_highest_in_resolution, incoming_flow_lowest_resolution, incoming_flow_lowest_storage_resolution_intra_rp, incoming_flow_storage_inter_rp_balance, max_input_flows_limit, max_output_flows_limit, max_storage_level_inter_rp_limit, max_storage_level_intra_rp_limit, max_transport_flow_limit, min_storage_level_inter_rp_limit, min_storage_level_intra_rp_limit, min_transport_flow_limit, outgoing_flow_highest_in_out_resolution, outgoing_flow_highest_out_resolution, outgoing_flow_lowest_resolution, outgoing_flow_lowest_storage_resolution_intra_rp, outgoing_flow_storage_inter_rp_balance, storage_level_inter_rp, storage_level_intra_rp
The model has not been solved yet, which can be verified through the solved
flag inside the energy problem:
energy_problem.solved
false
Finally, we can solve the model:
solution = solve_model!(energy_problem)
TulipaEnergyModel.Solution(Dict(:ocgt => 1.1368683772161603e-15, :wind => 35.0, :ccgt => 2.0, :solar => 44.99999994237288), Dict{Tuple{Symbol, Symbol}, Float64}(), Float64[], Float64[], [0.0, 1.1368683772161603e-13, 1.1368683772161603e-12, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 … 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 269238.4382388837, Dict(:hub_balance => [], :consumer_balance => [3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004 … 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125]))
The solution is included in the individual assets and flows, but for completeness, we return the full solution
object, also defined in the Structures section.
In particular, the objective value and the termination status are also included in the energy problem:
energy_problem.objective_value, energy_problem.termination_status
(269238.4382375954, MathOptInterface.OPTIMAL)
Manually creating all structures without EnergyProblem
For additional control, it might be desirable to use the internal structures of EnergyProblem
directly. This can be error-prone, but it is slightly more efficient. The full description for these structures can be found in Structures.
using TulipaEnergyModel
# input_dir should be the path to Tiny
graph, representative_periods, timeframe = create_graph_and_representative_periods_from_csv_folder(input_dir)
(Meta graph based on a Graphs.SimpleGraphs.SimpleDiGraph{Int64} with vertex labels of type Symbol, vertex metadata of type GraphAssetData, edge metadata of type GraphFlowData, graph metadata given by nothing, and default weight nothing, RepresentativePeriod[RepresentativePeriod(Dict(352 => 1.0, 312 => 1.0, 81 => 1.0, 341 => 1.0, 141 => 1.0, 32 => 1.0, 291 => 1.0, 31 => 1.0, 282 => 1.0, 272 => 1.0…), 73.0, 1:24, 1.0), RepresentativePeriod(Dict(56 => 1.0, 35 => 1.0, 6 => 1.0, 67 => 1.0, 234 => 1.0, 215 => 1.0, 73 => 1.0, 164 => 1.0, 115 => 1.0, 153 => 1.0…), 182.5, 1:24, 1.0), RepresentativePeriod(Dict(110 => 1.0, 60 => 1.0, 220 => 1.0, 30 => 1.0, 268 => 1.0, 308 => 1.0, 219 => 1.0, 319 => 1.0, 320 => 1.0, 348 => 1.0…), 109.5, 1:24, 1.0)], Timeframe(365, 366×3 DataFrame
Row │ period rep_period weight
│ Int64 Int64 Float64
─────┼─────────────────────────────
1 │ 1 1 1.0
2 │ 2 1 1.0
3 │ 3 2 1.0
4 │ 4 2 1.0
5 │ 5 2 1.0
6 │ 6 2 1.0
7 │ 7 2 1.0
8 │ 8 3 1.0
⋮ │ ⋮ ⋮ ⋮
360 │ 360 3 1.0
361 │ 361 1 1.0
362 │ 362 2 1.0
363 │ 363 2 1.0
364 │ 364 3 1.0
365 │ 365 2 0.5
366 │ 365 3 0.5
351 rows omitted))
We also need a time partition for the constraints to create the model. Creating an energy problem automatically computes this data, but since we are doing it manually, we need to calculate it ourselves.
constraints_partitions = compute_constraints_partitions(graph, representative_periods)
Dict{Symbol, Dict{Tuple{Symbol, Int64}, Vector{UnitRange{Int64}}}} with 5 entries:
:lowest => Dict((:ccgt, 3)=>[1:1, 2:2, 3:3, 4:4, 5:5, …
:highest_in => Dict()
:lowest_storage_level_intra_rp => Dict()
:highest_in_out => Dict((:demand, 1)=>[1:1, 2:2, 3:3, 4:4, 5:5…
:highest_out => Dict((:ccgt, 3)=>[1:1, 2:2, 3:3, 4:4, 5:5, …
The constraints_partitions
has two dictionaries with the keys :lowest_resolution
and :highest_resolution
. The lowest resolution dictionary is mainly used to create the constraints for energy balance, whereas the highest resolution dictionary is mainly used to create the capacity constraints in the model.
Finally, we also need dataframes that store the linearized indexes of the variables.
dataframes = construct_dataframes(graph, representative_periods, constraints_partitions, timeframe)
Dict{Symbol, DataFrames.DataFrame} with 7 entries:
:lowest => 360×4 DataFrame…
:highest_in => 0×4 DataFrame…
:lowest_storage_level_intra_rp => 0×4 DataFrame…
:highest_in_out => 72×4 DataFrame…
:highest_out => 360×4 DataFrame…
:flows => 360×6 DataFrame…
:storage_level_inter_rp => 0×3 DataFrame…
Now we can compute the model.
model = create_model(graph, representative_periods, dataframes, timeframe)
A JuMP Model
Minimization problem with:
Variables: 364
Objective function type: JuMP.AffExpr
`JuMP.AffExpr`-in-`MathOptInterface.EqualTo{Float64}`: 72 constraints
`JuMP.AffExpr`-in-`MathOptInterface.LessThan{Float64}`: 360 constraints
`JuMP.VariableRef`-in-`MathOptInterface.GreaterThan{Float64}`: 364 constraints
`JuMP.VariableRef`-in-`MathOptInterface.LessThan{Float64}`: 1 constraint
`JuMP.VariableRef`-in-`MathOptInterface.Integer`: 4 constraints
Model mode: AUTOMATIC
CachingOptimizer state: NO_OPTIMIZER
Solver name: No optimizer attached.
Names registered in the model: assets_investment, assets_profile_times_capacity_in, assets_profile_times_capacity_out, consumer_balance, conversion_balance, energy_limit, flow, flows_investment, hub_balance, incoming_flow_highest_in_out_resolution, incoming_flow_highest_in_resolution, incoming_flow_lowest_resolution, incoming_flow_lowest_storage_resolution_intra_rp, incoming_flow_storage_inter_rp_balance, max_input_flows_limit, max_output_flows_limit, max_storage_level_inter_rp_limit, max_storage_level_intra_rp_limit, max_transport_flow_limit, min_storage_level_inter_rp_limit, min_storage_level_intra_rp_limit, min_transport_flow_limit, outgoing_flow_highest_in_out_resolution, outgoing_flow_highest_out_resolution, outgoing_flow_lowest_resolution, outgoing_flow_lowest_storage_resolution_intra_rp, outgoing_flow_storage_inter_rp_balance, storage_level_inter_rp, storage_level_intra_rp
Finally, we can compute the solution.
solution = solve_model(model)
TulipaEnergyModel.Solution(Dict(:ocgt => 1.1368683772161603e-15, :wind => 35.0, :ccgt => 2.0, :solar => 44.99999994237288), Dict{Tuple{Symbol, Symbol}, Float64}(), Float64[], Float64[], [0.0, 1.1368683772161603e-13, 1.1368683772161603e-12, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 … 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 269238.4382388837, Dict(:hub_balance => [], :consumer_balance => [3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004 … 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125]))
or, if we want to store the flow
, storage_level_intra_rp
, and storage_level_inter_rp
optimal value in the dataframes:
solution = solve_model!(dataframes, model)
TulipaEnergyModel.Solution(Dict(:ocgt => 1.1368683772161603e-15, :wind => 35.0, :ccgt => 2.0, :solar => 44.99999994237288), Dict{Tuple{Symbol, Symbol}, Float64}(), Float64[], Float64[], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 … 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 269238.4382375954, Dict(:hub_balance => [], :consumer_balance => [3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004 … 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125]))
This solution
structure is the same as the one returned when using an EnergyProblem
.
Change optimizer and specify parameters
By default, the model is solved using the HiGHS optimizer (or solver). To change this, we can give the functions run_scenario
, solve_model
, or solve_model!
a different optimizer.
For instance, we run the GLPK optimizer below:
using TulipaEnergyModel, GLPK
energy_problem = run_scenario(input_dir, optimizer = GLPK.Optimizer)
EnergyProblem:
- Time for reading the data (in seconds): 0.003046825
- Model created!
- Time for creating the model (in seconds): 0.005618032
- Number of variables: 364
- Number of constraints for variable bounds: 364
- Number of structural constraints: 432
- Model solved!
- Time for solving the model (in seconds): 1.762546929
- Termination status: OPTIMAL
- Objective value: 269238.4382417078
or
using GLPK
solution = solve_model!(energy_problem, GLPK.Optimizer)
TulipaEnergyModel.Solution(Dict(:ocgt => 1.1368683772161603e-15, :wind => 35.0, :ccgt => 2.0, :solar => 44.99999994237288), Dict{Tuple{Symbol, Symbol}, Float64}(), Float64[], Float64[], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 … 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 269238.4382375952, Dict(:hub_balance => [], :consumer_balance => [3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004 … 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125]))
or
using GLPK
solution = solve_model(model, GLPK.Optimizer)
TulipaEnergyModel.Solution(Dict(:ocgt => 1.1368683772161603e-15, :wind => 35.0, :ccgt => 2.0, :solar => 44.99999994237288), Dict{Tuple{Symbol, Symbol}, Float64}(), Float64[], Float64[], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 … 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 269238.4382375952, Dict(:hub_balance => [], :consumer_balance => [3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004, 3.6500000000000004 … 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125, 9.125]))
Notice that, in any of these cases, we need to explicitly add the GLPK package ourselves and add using GLPK
before using GLPK.Optimizer
.
In any of these cases, default parameters for the GLPK
optimizer are used, which you can query using default_parameters
. You can pass a dictionary using the keyword argument parameters
to change the defaults. For instance, in the example below, we change the maximum allowed runtime for GLPK to be 1 seconds, which will most likely cause it to fail to converge in time.
using TulipaEnergyModel, GLPK
parameters = Dict("tm_lim" => 1)
energy_problem = run_scenario(input_dir, optimizer = GLPK.Optimizer, parameters = parameters)
energy_problem.termination_status
TIME_LIMIT::TerminationStatusCode = 12
For the complete list of parameters, check your chosen optimizer.
These parameters can also be passed via a file. See the read_parameters_from_file
function for more details.
Using the graph structure
Read about the graph structure in the Graph section first.
We will use the graph
created above for the "Tiny" dataset.
The first thing that we can do is access all assets. They are the labels of the graph and can be accessed via the MetaGraphsNext API:
using MetaGraphsNext
# Accessing assets
labels(graph)
Base.Generator{Base.OneTo{Int64}, MetaGraphsNext.var"#20#21"{MetaGraphsNext.MetaGraph{Int64, Graphs.SimpleGraphs.SimpleDiGraph{Int64}, Symbol, GraphAssetData, GraphFlowData, Nothing, Nothing, Nothing}}}(MetaGraphsNext.var"#20#21"{MetaGraphsNext.MetaGraph{Int64, Graphs.SimpleGraphs.SimpleDiGraph{Int64}, Symbol, GraphAssetData, GraphFlowData, Nothing, Nothing, Nothing}}(Meta graph based on a Graphs.SimpleGraphs.SimpleDiGraph{Int64} with vertex labels of type Symbol, vertex metadata of type GraphAssetData, edge metadata of type GraphFlowData, graph metadata given by nothing, and default weight nothing), Base.OneTo(6))
Notice that the result is a generator, so if we want the actual results, we have to collect it:
labels(graph) |> collect
6-element Vector{Symbol}:
:ocgt
:ccgt
:wind
:solar
:ens
:demand
To access the asset data, we can index the graph with an asset label:
graph[:ocgt]
GraphAssetData(:producer, true, true, 25.0, missing, 100.0, 0.0, 0.0, false, 0.0, 0.0, 0.0, 0.0, Dict{Symbol, Vector{Float64}}(), Dict{Tuple{Symbol, Int64}, Vector{Float64}}(), UnitRange{Int64}[], Dict{Int64, Vector{UnitRange{Int64}}}(2 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24], 3 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24], 1 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24]), -1.0, Dict{Tuple{Int64, UnitRange{Int64}}, Float64}(), Dict{UnitRange{Int64}, Float64}())
This is a Julia struct, or composite type, named GraphAssetData. We can access its fields with .
:
graph[:ocgt].type
:producer
Since labels
returns a generator, we can iterate over its contents without collecting it into a vector.
for a in labels(graph)
println("Asset $a has type $(graph[a].type)")
end
Asset ocgt has type producer
Asset ccgt has type producer
Asset wind has type producer
Asset solar has type producer
Asset ens has type producer
Asset demand has type consumer
To get all flows we can use edge_labels
:
edge_labels(graph) |> collect
5-element Vector{Tuple{Symbol, Symbol}}:
(:ocgt, :demand)
(:ccgt, :demand)
(:wind, :demand)
(:solar, :demand)
(:ens, :demand)
To access the flow data, we index with graph[u, v]
:
graph[:ocgt, :demand]
GraphFlowData(:electricity, true, false, false, false, 0.07, 0.0, missing, 0.0, 0.0, 0.0, 0.0, Dict{Symbol, Vector{Float64}}(), Dict{Tuple{Symbol, Int64}, Vector{Float64}}(), UnitRange{Int64}[], Dict{Int64, Vector{UnitRange{Int64}}}(2 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24], 3 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24], 1 => [1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10 … 15:15, 16:16, 17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24]), Dict{Tuple{Int64, UnitRange{Int64}}, Float64}(), -1.0)
The type of the flow struct is GraphFlowData.
We can easily find all assets v
for which a flow (a, v)
exists for a given asset a
(in this case, demand):
inneighbor_labels(graph, :demand) |> collect
5-element Vector{Symbol}:
:ocgt
:ccgt
:wind
:solar
:ens
Similarly, all assets u
for which a flow (u, a)
exists for a given asset a
(in this case, ocgt):
outneighbor_labels(graph, :ocgt) |> collect
1-element Vector{Symbol}:
:demand
Manipulating the solution
First, see the description of the solution object.
Let's consider the larger dataset "Norse" in this section. And let's talk about two ways to access the solution.
The solution returned by solve_model
The solution, as shown before, can be obtained when calling solve_model
or solve_model!
.
using TulipaEnergyModel
# input_dir should be the path to Norse
energy_problem = create_energy_problem_from_csv_folder(input_dir)
create_model!(energy_problem)
solution = solve_model!(energy_problem)
To create a traditional array in the order given by the investable assets, one can run
using MetaGraphsNext
graph = energy_problem.graph
[solution.assets_investment[a] for a in labels(graph) if graph[a].investable]
12-element Vector{Float64}:
1255.0
500.0
83.0
14708.0
1.7890356162559602e-5
0.0
80.0
67.0
0.0
163.0
33.0
0.0
To create a traditional array in the order given by the investable flows, one can run
[solution.flows_investment[(u, v)] for (u, v) in edge_labels(graph) if graph[u, v].investable]
3-element Vector{Float64}:
5.0
8.510447821994182
6.0
The solution.flow
, solution.storage_level_intra_rp
, and solution.storage_level_inter_rp
values are linearized according to the dataframes in the dictionary energy_problem.dataframes
with keys :flows
, :lowest_storage_level_intra_rp
, and :storage_level_inter_rp
, respectively. You need to query the data from these dataframes and then use the column index
to select the appropriate value.
To create a vector with all values of flow
for a given (u, v)
and rp
, one can run
(u, v) = first(edge_labels(graph))
rp = 1
df = filter(
row -> row.rp == rp && row.from == u && row.to == v,
energy_problem.dataframes[:flows],
view = true,
)
[solution.flow[row.index] for row in eachrow(df)]
168-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
⋮
5386.687839696075
6583.403257693528
10502.25248910553
9977.343240986818
7756.245816043869
3186.1304959668196
0.0
0.0
0.0
To create a vector with the all values of storage_level_intra_rp
for a given a
and rp
, one can run
a = energy_problem.dataframes[:lowest_storage_level_intra_rp].asset[1]
rp = 1
df = filter(
row -> row.asset == a && row.rp == rp,
energy_problem.dataframes[:lowest_storage_level_intra_rp],
view = true,
)
[solution.storage_level_intra_rp[row.index] for row in eachrow(df)]
168-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
-0.0
2089.9999999999995
8075.0
⋮
40005.65821031217
33075.76004431899
22020.7574242079
11518.290854748093
3353.8215747019153
0.0
0.0
0.0
0.0
To create a vector with the all values of storage_level_inter_rp
for a given a
, one can run
a = energy_problem.dataframes[:storage_level_inter_rp].asset[1]
df = filter(
row -> row.asset == a,
energy_problem.dataframes[:storage_level_inter_rp],
view = true,
)
[solution.storage_level_inter_rp[row.index] for row in eachrow(df)]
52-element Vector{Float64}:
25084.0
25167.999999999996
25251.999999999993
25335.999999999993
25419.99999999999
25503.999999999985
25587.999999999978
25671.999999999975
25755.99999999997
25839.999999999967
⋮
28978.947368421064
29074.736842105278
29158.736842105278
29242.736842105278
29326.736842105278
29410.736842105278
29494.736842105278
29578.736842105278
29674.736842105278
The solution inside the graph
In addition to the solution object, the solution is also stored by the individual assets and flows when solve_model!
is called (i.e., when using an EnergyProblem object).
They can be accessed like any other value from GraphAssetData or GraphFlowData, which means that we recreate the values from the previous section in a new way:
[energy_problem.graph[a].investment for a in labels(graph) if graph[a].investable]
12-element Vector{Float64}:
1255.0
500.0
83.0
14708.0
1.7890356162559602e-5
0.0
80.0
67.0
0.0
163.0
33.0
0.0
[energy_problem.graph[u, v].investment for (u, v) in edge_labels(graph) if graph[u, v].investable]
3-element Vector{Float64}:
5.0
8.510447821994182
6.0
(u, v) = first(edge_labels(graph))
rp = 1
df = filter(
row -> row.rp == rp && row.from == u && row.to == v,
energy_problem.dataframes[:flows],
view = true,
)
[energy_problem.graph[u, v].flow[(rp, row.timesteps_block)] for row in eachrow(df)]
168-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
⋮
5386.687839696075
6583.403257693528
10502.25248910553
9977.343240986818
7756.245816043869
3186.1304959668196
0.0
0.0
0.0
To create a vector with all the values of storage_level_intra_rp
for a given a
and rp
, one can run
a = energy_problem.dataframes[:lowest_storage_level_intra_rp].asset[1]
rp = 1
df = filter(
row -> row.asset == a && row.rp == rp,
energy_problem.dataframes[:lowest_storage_level_intra_rp],
view = true,
)
[energy_problem.graph[a].storage_level_intra_rp[(rp, row.timesteps_block)] for row in eachrow(df)]
168-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
-0.0
2089.9999999999995
8075.0
⋮
40005.65821031217
33075.76004431899
22020.7574242079
11518.290854748093
3353.8215747019153
0.0
0.0
0.0
0.0
To create a vector with all the values of storage_level_inter_rp
for a given a
, one can run
a = energy_problem.dataframes[:storage_level_inter_rp].asset[1]
df = filter(
row -> row.asset == a,
energy_problem.dataframes[:storage_level_inter_rp],
view = true,
)
[energy_problem.graph[a].storage_level_inter_rp[row.periods_block] for row in eachrow(df)]
52-element Vector{Float64}:
25084.0
25167.999999999996
25251.999999999993
25335.999999999993
25419.99999999999
25503.999999999985
25587.999999999978
25671.999999999975
25755.99999999997
25839.999999999967
⋮
28978.947368421064
29074.736842105278
29158.736842105278
29242.736842105278
29326.736842105278
29410.736842105278
29494.736842105278
29578.736842105278
29674.736842105278
The solution inside the dataframes object
In addition to being stored in the solution
object, and in the graph
object, the solution for the flow
, storage_level_intra_rp
, and storage_level_inter_rp
is also stored inside the corresponding DataFrame objects if solve_model!
is called.
The code below will do the same as in the two previous examples:
(u, v) = first(edge_labels(graph))
rp = 1
df = filter(
row -> row.rp == rp && row.from == u && row.to == v,
energy_problem.dataframes[:flows],
view = true,
)
df.solution
168-element view(::Vector{Float64}, 1:168) with eltype Float64:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
⋮
5386.687839696075
6583.403257693528
10502.25248910553
9977.343240986818
7756.245816043869
3186.1304959668196
0.0
0.0
0.0
a = energy_problem.dataframes[:storage_level_inter_rp].asset[1]
df = filter(
row -> row.asset == a,
energy_problem.dataframes[:storage_level_inter_rp],
view = true,
)
df.solution
52-element view(::Vector{Float64}, 1:52) with eltype Float64:
25084.0
25167.999999999996
25251.999999999993
25335.999999999993
25419.99999999999
25503.999999999985
25587.999999999978
25671.999999999975
25755.99999999997
25839.999999999967
⋮
28978.947368421064
29074.736842105278
29158.736842105278
29242.736842105278
29326.736842105278
29410.736842105278
29494.736842105278
29578.736842105278
29674.736842105278
a = energy_problem.dataframes[:lowest_storage_level_intra_rp].asset[1]
rp = 1
df = filter(
row -> row.asset == a && row.rp == rp,
energy_problem.dataframes[:lowest_storage_level_intra_rp],
view = true,
)
df.solution
168-element view(::Vector{Float64}, 8:175) with eltype Float64:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
-0.0
2089.9999999999995
8075.0
⋮
40005.65821031217
33075.76004431899
22020.7574242079
11518.290854748093
3353.8215747019153
0.0
0.0
0.0
0.0
Values of constraints and expressions
By accessing the model directly, we can query the values of constraints and expressions. We need to know the name of the constraint and how it is indexed, and for that, you will need to check the model.
For instance, we can get all incoming flows in the lowest resolution for a given asset for a given representative period with the following:
using JuMP
a = energy_problem.dataframes[:lowest].asset[end]
rp = 1
df = filter(
row -> row.asset == a && row.rp == rp,
energy_problem.dataframes[:lowest],
view = true,
)
[value(energy_problem.model[:incoming_flow_lowest_resolution][row.index]) for row in eachrow(df)]
168-element Vector{Float64}:
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
⋮
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
The values of constraints can also be obtained, however, they are frequently indexed in a subset, which means that their indexing is not straightforward. To know how they are indexed, it is necessary to look at the model code. For instance, to get the consumer balance, we first need to filter the :highest_in_out
dataframes by consumers:
df_consumers = filter(
row -> graph[row.asset].type == :consumer,
energy_problem.dataframes[:highest_in_out],
view = false,
);
We set view = false
to create a copy of this DataFrame so we can make our indexes:
df_consumers.index = 1:size(df_consumers, 1) # overwrites existing index
1:960
Now we can filter this DataFrame. Note that the names in the stored dataframes are defined as Symbol.
a = :Asgard_E_demand
df = filter(
row -> row.asset == a && row.rp == rp,
df_consumers,
view = true,
)
value.(energy_problem.model[:consumer_balance][df.index])
168-element Vector{Float64}:
33951.9255800345
32339.440666441926
30618.08007858144
29154.857719356212
28466.28865613107
27703.76592365483
26184.454469129858
26463.520980592206
27178.204198305513
29616.349838426668
⋮
60971.61327059055
61868.328688588
65787.17792
65262.26867188129
63041.17124693834
58471.05592686129
54777.52475361511
52128.801264625705
46907.046645223265
Here value.
(i.e., broadcasting) was used instead of the vector comprehension from previous examples just to show that it also works.
The value of the constraint is obtained by looking only at the part with variables. So a constraint like 2x + 3y - 1 <= 4
would return the value of 2x + 3y
.
Writing the output to CSV
To save the solution to CSV files, you can use save_solution_to_file
:
mkdir("outputs")
save_solution_to_file("outputs", energy_problem)
Plotting
In the previous sections, we have shown how to create vectors such as the one for flows. If you want simple plots, you can plot the vectors directly using any package you like.
If you would like more custom plots, check out TulipaPlots.jl, under development, which provides tailor-made plots for TulipaEnergyModel.jl.
Hydrothermal Dispatch example
Under development!