Running the national scale example model¶
This notebook will show you how to load, build, solve, and examine the results of the national scale example model.
import pandas as pd
import plotly.express as px
import calliope
# We increase logging verbosity
calliope.set_log_verbosity("INFO", include_solver_output=False)
Load model and examine inputs¶
model = calliope.examples.national_scale()
[2026-04-22 17:28:13] INFO Math init | loading pre-defined math.
[2026-04-22 17:28:13] INFO Math init | loading math files {'base', 'milp', 'storage_inter_cluster', 'spores', 'operate'}.
[2026-04-22 17:28:13] INFO Model: preprocessing data
[2026-04-22 17:28:13] INFO Math build | building applied math with ['base'].
[2026-04-22 17:28:14] INFO input data `color` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:14] INFO input data `name` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:14] INFO input data `color` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:14] INFO input data `name` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:14] INFO Model: initialisation complete
Model inputs can be viewed at model.inputs.
Variables are indexed over any combination of techs, nodes, carriers, costs and timesteps.
model.inputs
<xarray.Dataset> Size: 82kB
Dimensions: (costs: 1, techs: 8, nodes: 5, carriers: 1,
timesteps: 120)
Coordinates:
* costs (costs) object 8B 'monetary'
* techs (techs) object 64B 'battery' ... 'region1_t...
* carriers (carriers) object 8B 'power'
* nodes (nodes) object 40B 'region1' ... 'region2'
* timesteps (timesteps) datetime64[ns] 960B 2005-01-01 ...
Data variables: (12/35)
cost_interest_rate (costs) float64 8B 0.1
bigM float64 8B 1e+06
objective_cost_weights (costs) float64 8B 1.0
base_tech (techs) object 64B 'storage' ... 'transmiss...
carrier_in (nodes, techs, carriers) bool 40B False ......
color (techs) object 64B '#3B61E3' ... '#8465A9'
... ...
sink_use_equals (timesteps, nodes, techs) float64 38kB nan ...
source_use_max (timesteps, nodes, techs) float64 38kB nan ...
definition_matrix (nodes, techs, carriers) bool 40B False ......
distance (techs) float64 64B nan nan ... 111.0 512.3
timestep_resolution (timesteps) float64 960B 1.0 1.0 ... 1.0 1.0
timestep_weights (timesteps) float64 960B 1.0 1.0 ... 1.0 1.0Individual data variables can be accessed easily, to_series().dropna() allows us to view the data in a nice tabular format.
model.inputs.flow_cap_max.to_series().dropna()
nodes techs region1 ccgt 30000.0 region1_to_region2 10000.0 region1_1 csp 10000.0 region1_2 csp 10000.0 region1_3 csp 10000.0 region2 battery 1000.0 region1_to_region2 10000.0 Name: flow_cap_max, dtype: float64
You can apply node/tech/carrier/timesteps only operations, like summing information over timesteps
model.inputs.sink_use_equals.sum(
"timesteps", min_count=1, skipna=True
).to_series().dropna()
nodes techs region1 demand_power 3793748.48 region2 demand_power 288114.75 Name: sink_use_equals, dtype: float64
Build and solve the optimisation problem.¶
Results are loaded into model.results.
By setting the log verbosity at the start of this tutorial to "INFO", we can see the timing of parts of the run, as well as the solver's log.
model.build()
model.solve()
[2026-04-22 17:28:14] INFO Model: backend build starting
[2026-04-22 17:28:14] INFO Optimisation Model | parameters/lookups | Generated.
[2026-04-22 17:28:14] INFO Optimisation Model | variables | Generated.
[2026-04-22 17:28:15] INFO Optimisation Model | global_expressions | Generated.
[2026-04-22 17:28:16] INFO Optimisation Model | constraints | Generated.
[2026-04-22 17:28:16] INFO Optimisation Model | piecewise_constraints | Generated.
[2026-04-22 17:28:17] INFO Optimisation Model | objectives | Generated.
[2026-04-22 17:28:17] INFO Model: backend build complete
[2026-04-22 17:28:17] INFO Optimisation model | starting model in base mode.
[2026-04-22 17:28:17] INFO Optimisation Model | postprocess | Generated.
[2026-04-22 17:28:17] INFO Backend: solver finished running. Time since start of solving optimisation problem: 0:00:00.746625
[2026-04-22 17:28:17] INFO Postprocessing: applied zero threshold 1e-10 to model results.
[2026-04-22 17:28:17] INFO Postprocessing: ended. Time since start of solving optimisation problem: 0:00:00.780623
[2026-04-22 17:28:17] INFO Backend: model solve completed. Time since start of solving optimisation problem: 0:00:00.780960
Model results are held in the same structure as model inputs. The results consist of the optimal values for all decision variables, including capacities and carrier flow. There are also results, like system capacity factor and levelised costs, which are calculated in postprocessing before being added to the results Dataset
Examine results¶
model.results
<xarray.Dataset> Size: 365kB
Dimensions: (techs: 8, nodes: 5, carriers: 1,
timesteps: 120, costs: 1)
Coordinates:
* techs (techs) object 64B 'battery' ... 'region1_to...
* nodes (nodes) object 40B 'region1' ... 'region2'
* carriers (carriers) object 8B 'power'
* timesteps (timesteps) datetime64[ns] 960B 2005-01-01 ....
* costs (costs) object 8B 'monetary'
Data variables: (12/30)
flow_cap (nodes, techs, carriers) float64 320B nan .....
link_flow_cap (techs) float64 64B nan nan ... 3.23e+03
flow_out (nodes, techs, carriers, timesteps) float64 38kB ...
flow_in (nodes, techs, carriers, timesteps) float64 38kB ...
area_use (nodes, techs) float64 320B nan nan ... nan nan
source_use (nodes, techs, timesteps) float64 38kB nan ....
... ...
total_generation (techs, carriers) float64 64B 1.581e+04 ... ...
systemwide_levelised_cost (techs, carriers, costs) float64 64B 0.1 ......
total_levelised_cost (carriers, costs) float64 8B 0.05363
unmet_sum (nodes, carriers, timesteps) float64 5kB 0.0...
curtailment (nodes, techs, timesteps) float64 38kB nan ....
total_curtailment (nodes, techs) float64 320B nan nan ... nan nanWe can sum electricity output over all locations and turn the result into a pandas DataFrame.
Note: electricity output of transmission technologies (e.g., region1_to_region2) is the import of electricity at nodes.
df_electricity = model.results.flow_out.sel(carriers="power").sum("nodes").to_series()
df_electricity.head()
techs timesteps battery 2005-01-01 00:00:00 0.0 2005-01-01 01:00:00 0.0 2005-01-01 02:00:00 0.0 2005-01-01 03:00:00 0.0 2005-01-01 04:00:00 0.0 Name: flow_out, dtype: float64
We can also view total costs associated with each of our technologies at each of our nodes:
costs = model.results.cost.to_series().dropna()
costs.head()
nodes techs costs region1 ccgt monetary 170306.866481 region1_to_region2 monetary 487.527358 region1_1 csp monetary 94592.428366 region1_2 csp monetary 0.000000 region1_3 csp monetary 9013.121387 Name: cost, dtype: float64
We can also examine levelized costs for each location and technology, which is calculated in a post-processing step:
lcoes = model.results.systemwide_levelised_cost.to_series().dropna()
lcoes
techs carriers costs battery power monetary 0.100035 ccgt power monetary 0.049961 csp power monetary 0.142719 region1_to_region2 power monetary 0.005364 Name: systemwide_levelised_cost, dtype: float64
# We set the color mapping to use in all our plots by extracting the colors defined in the technology definitions of our model.
colors = model.inputs.color.to_series().to_dict()
Plotting flows¶
We do this by combining in- and out-flows and separating demand from other technologies. First, we look at the aggregated result across all nodes, then we look at each node separately.
df_electricity = (
(model.results.flow_out.fillna(0) - model.results.flow_in.fillna(0))
.sel(carriers="power")
.sum("nodes")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow in/out (kWh)")
.reset_index()
)
df_electricity_demand = df_electricity[df_electricity.techs == "demand_power"]
df_electricity_other = df_electricity[df_electricity.techs != "demand_power"]
print(df_electricity.head())
fig1 = px.bar(
df_electricity_other,
x="timesteps",
y="Flow in/out (kWh)",
color="techs",
color_discrete_map=colors,
)
fig1.add_scatter(
x=df_electricity_demand.timesteps,
y=-1 * df_electricity_demand["Flow in/out (kWh)"],
marker_color="black",
name="demand",
)
techs timesteps Flow in/out (kWh) 0 battery 2005-01-01 14:00:00 -76.402643 1 battery 2005-01-01 15:00:00 -286.886000 2 battery 2005-01-01 16:00:00 163.934000 3 battery 2005-01-01 17:00:00 122.950000 4 battery 2005-01-01 18:00:00 40.984000
df_electricity = (
(model.results.flow_out.fillna(0) - model.results.flow_in.fillna(0))
.sel(carriers="power")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow in/out (kWh)")
.reset_index()
)
df_electricity_demand = df_electricity[df_electricity.techs == "demand_power"]
df_electricity_other = df_electricity[df_electricity.techs != "demand_power"]
print(df_electricity.head())
node_order = df_electricity.nodes.unique()
fig = px.bar(
df_electricity_other,
x="timesteps",
y="Flow in/out (kWh)",
facet_row="nodes",
color="techs",
category_orders={"nodes": node_order},
height=1000,
color_discrete_map=colors,
)
showlegend = True
# we reverse the node order (`[::-1]`) because the rows are numbered from bottom to top.
for idx, node in enumerate(node_order[::-1]):
demand_ = df_electricity_demand.loc[
df_electricity_demand.nodes == node, "Flow in/out (kWh)"
]
if not demand_.empty:
fig.add_scatter(
x=df_electricity_demand.loc[
df_electricity_demand.nodes == node, "timesteps"
],
y=-1 * demand_,
row=idx + 1,
col="all",
marker_color="black",
name="Demand",
legendgroup="demand",
showlegend=showlegend,
)
showlegend = False
fig.update_yaxes(matches=None)
fig.show()
nodes techs timesteps Flow in/out (kWh) 0 region1 ccgt 2005-01-01 00:00:00 27936.360 1 region1 ccgt 2005-01-01 01:00:00 26894.673 2 region1 ccgt 2005-01-01 02:00:00 26189.672 3 region1 ccgt 2005-01-01 03:00:00 25630.273 4 region1 ccgt 2005-01-01 04:00:00 25675.049
Plotting capacities¶
We can plot capacities without needing to combine arrays. We first look at flow capacities, then storage capacities.
df_capacity = (
model.results.flow_cap.where(model.results.techs != "demand_power")
.sel(carriers="power")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow capacity (kW)")
.reset_index()
)
print(df_capacity.head())
fig = px.bar(
df_capacity,
x="nodes",
y="Flow capacity (kW)",
color="techs",
color_discrete_map=colors,
)
fig.show()
nodes techs Flow capacity (kW) 0 region1 ccgt 30000.0000 1 region1 region1_to_region1_1 9000.0000 2 region1 region1_to_region1_3 2280.3859 3 region1 region1_to_region2 3230.4729 4 region1_1 csp 10000.0000
df_storage_cap = (
model.results.storage_cap.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Storage capacity (kWh)")
.reset_index()
)
print(df_capacity.head())
fig = px.bar(
df_storage_cap,
x="nodes",
y="Storage capacity (kWh)",
color="techs",
color_discrete_map=colors,
)
fig.show()
nodes techs Flow capacity (kW) 0 region1 ccgt 30000.0000 1 region1 region1_to_region1_1 9000.0000 2 region1 region1_to_region1_3 2280.3859 3 region1 region1_to_region2 3230.4729 4 region1_1 csp 10000.0000
Spatial plots¶
Plotly express is limited in its ability to plot spatially, but we can at least plot the connections that exist in our results with capacity information available on hover.
df_coords = model.inputs[["latitude", "longitude"]].to_dataframe().reset_index()
df_capacity = (
model.results.flow_cap.where(model.inputs.base_tech == "transmission")
.sel(carriers="power")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow capacity (kW)")
.reset_index()
)
df_capacity_coords = pd.merge(df_coords, df_capacity, left_on="nodes", right_on="nodes")
fig = px.line_map(
df_capacity_coords,
lat="latitude",
lon="longitude",
color="techs",
hover_data="Flow capacity (kW)",
zoom=3,
height=300,
)
fig.update_layout(
mapbox_style="open-street-map",
mapbox_zoom=4,
mapbox_center_lat=df_coords.latitude.mean(),
mapbox_center_lon=df_coords.longitude.mean(),
margin={"r": 0, "t": 0, "l": 0, "b": 0},
)
See the Calliope documentation for more details on setting up and running a Calliope model.