Extracting results: Part I
This tutorial showcases how results can be extracted, including how user defined templates are able to create new result calculations.
import iesopt
config_file = iesopt.make_example(
"48_custom_results", dst_dir="ex_custom_results", dst_name="config"
)
INFO:iesopt:Data folder for examples already exists; NOT copying ANY contents
INFO:iesopt:Creating example ('48_custom_results') at: 'ex_custom_results/config.iesopt.yaml'
INFO:iesopt:Set write permissions for example ('ex_custom_results/config.iesopt.yaml'), and data folder ('ex_custom_results/files')
model = iesopt.run(config_file, verbosity=False)
assert model.status == iesopt.ModelStatus.OPTIMAL
Accessing model results: Objectives
The objective of your model - after a successful solve - can be extracted using:
model.objective_value
981.1745152354571
It may however be the case, that you have registered multiple objective functions (which can be used for multi-objective
algorithms, or even be useful just for analysis purposes). You can get their value by their name. The default objective
is always called total_cost
and is the only one guaranteed to always exist. We can check which objectives are registered:
model.results.objectives.keys()
dict_keys(['total_cost'])
And get the value of total_cost
(which should match the one obtained from model.objective_value
for this example):
model.results.objectives["total_cost"]
981.1745152354571
Accessing model results: Variables
Three different ways to access the results of our custom storage component storage
:
Direct access
model.results.components["storage"].res.setpoint
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
Accessing dual information:
model.results.components["grid"].con.nodalbalance__dual
Access using get(...)
model.results.get("component", "storage", "res", "setpoint")
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
Accessing dual information:
model.results.get("component", "grid", "con", "nodalbalance", mode="dual")
Collective results
Using to_dict(...)
results = model.results.to_dict()
results[("storage", "res", "setpoint")]
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
Accessing dual information:
results[("grid", "con", "nodalbalance__dual")]
Note: You can filter the results returned by
to_dict(...)
in exactly the same way as when usingto_pandas(...)
( see below). However, this is uncommon to be useful, since you most likely want to work with tabular data anyways when using the filter function, which is why we skip it here.
Using to_pandas(...)
df = model.results.to_pandas()
df.loc[
(
(df["component"] == "storage")
& (df["fieldtype"] == "res")
& (df["field"] == "setpoint")
& (df["mode"] == "primal")
),
"value",
].values
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
series = model.results.to_pandas(
lambda c, t, f: c == "storage" and t == "res" and f == "setpoint"
)
series.head()
t1 -3.656510
t2 3.473684
t3 -0.997230
t4 0.947368
t5 0.000000
Name: (storage, res, setpoint), dtype: float64
We could actually only filter for the component (c == "storage"
), since this is the only result that it creates.
series = model.results.to_pandas(lambda c, t, f: c == "storage")
series.head()
t1 -3.656510
t2 3.473684
t3 -0.997230
t4 0.947368
t5 0.000000
Name: (storage, res, setpoint), dtype: float64
This may however be dangerous, since a similar call
model.results.to_pandas(lambda c,t,f: c == "grid")
would then suddenly return a pd.DataFrame
, since it contains two different results (try it out!) linked to the
component grid
.
Accessing dual information (part I):
model.results.to_pandas(lambda c,t,f: c == "grid" and t == "con")
This works, since the model only contains a single result linked to constraints of the component
grid
. However, this may again be dangerous, which is why you could instead make use of something likedf = model.results.to_pandas() df[df["mode"] == "dual"]
Note that this extracts ALL dual results, not only those for the component used above, but again
to_pandas(...)
is mostly there to extract more than one result at the same time (we cover “Which way should I use below?”).
You may now wonder - since it all looks the same - what to_pandas(...)
could be useful for. It’s main usage is
extracting more than one result in one call:
df = model.results.to_pandas(field_types="exp", orientation="wide")
df.head(5)
storage.storage | demand | grid | generator | |
---|---|---|---|---|
exp | exp | exp | exp | |
injection | value | injection | out_electricity | |
t1 | 3.473684 | 4.0 | 4.440892e-16 | 7.65651 |
t2 | -3.473684 | 4.0 | -1.110223e-16 | 0.70000 |
t3 | 0.947368 | 4.0 | 2.220446e-16 | 4.99723 |
t4 | -0.947368 | 4.0 | 0.000000e+00 | 3.10000 |
t5 | 0.000000 | 4.0 | 0.000000e+00 | 4.00000 |
Which result extraction should I use?
As a basic guide you can use the following logic to decide how to extract results. Are you:
Looking for a single result of a component? Extract it similar to
model.results.components["storage"].res.setpoint
.Looking for multiple results of a component (e.g., all objective terms created by a single
Unit
), or similar results of multiple components (e.g., electricity generation of all generators)? Make use ofto_pandas(...)
, applying a specific filter, and either usingorientation = "long"
(the default), ororientation = "wide"
.
Advanced usage: Looking for a single result of a component at a time, but doing it repeatedly for a single run (= extracting a single result from component A
, then one from component B
, and so on)?
Then use the to_dict(...)
function and then extract your results similar to model.to_dict()[("storage", "res", "setpoint")]
. Compared to (1.) this has the advantage of caching results during the first call to to_dict(...)
, and being able to only extract specific results if correctly filtered. **Pay attention to why, when, and how you use this, since improper usage may be way slower than directly accessing your results as explained above.
Loading results from file
The example that we have used until now, does not write any results to a file. To load some, we therefore need to enable this and then re-solve the model.
For that, edit the top-level config file, and change
config:
# ...
results:
enabled: true
memory_only: false
to
config:
# ...
results:
enabled: true
memory_only: true # <-- change here!
Now run the model once more to create the result output file
model = iesopt.run("ex_custom_results/config.iesopt.yaml", verbosity=False)
This will create an IESopt result file SomeScenario.iesopt.result.jld2
inside the
ex_custom_results/out/CustomResults/
folder, which contains all results. This can be used to analyse results at a
later time. To prevent losing information it tries to extract all results - which may be time intensive, but
ensures that you do not forget to extract something, to only realise later that you miss it.
We can now load this file using
results = iesopt.Results(
file="ex_custom_results/out/CustomResults/SomeScenario.iesopt.result.jld2"
)
Now, you can use exactly the same code that we have already walked through, c.f. Accessing model results: Variables, just by
replacing every access to model.results
by results
.
File results: Direct access
# instead of
# `model.results.components["storage"].res.setpoint`
# we now use:
results.components["storage"].res.setpoint
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
File results: Access using get(...)
results.get("component", "storage", "res", "setpoint")
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
File results: Collective results
File results: Using to_dict(...)
result_dict = results.to_dict()
result_dict[("storage", "res", "setpoint")]
array([-3.6565097 , 3.47368421, -0.99722992, 0.94736842, 0. ,
0. , 0. , -5.6 , 0. , -0.1 ,
-1.50221607, 3.47368421, 2.63157895, 0.31578947, 0.42105263,
-3.46149584, 0. , 0.84210526, -2.4 , -4. ,
3.57894737, 3.47368421, 0.94736842, 0.52631579])
File results: Using to_pandas(...)
df = results.to_pandas(field_types="exp", orientation="wide")
df.head()
storage.storage | demand | grid | generator | |
---|---|---|---|---|
exp | exp | exp | exp | |
injection | value | injection | out_electricity | |
t1 | 3.473684 | 4.0 | 4.440892e-16 | 7.65651 |
t2 | -3.473684 | 4.0 | -1.110223e-16 | 0.70000 |
t3 | 0.947368 | 4.0 | 2.220446e-16 | 4.99723 |
t4 | -0.947368 | 4.0 | 0.000000e+00 | 3.10000 |
t5 | 0.000000 | 4.0 | 0.000000e+00 | 4.00000 |
Calling into Julia
You should probably never need the following, but you can also manually access the results
data Struct
inside the
Julia model to extract some results. For an optimized model (not for results loaded from a file!), this could be done
using
my_result = (
model.core.ext[iesopt.Symbol("iesopt")].results.components["storage"].res.setpoint
)
Observe that
type(my_result)
juliacall.VectorValue
which shows that the other modes of result extraction take care of a proper Julia-to-Python conversion for you already. Further, you can then
my_result[:5]
5-element view(::Vector{Float64}, 1:1:5) with eltype Float64:
-3.656509695290859
3.473684210526316
-0.997229916897507
0.9473684210526315
0.0
which, as you see, returns an actual view
into the Vector{Float64}
, indexed using the Julia range 1:1:5
(given by
the Python range :5
). But
my_result[0:5]
5-element view(::Vector{Float64}, 1:1:5) with eltype Float64:
-3.656509695290859
3.473684210526316
-0.997229916897507
0.9473684210526315
0.0
makes it clear, that the wrapper we use automatically translates between 0-based (Python) and 1-based (Julia) indexing,
which may become confusing and error-prone when thinking about a Julia Vector
but accessing the first entry using
my_result[0]
-3.656509695290859
Accessing dual information (part I):
model.core.ext[iesopt.Symbol("iesopt")].results.components["grid"].con.nodalbalance__dual