Running the urban scale example model¶
This notebook will show you how to load, build, solve, and examine the results of the urban 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.urban_scale()
[2026-04-22 17:28:21] INFO Math init | loading pre-defined math.
[2026-04-22 17:28:21] INFO Math init | loading math files {'additional_math', 'operate', 'storage_inter_cluster', 'spores', 'milp', 'base'}.
[2026-04-22 17:28:21] INFO Model: preprocessing data
[2026-04-22 17:28:21] INFO Math build | building applied math with ['base', 'additional_math'].
[2026-04-22 17:28:21] INFO input data `color` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:21] INFO input data `name` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:21] INFO input data `color` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:21] INFO input data `name` not defined in model math; it will not be available in the optimisation problem.
[2026-04-22 17:28:21] 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: 86kB
Dimensions: (costs: 1, techs: 12, carriers: 3, nodes: 4,
timesteps: 48)
Coordinates:
* costs (costs) object 8B 'monetary'
* techs (techs) object 96B 'N1_to_X2' ... 'supply_gri...
* carriers (carriers) object 24B 'electricity' 'gas' 'heat'
* nodes (nodes) object 32B 'N1' 'X1' 'X2' 'X3'
* timesteps (timesteps) datetime64[ns] 384B 2005-07-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 96B 'transmission' ... 'supply'
carrier_export (techs, carriers) bool 36B False False ... False
carrier_in (nodes, techs, carriers) bool 144B False ... ...
... ...
longitude (nodes) float64 32B -0.1247 -0.1613 ... -0.1311
source_use_equals (techs, timesteps) float64 5kB nan nan ... nan
sink_use_equals (timesteps, techs, nodes) float64 18kB nan .....
definition_matrix (nodes, techs, carriers) bool 144B False ... ...
timestep_resolution (timesteps) float64 384B 1.0 1.0 1.0 ... 1.0 1.0
timestep_weights (timesteps) float64 384B 1.0 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()
techs carriers nodes N1_to_X2 heat N1 2000.0 X2 2000.0 N1_to_X3 heat N1 2000.0 X3 2000.0 X1_to_N1 heat N1 2000.0 X1 2000.0 X1_to_X2 electricity X1 2000.0 X2 2000.0 X1_to_X3 electricity X1 2000.0 X3 2000.0 boiler heat X2 600.0 X3 600.0 chp electricity X1 1500.0 pv electricity X1 250.0 X2 250.0 X3 50.0 supply_gas gas X1 2000.0 X2 2000.0 X3 2000.0 supply_grid_power electricity X1 2000.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()
techs nodes demand_electricity X1 35.271156 X2 8796.878622 X3 1244.604116 demand_heat X1 33.999992 X2 7147.808356 X3 50.567751 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:21] INFO Model: backend build starting
[2026-04-22 17:28:22] INFO Optimisation Model | parameters/lookups | Generated.
[2026-04-22 17:28:22] INFO Optimisation Model | variables | Generated.
[2026-04-22 17:28:23] INFO Optimisation Model | global_expressions | Generated.
[2026-04-22 17:28:24] INFO Optimisation Model | constraints | Generated.
[2026-04-22 17:28:24] INFO Optimisation Model | piecewise_constraints | Generated.
[2026-04-22 17:28:24] INFO Optimisation Model | objectives | Generated.
[2026-04-22 17:28:24] INFO Model: backend build complete
[2026-04-22 17:28:24] INFO Optimisation model | starting model in base mode.
[2026-04-22 17:28:24] INFO Optimisation Model | postprocess | Generated.
[2026-04-22 17:28:24] INFO Backend: solver finished running. Time since start of solving optimisation problem: 0:00:00.598181
[2026-04-22 17:28:24] INFO Postprocessing: applied zero threshold 1e-10 to model results.
[2026-04-22 17:28:24] INFO Postprocessing: ended. Time since start of solving optimisation problem: 0:00:00.628159
[2026-04-22 17:28:24] INFO Backend: model solve completed. Time since start of solving optimisation problem: 0:00:00.628481
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: 389kB
Dimensions: (techs: 12, nodes: 4, carriers: 3,
timesteps: 48, costs: 1)
Coordinates:
* techs (techs) object 96B 'N1_to_X2' ... 'supply_gri...
* nodes (nodes) object 32B 'N1' 'X1' 'X2' 'X3'
* carriers (carriers) object 24B 'electricity' 'gas' 'heat'
* timesteps (timesteps) datetime64[ns] 384B 2005-07-01 .....
* costs (costs) object 8B 'monetary'
Data variables: (12/25)
flow_cap (nodes, techs, carriers) float64 1kB nan ... nan
link_flow_cap (techs) float64 96B 190.2 10.38 ... nan nan
flow_out (nodes, techs, carriers, timesteps) float64 55kB ...
flow_in (nodes, techs, carriers, timesteps) float64 55kB ...
flow_export (nodes, techs, carriers, timesteps) float64 55kB ...
area_use (nodes, techs) float64 384B nan nan ... nan nan
... ...
capacity_factor (nodes, techs, carriers, timesteps) float64 55kB ...
systemwide_capacity_factor (techs, carriers) float64 288B nan nan ... nan
total_generation (techs, carriers) float64 288B nan nan ... nan
systemwide_levelised_cost (techs, carriers, costs) float64 288B nan ......
total_levelised_cost (carriers, costs) float64 24B 0.04246 ... 0.0296
unmet_sum (nodes, carriers, timesteps) float64 5kB nan ...We can sum heat output over all locations and turn the result into a pandas DataFrame.
Note: heat output of transmission technologies (e.g., N1_to_X2) is the import of heat at nodes.
df_heat = (
model.results.flow_out.sel(carriers="heat")
.sum("nodes", min_count=1, skipna=True)
.to_series()
.dropna()
.unstack("techs")
)
df_heat.head()
| techs | N1_to_X2 | N1_to_X3 | X1_to_N1 | boiler | chp |
|---|---|---|---|---|---|
| timesteps | |||||
| 2005-07-01 00:00:00 | 242.341998 | 18.751161 | 85.869798 | 0.000000 | 92.861360 |
| 2005-07-01 01:00:00 | 66.368993 | 0.844722 | 72.541074 | 4.100046 | 78.466297 |
| 2005-07-01 02:00:00 | 67.582474 | 0.000000 | 72.915564 | 9.626102 | 78.876806 |
| 2005-07-01 03:00:00 | 67.487047 | 0.000000 | 72.812606 | 37.084989 | 78.877367 |
| 2005-07-01 04:00:00 | 70.052981 | 0.844807 | 76.515868 | 53.191064 | 83.204646 |
We can also examine total technology costs.
costs = model.results.cost.to_series().dropna()
costs.head()
nodes techs costs N1 N1_to_X2 monetary 0.051679 N1_to_X3 monetary 0.003761 X1_to_N1 monetary 0.154602 X1 X1_to_N1 monetary 0.154602 X1_to_X2 monetary 0.008286 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.sel(carriers="electricity")
.to_series()
.dropna()
)
lcoes.head()
techs costs X1_to_X2 monetary 0.000002 X1_to_X3 monetary 0.000002 chp monetary 0.016822 pv monetary 0.038754 supply_grid_power monetary 0.115972 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 combinging in- and out-flows and separating demand from other technologies.
First, we look at the aggregated result across all nodes for electricity, then we look at each node and carrier separately.
df_electricity = (
(model.results.flow_out.fillna(0) - model.results.flow_in.fillna(0))
.sel(carriers="electricity")
.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_electricity"]
df_electricity_other = df_electricity[df_electricity.techs != "demand_electricity"]
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 X1_to_X2 2005-07-01 00:00:00 -1.929506 1 X1_to_X2 2005-07-01 01:00:00 -1.570625 2 X1_to_X2 2005-07-01 02:00:00 -1.581138 3 X1_to_X2 2005-07-01 03:00:00 -1.581138 4 X1_to_X2 2005-07-01 04:00:00 -1.688397
carriers = ["heat", "electricity"]
df_flows = (
(model.results.flow_out.fillna(0) - model.results.flow_in.fillna(0))
.sel(carriers=carriers)
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow in/out (kWh)")
.reset_index()
)
df_demand = df_flows[df_flows.techs.str.contains("demand")]
df_flows_other = df_flows[~df_flows.techs.str.contains("demand")]
print(df_flows.head())
node_order = df_flows_other.nodes.unique()
fig = px.bar(
df_flows_other,
x="timesteps",
y="Flow in/out (kWh)",
facet_row="nodes",
facet_col="carriers",
color="techs",
category_orders={"nodes": node_order, "carriers": carriers},
height=1000,
color_discrete_map=colors,
)
showlegend = True
# we reverse the node order (`[::-1]`) because the rows are numbered from bottom to top.
for row, node in enumerate(node_order[::-1]):
for col, carrier in enumerate(carriers):
demand_ = df_demand.loc[
(df_demand.nodes == node) & (df_demand.techs == f"demand_{carrier}"),
"Flow in/out (kWh)",
]
if not demand_.empty:
fig.add_scatter(
x=model.results.timesteps.values,
y=-1 * demand_,
row=row + 1,
col=col + 1,
marker_color="black",
name="Demand",
legendgroup="demand",
showlegend=showlegend,
)
showlegend = False
fig.update_yaxes(matches=None)
fig.show()
nodes techs carriers timesteps Flow in/out (kWh) 0 N1 N1_to_X2 heat 2005-07-01 00:00:00 -83.855762 1 N1 N1_to_X2 heat 2005-07-01 01:00:00 -71.606325 2 N1 N1_to_X2 heat 2005-07-01 02:00:00 -72.915564 3 N1 N1_to_X2 heat 2005-07-01 03:00:00 -72.812606 4 N1 N1_to_X2 heat 2005-07-01 04:00:00 -75.581024
Plotting capacities¶
We can plot capacities without needing to combine arrays. We can look at capacities for different carriers separately. We ignore demand and transmission technology capacities in this example.
df_capacity = (
model.results.flow_cap.where(
~model.inputs.base_tech.str.contains("demand|transmission")
)
.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",
facet_col="carriers",
color_discrete_map=colors,
)
fig.show()
nodes techs carriers Flow capacity (kW) 0 X1 chp electricity 260.946700 1 X1 chp gas 644.312830 2 X1 chp heat 208.757360 3 X1 supply_gas gas 644.312830 4 X1 supply_grid_power electricity 33.620147
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. You will only see hover information for one carrier at a time. To see the other carrier's information, hide one carrier by clicking on its name in the legend.
df_coords = model.inputs[["latitude", "longitude"]].to_dataframe().reset_index()
df_capacity = (
model.results.flow_cap.where(model.inputs.base_tech == "transmission")
.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="carriers",
hover_name="nodes",
hover_data="Flow capacity (kW)",
zoom=3,
height=300,
)
fig.update_layout(
map_style="open-street-map",
map_zoom=11,
map_center_lat=df_coords.latitude.mean(),
map_center_lon=df_coords.longitude.mean(),
margin={"r": 0, "t": 0, "l": 0, "b": 0},
hoverdistance=50,
)
See the Calliope documentation for more details on setting up and running a Calliope model.