In this post we’ll run the café example from the Ciw documentation, collect the results, and display them as a Sankey diagram.
First, we can run the example:
"""Run the cafe example from the Ciw documentation.
This is the model being visualised: a three-node cafe with separate cold-food
and hot-food preparation stations feeding a till. Arrivals come in at two
rates, and after being served at the till everyone exits.
"""
from __future__ import annotations
import ciw
import pandas as pd
seed = 2018
max_time = 200
def build_network() -> ciw.network.Network:
"""Return the cafe network from the Ciw documentation.
Returns
-------
ciw.network.Network
A three-node network: ``ColdFood`` (1), ``HotFood`` (2), and ``Till``
(3).
"""
return ciw.create_network(
arrival_distributions=[
ciw.dists.Exponential(rate=0.3),
ciw.dists.Exponential(rate=0.2),
None,
],
service_distributions=[
ciw.dists.Exponential(rate=1.0),
ciw.dists.Exponential(rate=0.4),
ciw.dists.Exponential(rate=0.5),
],
routing=[[0.0, 0.3, 0.7], [0.0, 0.0, 1.0], [0.0, 0.0, 0.0]],
number_of_servers=[1, 2, 2],
)
def run_simulation(
simulation_time: float = max_time, random_seed: int = seed
) -> ciw.Simulation:
"""Seed Ciw, build the network, and simulate until ``simulation_time``.
Parameters
----------
simulation_time:
The time to simulate until.
random_seed:
Seed for Ciw's random number generator, for repeatability.
Returns
-------
ciw.Simulation
The simulation after running.
"""
ciw.seed(random_seed)
network = build_network()
simulation = ciw.Simulation(network)
simulation.simulate_until_max_time(simulation_time)
return simulation
def get_records(simulation: ciw.Simulation) -> pd.DataFrame:
"""Return the simulation's records as a DataFrame."""
return pd.DataFrame(simulation.get_all_records())In order to prepare the results from Ciw for the Plotly Sankey diagramming class, we need to do some additional processing. I also just want to include the arrival node and the exit node just because I can. So we’ll need to extract how many times an arrival came into the system from the arrival node:
"""Count how many customers first entered at each node.
Ciw's records do not include the arrival node, so to draw the flow out of the
arrival node we find, for each customer, the first node they visited and count
how many customers started at each node. Those counts are returned with
``node == 0``, the index used for the arrival node in the Sankey diagram.
"""
from __future__ import annotations
import pandas as pd
def first_nodes(recs: pd.DataFrame) -> pd.DataFrame:
"""Return the number of customers whose first node was each node.
Parameters
----------
recs:
The records table from a Ciw simulation.
Returns
-------
pandas.DataFrame
A frame with columns ``node`` (always ``0``, the arrival node) and
``flow`` (the number of customers that first arrived at each node).
"""
return (
recs.sort_values(by="arrival_date")
.groupby("id_number")["node"]
.apply(lambda x: x.iloc[0])
.value_counts()
.reset_index(name="flow")
.rename(columns={"index": "destination"})
.assign(node=0)
)We’ll also need to compute the amount of flow to/from the other nodes. The reason why the arrival node is separate is because it isn’t included in the records table anyway.
"""Count the flow along every edge of the network.
Grouping the records by ``(node, destination)`` and counting gives the number
of moves along each edge. Customers leaving the system are recorded with a
destination of ``-1``; that is remapped to the index used for the exit node
(``4``) so that the Sankey diagram can draw them.
"""
from __future__ import annotations
import pandas as pd
exit_index = 4
def edge_flows(recs: pd.DataFrame, exit_node: int = exit_index) -> pd.DataFrame:
"""Return the number of moves along each edge.
Parameters
----------
recs:
The records table from a Ciw simulation.
exit_node:
The index to use for the exit node, replacing Ciw's ``-1``.
Returns
-------
pandas.DataFrame
A frame with columns ``node``, ``destination``, and ``flow``.
"""
return (
recs.groupby(by=["node", "destination"])
.size()
.reset_index(name="flow")
.replace({-1: exit_node})
)Now we can combine these two information sources and get rid of the -1 for the exit node since we will want a proper index.
"""Combine the arrival and edge flow tables into one link table.
The arrival-node flows and the edge flows share the columns ``node`` and
``flow`` (the edge table additionally has ``destination``), so they can simply
be stacked with :func:`pandas.concat` to form the complete set of Sankey links.
"""
from __future__ import annotations
import pandas as pd
def combine_flows(
arrival_flows: pd.DataFrame, edge_flows: pd.DataFrame
) -> pd.DataFrame:
"""Stack the arrival flows and the edge flows.
Parameters
----------
arrival_flows:
The frame returned by
:func:`ciw_plotly_sankey.first_nodes.first_nodes`.
edge_flows:
The frame returned by
:func:`ciw_plotly_sankey.edge_flows.edge_flows`.
Returns
-------
pandas.DataFrame
The concatenation of the two frames.
"""
return pd.concat((arrival_flows, edge_flows))Finally, let’s sort the values just for fun (as far as I know this doesn’t change anything of substance).
"""Sort the link table before drawing.
Sorting by destination, then node, then flow is a cosmetic step: as far as the
author knows it does not change anything of substance in the diagram, but it
makes the resulting link order deterministic and easier to read.
"""
from __future__ import annotations
import pandas as pd
def sort_flows(recs: pd.DataFrame) -> pd.DataFrame:
"""Return the link table sorted by destination, node, and flow."""
return recs.sort_values(by=["destination", "node", "flow"])Now we can prepare the Plotly figure as follows:
"""Build the Plotly Sankey figure from the link table.
The nodes are labelled ``ArrivalNode``, ``ColdFood``, ``HotFood``, ``Till``,
and ``Exit`` -- indices ``0`` to ``4`` -- matching the ``node`` and
``destination`` indices in the link table.
"""
from __future__ import annotations
import pandas as pd
import plotly.graph_objects as go
node_labels = ["ArrivalNode", "ColdFood", "HotFood", "Till", "Exit"]
def build_figure(recs: pd.DataFrame) -> go.Figure:
"""Return a Plotly Sankey figure for the link table.
Parameters
----------
recs:
A link table with columns ``node``, ``destination``, and ``flow``.
Returns
-------
plotly.graph_objects.Figure
The Sankey figure.
"""
return go.Figure(
go.Sankey(
arrangement="snap",
node=dict(label=node_labels, pad=10),
link=dict(
arrowlen=15,
source=recs.node,
target=recs.destination,
value=recs.flow,
),
)
)Then we can finally change some background stuff and print the HTML which I have promptly pasted below into this post:
"""Style the figure and render it to embeddable HTML.
This is the last step of the pipeline: run the cafe example, build the link
table, draw the Sankey figure, set the paper and plot backgrounds, and print
the figure as an HTML fragment ready to paste into a post.
"""
from __future__ import annotations
import plotly.graph_objects as go
from ciw_plotly_sankey.combine import combine_flows
from ciw_plotly_sankey.edge_flows import edge_flows
from ciw_plotly_sankey.figure import build_figure
from ciw_plotly_sankey.first_nodes import first_nodes
from ciw_plotly_sankey.network import get_records, run_simulation
from ciw_plotly_sankey.sort import sort_flows
transparent_grey = "rgba(0.5,0.5,0.5,0.5)"
def prepare_links(recs) -> "object":
"""Turn the simulation records into the sorted Sankey link table."""
arrival_flows = first_nodes(recs)
flow_edges = edge_flows(recs)
return sort_flows(combine_flows(arrival_flows, flow_edges))
def style_figure(fig: go.Figure) -> go.Figure:
"""Set the paper and plot backgrounds of the figure."""
fig.layout.paper_bgcolor = transparent_grey
fig.layout.plot_bgcolor = transparent_grey
return fig
def to_html(fig: go.Figure) -> str:
"""Return the figure as an HTML fragment using the Plotly CDN."""
return fig.to_html(full_html=False, include_plotlyjs="cdn")
def build_diagram() -> go.Figure:
"""Run the cafe example and return the styled Sankey figure."""
simulation = run_simulation()
recs = get_records(simulation)
return style_figure(build_figure(prepare_links(recs)))
def main() -> None:
"""Build the diagram and print its HTML representation."""
print(to_html(build_diagram()))
if __name__ == "__main__":
main()And there we have it. A Sankey diagram from Ciw’s results output.
I’ll be the first to admit that this diagram could use some work, but I am pleased at how this gives a prototype with little code. Primarily it is how jumbled up the arcs are going out of cold food. It may be possible to use different drawing layouts for x and y to obtain a nicer arrangement. The Networkx package has some layout functions that might supply such arrangements even if the core Networkx drawing utilities are not used.
Now, to me, the point of such a diagram isn’t to show a plan for a model, but rather to show what actually happened. It shows us how many of the customers went through which of the routing paths.
The above code needs some minor changes to be reusable, but it is definitely a process that can be generalized to work on other simulation records from Ciw. The details of the model were not particularly important beyond what could be extracted from the results. The structure of the model was implicit in the results. Some parts of the structure could be missing when the simulation is either small or heavily constrained, but then again that might be desirable if you are interested in seeing actual flow rather than what you think you put into the model.