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 using to_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 like

df = 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:

  1. Looking for a single result of a component? Extract it similar to model.results.components["storage"].res.setpoint.

  2. 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 of to_pandas(...), applying a specific filter, and either using orientation = "long" (the default), or orientation = "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