Skip to content

Visualisation

skroute.viz draws what the solvers do: the nodes and the closed route of a solution (one colour per trip), the best-so-far cost per iteration, the same route on OpenStreetMap tiles, and — through the fit(..., callback=) protocol — the search while it runs: LivePlot redraws the current and best tours at every iteration, together with the structure a solver is building when it reports one (the edges of a construction heuristic, the pheromone trails of the ant colony, the SOM ring — D31); Recorder keeps every event with a wall-clock stamp and replays the run at time-lapse speed as a matplotlib animation, a live replay, a GIF or MP4, or a Plotly figure with Play/Pause, a speed menu and a slider. The plan also leaves the library: google_maps_urls, to_kml and google_maps_html (module skroute.viz.google_maps, which needs none of the viz extras) turn a fitted solver, a RouteEvent or a plain route with its (latitude, longitude) coordinates into Google Maps Directions links per day, a KML for Google My Maps and Google Earth, and a standalone page that draws the real roads with the Maps JavaScript API — D34. The user guide page walks through the workflows.

The package is optional and imported by nothing else in scikit-route. matplotlib is loaded on first use — pip install "scikit-route[viz]" — and plotly only by the map tools — pip install "scikit-route[viz-map]"; a missing library raises an ImportError that names the extra. Coordinates are (n, 2) arrays in matrix row order: column 0 is x and column 1 is y for the matplotlib tools, (latitude, longitude) for the map tools.

>>> import matplotlib
>>> matplotlib.use("Agg")  # headless; any interactive backend works the same
>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_tsp
>>> from skroute.viz import Recorder, plot_history, plot_route
>>> dj = load_tsp("dj38")
>>> rec = Recorder(every=5)
>>> ils = IteratedLocalSearch(random_state=0).fit(
...     dj.distance_matrix(), labels=dj.labels, coords=dj.coords, callback=rec
... )
>>> ax = plot_route(ils)  # coordinates come from the fit
>>> len(ax.lines), len(ax.lines[0].get_xdata())  # one closed trip through the 38 cities
(1, 39)
>>> ax = plot_history(ils)
>>> len(ax.lines[0].get_ydata()) == ils.n_iter_
True
>>> rec.n_frames == len(rec) and bool((rec.frame_delays(speed=10) >= 10).all())  # a frame per kept event
True
>>> live = rec.replay(speed=1e9)  # the run again, through a LivePlot, as fast as it can be drawn
>>> live.n_events == len(rec)
True

plot_route

plot_route(obj: Any, coords: Any = None, *, ax: Axes | None = None, labels: bool = False, depot: bool = True, trip_colors: bool = True, **line_kwargs: Any) -> Axes

Draw the nodes and the closed route of a solution, one colour per trip.

Parameters:

Name Type Description Default
obj fitted estimator, RouteEvent or array of row positions

A fitted solver (its tour_ is decoded with its problem_), a progress event (its best_tour, or tour when there is no best yet; an event without any tour but with extra["edges"] — one construction step — draws those edges instead, and extra["edge_weights"]/extra["ring"] are drawn whenever present), or a route array whose entries are row positions of coords — the tour_/route_ of a solver fitted without labels=. In the array the depot is the first entry and a repeated depot separates trips ([0, 3, 1, 0, 2, 0] is two trips).

required
coords (n, 2) array-like

Positions in matrix row order; column 0 is drawn as x and column 1 as y. Default: the coordinates the problem carries (fit(..., coords=)). For (latitude, longitude) data pass coords[:, ::-1] or use plot_route_map.

None
ax matplotlib Axes

Draw into these axes; a new 7 x 7 inch figure otherwise.

None
labels bool

Write each node's label next to it.

False
depot bool

Mark the depot with a star (a plain point otherwise).

True
trip_colors bool

One colour per trip; False (or a color= keyword) draws every trip alike.

True
**line_kwargs Any

Forwarded to Axes.plot for the route lines (linewidth, alpha, color...).

{}

Returns:

Name Type Description
ax matplotlib Axes

Equal aspect, no ticks, one Line2D per trip; the title names the solver and the cost when they are known (and the number of edges of a construction step).

Examples:

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_tsp
>>> from skroute.viz import plot_route
>>> dj = load_tsp("dj38")
>>> ils = IteratedLocalSearch(random_state=0).fit(
...     dj.distance_matrix(), labels=dj.labels, coords=dj.coords
... )
>>> ax = plot_route(ils)
>>> len(ax.lines), len(ax.lines[0].get_xdata())  # one trip, closed at the depot
(1, 39)
>>> ax.get_title().startswith("IteratedLocalSearch | cost ")
True

A route array with its coordinates (no estimator needed):

>>> import numpy as np
>>> xy = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
>>> ax = plot_route([0, 1, 0, 2, 3], xy)  # two trips: 0-1-0 and 0-2-3-0
>>> len(ax.lines)
2

One step of a construction heuristic — an event without a tour but with extra["edges"] (D31) — is drawn as its edges, x0, x1, nan per edge in a single line:

>>> from skroute import RoutingProblem
>>> from skroute.base import RouteEvent
>>> from skroute.preprocessing import distance_matrix
>>> problem = RoutingProblem(distance_matrix(xy), coords=xy)
>>> edges = {"edges": [(0, 1), (1, 2), (2, 0)]}
>>> step = RouteEvent("Insertion", "iteration", 2, np.nan, np.nan, None, None, problem, edges)
>>> ax = plot_route(step)
>>> len(ax.lines), len(ax.lines[0].get_xdata()), ax.get_title()
(1, 9, 'Insertion | 3 edges')

plot_history

plot_history(obj_or_events: Any, ax: Axes | None = None) -> Axes

Draw the best-so-far cost per outer iteration.

Parameters:

Name Type Description Default
obj_or_events fitted iterative estimator, Recorder or sequence of events

An estimator with history_, a Recorder, or a sequence of progress events (the "iteration" events' best_cost is drawn against iteration; a nan best — MILP before its first integral solution, a construction step — is skipped). Events forwarded by MultiStart (extra["restart"]) restart their iteration count at every restart: they are drawn against the iteration counted across the restarts, and the title names the ensemble.

required
ax matplotlib Axes

Draw into these axes; a new figure otherwise.

None

Returns:

Name Type Description
ax matplotlib Axes

A step plot (steps-post) with the iteration on x and the best cost on y.

Raises:

Type Description
NotFittedError

For an estimator that was not fitted.

ValueError

For a fitted estimator without history_ (a construction or exact solver).

Examples:

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from skroute import SimulatedAnnealing
>>> from skroute.datasets import load_tsp
>>> from skroute.viz import plot_history
>>> wi = load_tsp("wi29")
>>> sa = SimulatedAnnealing(random_state=0).fit(wi.distance_matrix(), labels=wi.labels)
>>> ax = plot_history(sa)
>>> len(ax.lines[0].get_ydata()) == sa.n_iter_
True
>>> ax.get_xlabel(), ax.get_ylabel()
('Iteration', 'Best cost')

plot_route_map

plot_route_map(obj: Any, coords: Any = None, *, zoom: float | None = None, names: Any = None, trip_names: Any = None) -> Any

Draw a solution on OpenStreetMap tiles with Plotly (one line per trip, the depot marked).

Parameters:

Name Type Description Default
obj fitted estimator, RouteEvent or array of row positions

As in plot_route.

required
coords (n, 2) array-like

(latitude, longitude) in decimal degrees, matrix row order; default: the problem's coords.

None
zoom float

Map zoom level; default: fitted to the points.

None
names sequence of n str or mapping {label: str}

The hover text of each node — a name per row of coords (matrix row order) or a mapping from label to name (a label without an entry shows the label); default: the labels.

None
trip_names sequence of str

One name per trip, shown in a legend ("Monday", "Tuesday"...); without it the lines are named "trip 1", "trip 2"... and the legend stays hidden.

None

Returns:

Name Type Description
fig Figure

Scattermap traces on the "open-street-map" style: nodes, depot and one line per trip; call fig.show() or fig.write_html("route.html").

Examples:

>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_barcelona
>>> from skroute.viz import plot_route_map
>>> bcn = load_barcelona()  # coords are (latitude, longitude)
>>> ils = IteratedLocalSearch(random_state=0).fit(bcn.cost, labels=bcn.labels, coords=bcn.coords)
>>> fig = plot_route_map(ils)
>>> [t.type for t in fig.data], fig.layout.map.style
(['scattermap', 'scattermap', 'scattermap'], 'open-street-map')

Names on hover and a legend of days for a two-day plan:

>>> days = IteratedLocalSearch(random_state=0).fit(
...     bcn.cost, labels=bcn.labels, coords=bcn.coords, time_matrix=bcn.time, max_time_work=6.0
... )
>>> places = {label: f"Place {label}" for label in bcn.labels}
>>> fig = plot_route_map(days, names=places, trip_names=["Monday", "Tuesday"])
>>> fig.data[1].text, [t.name for t in fig.data[2:]], fig.layout.showlegend
(('Place 10000007',), ['Monday', 'Tuesday'], True)

google_maps_urls

google_maps_urls(obj: Any, coords: Any = None, *, labels: Any = None, mode: str = 'driving', max_waypoints: int = 9) -> list[list[str]]

Google Maps Directions URLs of a plan: one list of links per day, each link one leg.

The Maps URL scheme (https://www.google.com/maps/dir/?api=1&origin=...&destination=... &waypoints=...&travelmode=...) takes at most nine waypoints between the origin and the destination, so a day with more stops is split into consecutive legs that share their boundary stop: the first leg leaves the depot, the last one returns to it, and every leg opens in a browser or in the Google Maps app with turn-by-turn directions. Coordinates are written as latitude,longitude with six decimals (about ten centimetres).

Parameters:

Name Type Description Default
obj fitted estimator, RouteEvent or route

A fitted solver (its trips_ and the coordinates of the fit), a progress event (its best tour, decoded with its problem) or a plain label-space route: an open tour, a closed one or a multi-trip route with the depot repeated between the days (route_); every occurrence of the depot — the first entry — starts a new day.

required
coords (n, 2) array-like

(latitude, longitude) in decimal degrees, matrix row order. Default: the coordinates the problem carries (fit(..., coords=)). Required for a plain route.

None
labels sequence of n hashables

For a plain route: the label of each row of coords. None means the route holds row positions.

None
mode ('driving', 'walking', 'bicycling', 'transit')

The travelmode of the links (Google ignores waypoints in transit mode).

"driving"
max_waypoints int

Intermediate stops per link. Nine is Google's documented limit for the URL scheme on the desktop site and in the Google Maps app; a link opened in a phone's browser keeps only three, so pass max_waypoints=3 for links that will be opened there.

9

Returns:

Name Type Description
urls list of list of str

urls[k] are the links of day k in driving order; one link when the day has at most max_waypoints stops.

Raises:

Type Description
ValueError

Without coordinates, for coordinates outside the latitude/longitude ranges or with the wrong number of rows, for labels= with a fitted estimator or an event, a route naming something that is not one of labels= (or indexing beyond coords), a route that visits no stop or one more than once, an event without a tour yet, an unknown mode or a non-positive max_waypoints.

NotFittedError

For an estimator that has not been fitted.

Examples:

>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_barcelona
>>> from skroute.viz import google_maps_urls
>>> bcn = load_barcelona()  # 19 places, coords are (latitude, longitude)
>>> ils = IteratedLocalSearch(random_state=0).fit(bcn.cost, labels=bcn.labels, coords=bcn.coords)
>>> urls = google_maps_urls(ils)
>>> len(urls), len(urls[0])  # one day of 18 stops: two links of at most nine waypoints
(1, 2)
>>> urls[0][0].startswith("https://www.google.com/maps/dir/?api=1&origin=41.398568%2C2.167441")
True
>>> urls[0][1].endswith("&travelmode=driving")
True

A plain route over its own coordinates (row positions; the depot repeats between the days):

>>> office = [40.331, -3.766]
>>> places = [office, [40.42, -3.70], [40.45, -3.69], [40.30, -3.73]]
>>> [len(day) for day in google_maps_urls([0, 2, 1, 0, 3, 0], places)]
[1, 1]
>>> google_maps_urls(["office", "b", "a"], places[:3], labels=["office", "a", "b"], mode="walking")[0]
['https://www.google.com/maps/dir/?api=1&origin=40.331000%2C-3.766000&destination=40.331000%2C-3.766000&waypoints=40.450000%2C-3.690000%7C40.420000%2C-3.700000&travelmode=walking']

to_kml

to_kml(obj: Any, coords: Any = None, path: str | PathLike[str] | None = None, *, labels: Any = None, names: Any = None, depot_name: str = 'Depot', trip_names: Any = None, colors: Any = None) -> Path

Write a plan as a KML 2.2 file: one folder per day with numbered stops and the day's line.

The document holds the depot as one placemark, one Style per day (a 4-pixel line and pins in the day's colour) and one Folder per day named trip_names[k] (default "Day k") with a Placemark per stop — named "k.j <name>", described as "Day k, stop j of m" — and a LineString of the closed trip depot → stops → depot. The line joins the stops as the crow flies; the page of google_maps_html draws the roads.

Google My Maps: open https://www.google.com/maps/d/, Create a new map, then in the untitled layer Import and choose the file — every folder becomes a group of numbered pins with its line, and the map opens on any phone signed into the account. Google Earth: File → Open (desktop) or Projects → Open → Import KML file (web).

Parameters:

Name Type Description Default
obj fitted estimator, RouteEvent or route required
coords (n, 2) array-like

(latitude, longitude) in matrix row order; default: the coordinates of the fit.

None
path str or path - like

Where to write the file (.kml). Required — it is positional after coords so to_kml(est, None, "plan.kml") and to_kml(est, path="plan.kml") both work.

None
labels sequence of n hashables

For a plain route: the label of each row of coords.

None
names sequence of n str or mapping {label: str}

A display name per node (matrix row order, or by label); default: the labels.

None
depot_name str

Name of the depot's placemark.

"Depot"
trip_names sequence of str

One folder name per day; default "Day 1", "Day 2"...

None
colors sequence of str

Hex colours ("#rrggbb") per day, cycled when shorter; default: PALETTE — ten distinct colours, so from the eleventh day on they repeat (pass more for longer plans).

None

Returns:

Name Type Description
path Path

The file written.

Raises:

Type Description
TypeError

Without path (also when a path was given in the coords slot), or when names or trip_names is a single str rather than a sequence.

ValueError

For the inputs google_maps_urls rejects (no or invalid coordinates, a route visiting a node twice or none...), names or trip_names of the wrong length, and colors empty or not hex.

NotFittedError

For an estimator that has not been fitted.

Examples:

>>> import tempfile, xml.etree.ElementTree as ET
>>> from pathlib import Path
>>> from skroute import TwoOpt
>>> from skroute.datasets import load_barcelona
>>> from skroute.viz import to_kml
>>> bcn = load_barcelona()
>>> two = TwoOpt().fit(
...     bcn.cost, labels=bcn.labels, coords=bcn.coords, time_matrix=bcn.time, max_time_work=6.0
... )  # 6-hour days: two of them (TwoOpt is deterministic, so the figures below are exact)
>>> out = Path(tempfile.mkdtemp())
>>> kml = to_kml(two, path=out / "plan.kml", trip_names=["Monday", "Tuesday"])
>>> root = ET.parse(kml).getroot()
>>> ns = {"k": "http://www.opengis.net/kml/2.2"}
>>> [f.findtext("k:name", namespaces=ns) for f in root.iterfind(".//k:Folder", ns)]
['Monday', 'Tuesday']
>>> [len(f.findall("k:Placemark", ns)) - 1 for f in root.iterfind(".//k:Folder", ns)]  # stops per day
[7, 11]
>>> (
...     root.find(".//k:Folder/k:Placemark/k:name", ns).text,
...     root.find(".//k:Folder/k:Placemark/k:description", ns).text,
... )
('1.1 23', 'Day 1, stop 1 of 7')

google_maps_html

google_maps_html(obj: Any, coords: Any = None, path: str | PathLike[str] | None = None, *, labels: Any = None, api_key: str | None = None, names: Any = None, trip_names: Any = None, title: str | None = None) -> Path

Write a standalone page that draws the plan on Google Maps with real road directions.

The page loads the Maps JavaScript API with api_key and, for every day, asks a DirectionsService for the roads — one request per leg of at most 25 waypoints, split like google_maps_urls — drawn by a DirectionsRenderer in the day's colour with numbered markers at the stops and a star at the depot. A legend lists the days with a checkbox each (hiding a day hides its roads and markers), the stops and the driving minutes; a leg whose request fails (quota, an unroutable point) is drawn as a dashed straight line and reported under the legend. The plan itself is embedded as one JSON object (<script type="application/json" id="skroute-plan">), computed here: trips with lat/lon/names lists, legs, color, n_stops and — when the solution carries a time matrix, assumed in minutes as travel_time_matrix (D33) returns it — driving_minutes; totals with n_trips, n_stops and the same minutes. Directions requests are billed to the key's project.

Parameters:

Name Type Description Default
obj fitted estimator, RouteEvent or route required
coords (n, 2) array-like

(latitude, longitude) in matrix row order; default: the coordinates of the fit.

None
path str or path - like

Where to write the page (.html). Required — positional after coords, so google_maps_html(est, None, "plan.html") and google_maps_html(est, path=...) both work.

None
labels sequence of n hashables

For a plain route: the label of each row of coords.

None
api_key str

A Maps JavaScript API key (Directions API enabled); default: the GOOGLE_MAPS_API_KEY environment variable. The key is written into the page — share the file accordingly.

None
names sequence of n str or mapping {label: str}

A display name per node (marker tooltips, the legend); default: the labels. A depot left unnamed (None, or a mapping without its label) reads "Depot (<label>)".

None
trip_names sequence of str

One name per day; default "Day 1", "Day 2"...

None
title str

Page title; default "<Solver> plan".

None

Returns:

Name Type Description
path Path

The file written.

Raises:

Type Description
TypeError

Without path (also when a path was given in the coords slot), or when names or trip_names is a single str rather than a sequence.

ValueError

Without a key: "no Google Maps API key: pass api_key= or set GOOGLE_MAPS_API_KEY"; for the inputs google_maps_urls rejects (no or invalid coordinates, a route visiting a node twice or none...); names or trip_names of the wrong length.

NotFittedError

For an estimator that has not been fitted.

Notes

The days take the ten colours of PALETTE in turn, so from the eleventh day on they repeat. The Directions requests go out a couple at a time and a leg refused with OVER_QUERY_LIMIT is asked once more before it falls back to the dashed line. The markers are google.maps.Marker, which Google deprecated in February 2024 in favour of AdvancedMarkerElement but keeps serving (the browser console notes it); a later release may switch.

Examples:

>>> import json, re, tempfile
>>> from pathlib import Path
>>> from skroute import TwoOpt
>>> from skroute.datasets import load_barcelona
>>> from skroute.viz import google_maps_html
>>> bcn = load_barcelona()
>>> two = TwoOpt().fit(
...     bcn.cost, labels=bcn.labels, coords=bcn.coords, time_matrix=bcn.time * 60, max_time_work=360
... )  # the bundled times are hours: minutes for the page (TwoOpt is deterministic: exact figures)
>>> out = Path(tempfile.mkdtemp())
>>> page = google_maps_html(two, path=out / "plan.html", api_key="AIza-demo", title="Barcelona, two days")
>>> text = page.read_text(encoding="utf-8")
>>> text.count("AIza-demo"), 'src="https://maps.googleapis.com/maps/api/js?key=AIza-demo' in text
(1, True)
>>> plan = json.loads(re.search(r'id="skroute-plan">(.*?)</script>', text, re.S).group(1))
>>> plan["totals"], [t["n_stops"] for t in plan["trips"]], plan["trips"][0]["legs"]
({'n_trips': 2, 'n_stops': 18, 'driving_minutes': 676.7}, [7, 11], [[0, 8]])

LivePlot

LivePlot(coords: Any, *, backend: str | None = None, map: bool = False, every: int = 1, show: str = 'both', trail: int = 0, figsize: tuple[float, float] = (7, 7), title: str | None = None, pause: float = 0.001, trip_colors: bool = True)

Watch a solver work: a callback that redraws the current and best tours as fit runs.

Pass an instance as fit(..., callback=live). On the "start" event it creates the figure (the nodes, the depot as a star); on every every-th "iteration" event it redraws the current tour as a thin light line and the best tour so far as a thick line, and refreshes the title with the solver, the iteration, the current and best costs and one solver-specific fact of event.extra — the most informative scalar (temperature, tenure, mean_cost, generation, radius...; restart always); on "end" it draws the final best route with one colour per trip. It never blocks and never calls plt.show() — in a script the window appears through plt.pause, so add plt.show() after fit to keep it open; in Jupyter the figure is refreshed in place.

The three optional extra keys of D31 are drawn whenever an event carries them: extra["edges"] — the partial structure a solver holds (the growing path of a nearest neighbour, the partial cycle of an insertion, the LP support of MILP) — as orange segments, which with tour=None is the picture of the route being built; extra["edge_weights"] turns those segments into pheromone trails drawn under the tours, wider and more opaque the stronger the trail; extra["ring"] — SOM's neurons — as a closed teal polyline with small markers. An event without a key clears that drawing.

One instance can watch several fits: a "start" that begins a new run (after the previous "end") opens a fresh figure and forgets a pending stop(); the nested "start" and "end" of the restarts a MultiStart(n_jobs=1) forwards reset the lines in place, so every restart is drawn from scratch and its final route stays only until the next one begins. In a script the figure is left open after fit (plt.close(live.fig) when watching many runs); under %matplotlib inline it is closed after the final display, so the cell shows the last picture once.

Parameters:

Name Type Description Default
coords (n, 2) array-like

Node positions in matrix row order; column 0 is x and column 1 is y (with map=True: latitude, longitude).

required
backend ('matplotlib', 'plotly')

"matplotlib" (the default) draws in a matplotlib figure (interactive backends show a window; headless Agg works silently: the artists are updated on every kept iteration and rendered once at "end"); "plotly" — the default with map=True — draws in a FigureWidget updated in place inside a notebook, or in a plain Figure shown once at "end" elsewhere.

"matplotlib"
map bool

Draw on OpenStreetMap tiles (Plotly Scattermap, so the plotly backend is selected; backend="matplotlib" with map=True is refused) with the coordinates as (latitude, longitude).

False
every int >= 1

Redraw on every every-th iteration event (a redraw costs milliseconds; a fast solver emits thousands of iterations per second).

1
show ('both', 'best', 'current')

Which tours to draw during the run: the current attempt (thin) and the best so far (thick), only the best, or only the current. The final route at "end" and the D31 structures are drawn regardless.

"both"
trail int >= 0

Keep the last trail current tours on screen, fading with age (a sense of where the search has been); 0 draws the current tour alone.

0
figsize tuple of two floats

Size of the matplotlib figure in inches.

(7, 7)
title str

Text that replaces the solver's name in the status line.

None
pause float

Seconds handed to plt.pause after each redraw (interactive matplotlib backends only).

0.001
trip_colors bool

One colour per trip on the final route; False draws every trip alike.

True

Attributes:

Name Type Description
fig matplotlib Figure, plotly Figure or None

Created on the first event.

ax matplotlib Axes or None

The axes (matplotlib backend only).

n_events int

Events received so far.

n_redraws int

Times the picture was refreshed (start, the kept iterations, the start of every nested restart and end).

Notes

Redraws happen on the thread that runs fit: matplotlib is not thread-safe, which is why MultiStart forwards the callback only with n_jobs=1. Calling stop() makes the callback return True at the next event, which asks the solver to stop after its current iteration (stop_reason_ == "callback"). With a desktop window keep fit on the main thread and call stop() from a key handler on live.fig; running fit in a worker thread and stopping it from another cell is safe under %matplotlib inline and with the plotly backend, whose redraws are IPython display calls rather than GUI drawing.

With the matplotlib backend the artists sit in a fixed order: ax.lines[0] is the current tour, ax.lines[1] the best, ax.lines[2] the edges, ax.lines[3] the ring, then the trail fading tours, and the final route's lines are appended at "end"; the pheromone trails are a LineCollection in ax.collections.

Examples:

>>> from skroute import SimulatedAnnealing
>>> from skroute.datasets import load_tsp
>>> from skroute.viz import LivePlot
>>> wi = load_tsp("wi29")
>>> live = LivePlot(wi.coords, every=20)
>>> live.n_events, live.fig is None  # nothing is drawn (or imported) before the first event
(0, True)
>>> sa = SimulatedAnnealing(random_state=0).fit(
...     wi.distance_matrix(), labels=wi.labels, callback=live
... )
>>> attempts = LivePlot(wi.coords, show="current", trail=5)  # the walker and its five last positions
>>> LivePlot(wi.coords, map=True).backend  # tiles are drawn by Plotly: map=True selects it
'plotly'
>>> LivePlot(wi.coords, backend="matplotlib", map=True)
Traceback (most recent call last):
    ...
ValueError: map=True needs backend="plotly": OpenStreetMap tiles are drawn with Plotly's Scattermap

stop

stop() -> None

Ask the solver to stop: the callback returns True at its next event (reset by a new run).

Recorder

Recorder(every: int = 1, keep_tours: bool = True)

Record the events of a run to replay them later: live, as an animation, a file or a Plotly slider.

Pass an instance as fit(..., callback=rec). Nothing is drawn during the run; the events are copied into events (label-space tours and the D31 structures — extra["edges"], extra["edge_weights"], extra["ring"] — included unless keep_tours=False) with a wall-clock stamp each, and the run can then be replayed:

  • animate — a matplotlib FuncAnimation drawing the current tour thin, the best tour thick and the structures, frame by frame in the recorded order, at the recorded pace divided by speed (a time-lapse) or at a fixed fps;
  • replay — the same events driven through a LivePlot at speed times the recorded pace, in a window or a notebook;
  • save — a GIF (pillow) or an MP4 (ffmpeg) of the animation;
  • to_plotly — a figure with frames, Play/Pause, a speed menu and a slider, optionally on OpenStreetMap tiles;
  • plot_history — the best-so-far curve of the kept events.

A recorder accumulates: handed to a second fit it appends that run's events (each event keeps a reference to its own problem, and every counts from the run's first iteration again). The replays draw every kept event on one set of coordinates, so build a new recorder per instance you want to replay.

Parameters:

Name Type Description Default
every int >= 1

Keep every every-th "iteration" event ("start" and "end" are always kept).

1
keep_tours bool

Store tour, best_tour and the D31 structures; False keeps the costs and the scalar facts only (the replays then have nothing to draw and raise ValueError).

True

Attributes:

Name Type Description
events list of RecordedEvent

The kept events, in order.

problem RoutingProblem or None

The instance of the latest run recorded (every event also carries its own).

costs ndarray of shape (n_events,), float64

cost of every kept event (nan when the solver reported none).

best_costs ndarray of shape (n_events,), float64

best_cost of every kept event, non-increasing (nan while unknown).

iterations ndarray of shape (n_events,), int64

iteration of every kept event.

timestamps ndarray of shape (n_events,), float64

time.perf_counter() of every kept event, non-decreasing; timestamps - timestamps[0] is the recorded clock of the run.

n_frames int

Kept events with something to draw — a tour, a best tour, edges or a ring: the frames of animate, save and to_plotly.

Examples:

A deterministic descent (three events) recorded and replayed headless:

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> import numpy as np
>>> from skroute import TwoOpt
>>> from skroute.viz import Recorder
>>> C = np.array([[0, 5, 9, 10], [5, 0, 4, 8], [9, 4, 0, 3], [10, 8, 3, 0]], dtype=float)
>>> xy = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
>>> rec = Recorder()
>>> est = TwoOpt().fit(C, callback=rec)
>>> [e.stage for e in rec.events], rec.n_frames
(['start', 'iteration', 'end'], 3)
>>> bool(np.all(np.diff(rec.timestamps) >= 0))
True
>>> rec.frame_delays(speed=1e9).tolist()  # microsecond gaps at 1e9x: clipped to the 10 ms floor
[10.0, 10.0, 10.0]
>>> anim = rec.animate(xy, fps=5)  # a FuncAnimation: plt.show() plays it
>>> live = rec.replay(xy, speed=1e9)  # drives a LivePlot through the three events, no waiting
>>> live.n_events
3

A real run, recorded every fifth iteration and saved as a time-lapse GIF:

>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_tsp
>>> dj = load_tsp("dj38")
>>> rec = Recorder(every=5)
>>> ils = IteratedLocalSearch(random_state=0).fit(
...     dj.distance_matrix(), labels=dj.labels, coords=dj.coords, callback=rec
... )
>>> rec.save("dj38.gif", speed=10)  # coords come from the fit; 10x time-lapse
>>> rec.to_plotly(dj.coords).show()  # Play/Pause, a speed menu and a slider

costs property

costs: ndarray

cost of every kept event (nan when unknown).

best_costs property

best_costs: ndarray

best_cost of every kept event.

iterations property

iterations: ndarray

iteration of every kept event.

timestamps property

timestamps: ndarray

time.perf_counter() of every kept event (seconds; non-decreasing).

n_frames property

n_frames: int

Kept events with something to draw: the frames of animate, save and to_plotly.

frame_delays

frame_delays(*, speed: float = 1.0) -> ndarray

Milliseconds each frame of animate stays on screen when the run is replayed at speed.

Frame k stays for the recorded gap between it and the next drawable event divided by speed — speed=10 is a ten-fold time-lapse — clipped to [10, 2000] ms so a burst of iterations never flashes past and a slow step (a cut round of MILP, a pause) never freezes the replay; the last frame keeps the delay of the one before it. The floor is per frame, so once the scaled gaps are under 10 ms — more than a hundred kept events per replayed second — a higher speed no longer shortens the animation: thin the frames with Recorder(every=) instead.

Parameters:

Name Type Description Default
speed float > 0

Time-lapse factor over the recorded clock.

1.0

Returns:

Name Type Description
delays ndarray of shape (n_frames,), float64

Per-frame delays in milliseconds, in [10, 2000].

animate

animate(coords: Any = None, *, show: str = 'both', interval: float | None = None, fps: float | None = None, speed: float = 1.0, hold: float = 1.0, trail: int = 0, figsize: tuple[float, float] = (7, 7), trip_colors: bool = True) -> FuncAnimation

Replay the kept events as a matplotlib animation, in the recorded order.

Every frame is drawn the way LivePlot draws a live event: the current tour thin, the best tour thick, extra["edges"] as segments, extra["edge_weights"] as pheromone trails, extra["ring"] as the SOM ring, the status line as the title, and the final route with trip colours at "end".

Parameters:

Name Type Description Default
coords (n, 2) array-like

Node positions in matrix row order (x, y); default: the recorded problem's coords.

None
show ('both', 'best', 'current')

Which tours to draw (the structures are always drawn).

"both"
interval float

A fixed delay between frames, in milliseconds (takes precedence over fps and speed).

None
fps float

A fixed frame rate: every frame lasts 1000 / fps ms (takes precedence over speed).

None
speed float > 0

Without interval and fps, the frames follow the recorded clock divided by speed — speed=10 replays a 30 s run of frames a fifth of a second apart in about 3 s — with every delay clipped to [10, 2000] ms (see frame_delays: the floor is per frame, so a denser run stretches rather than speeds up; thin it with Recorder(every=)).

1.0
hold float >= 0

Seconds the final picture stays before the animation loops.

1.0
trail int >= 0

Fading copies of the last trail current tours (as LivePlot(trail=)).

0
figsize tuple of two floats

Figure size in inches.

(7, 7)
trip_colors bool

One colour per trip on the final route.

True

Returns:

Name Type Description
anim FuncAnimation

One frame per kept drawable event (n_frames). plt.show() plays it in a script; in a notebook show it with IPython.display.HTML(anim.to_jshtml()); save writes it to a file (anim.save and to_jshtml work too, at one fixed frame rate: pass them fps=). Under %matplotlib inline the figure is closed right away (the animation keeps its own reference), so the cell does not end with a stray empty picture.

save

save(path: str | Path, coords: Any = None, *, fps: int = 20, dpi: int = 80, show: str = 'both', speed: float | None = None, hold: float = 1.0, trail: int = 0, figsize: tuple[float, float] = (6, 6), trip_colors: bool = True) -> Path

Write the replay to a file: a GIF through pillow, or any format ffmpeg writes (.mp4...).

Parameters:

Name Type Description Default
path str or Path

Destination; the suffix picks the writer (.gif -> pillow, anything else -> ffmpeg).

required
coords (n, 2) array-like

Node positions (x, y); default: the recorded problem's coords.

None
fps int > 0

Frames per second of the file: without speed every kept event is one frame.

20
dpi int

Resolution of the frames (figsize x dpi pixels: 480 x 480 by default).

80
show ('both', 'best', 'current')

Which tours to draw.

"both"
speed float > 0

Time-lapse factor: each frame is held for its recorded gap divided by speed, clipped to [10, 2000] ms like frame_delays and rounded to whole frames of 1 / fps s (a GIF folds the repeated frames, so a time-lapse costs no extra bytes).

None
hold float >= 0

Seconds the final picture stays at the end of the file.

1.0
trail int

As in animate.

0
figsize int

As in animate.

0
trip_colors int

As in animate.

0

Returns:

Name Type Description
path Path

The file written.

Raises:

Type Description
RuntimeError

For a non-GIF suffix when ffmpeg is not on the PATH.

replay

replay(coords: Any = None, *, speed: float = 10.0, every: int = 1, backend: str | None = None, show: str = 'both', **live_kwargs: Any) -> LivePlot

Drive a LivePlot through the recorded events at time-lapse speed.

The events are handed to a fresh LivePlot in the recorded order, on the recorded clock divided by speed: event k is drawn (timestamps[k] - timestamps[0]) / speed seconds after the first one, so the replay lasts the recorded span over speed and looks like the run did. The time a redraw takes is absorbed by the waits that follow it (a run drawn slower than its pace plays as fast as it can be drawn), and a gap longer than two seconds — a pause, a slow cut round — is cut down to two. It blocks until the last event. The waits only make sense where the frames are seen as they are drawn — an interactive matplotlib window, a notebook, the Plotly widget; on a headless backend (Agg, or the plotly backend outside a notebook) nothing shows before "end", so the events are drawn back to back and the call returns at once.

Parameters:

Name Type Description Default
coords (n, 2) array-like

Node positions; default: the recorded problem's coords.

None
speed float > 0

Time-lapse factor (1.0 replays in real time; a huge value skips every wait).

10.0
every int >= 1

Redraw every every-th iteration event (as LivePlot(every=)).

1
backend ('matplotlib', 'plotly')

Drawing backend of the LivePlot: "matplotlib" by default, "plotly" when map=True is among live_kwargs (the tiles are drawn by Plotly).

"matplotlib"
show ('both', 'best', 'current')

Which tours to draw.

"both"
**live_kwargs Any

Other LivePlot options: trail, map, figsize, title, pause, trip_colors.

{}

Returns:

Name Type Description
live LivePlot

The plot that was driven (live.fig holds the final picture).

Raises:

Type Description
ValueError

Without events, without a RoutingProblem on every event, or with nothing to draw (a recorder built with keep_tours=False), like the other replays.

to_plotly

to_plotly(coords: Any = None, *, map: bool = False, show: str = 'both', fps: float = 20) -> Any

Replay the run as a Plotly figure: one frame per kept event, Play/Pause, a speed menu and a slider.

Parameters:

Name Type Description Default
coords (n, 2) array-like

Node positions in matrix row order: (x, y), or (latitude, longitude) when map=True; default: the recorded problem's coords.

None
map bool

Draw on OpenStreetMap tiles (Scattermap) instead of plain axes.

False
show ('both', 'best', 'current')

Which tours to draw (the edges and the ring are always drawn).

"both"
fps float > 0

Frame rate of the Play button (1x); the speed menu offers 0.5x, 1x, 2x, 4x and 8x.

20

Returns:

Name Type Description
fig Figure

Nodes, depot and the first frame's current, best, edges and ring traces as base traces; fig.frames holds one frame per kept drawable event; Play/Pause buttons, a speed dropdown and a slider drive them.

plot_history

plot_history(ax: Axes | None = None) -> Axes

Best-so-far cost of the kept iteration events (see plot_history).

RecordedEvent dataclass

RecordedEvent(solver: str, stage: str, iteration: int, cost: float, best_cost: float, tour: ndarray | None, best_tour: ndarray | None, extra: dict[str, Any] = dict(), problem: Any = None, timestamp: float = 0.0)

The copy of a progress event a Recorder keeps.

Attributes:

Name Type Description
solver str

Class name of the solver.

stage {'start', 'iteration', 'end'}
iteration int

Outer iteration index (0 at start).

cost float

Objective of tour (nan when unknown).

best_cost float

Best objective so far.

tour ndarray or None

Label-space open giant tour of the current solution (None when the solver had none, or when the recorder was built with keep_tours=False).

best_tour ndarray or None

Label-space best-so-far tour (same conventions).

extra dict

Solver-specific facts (temperature, tenure, generation...). The D31 structures are copied: edges as a list of (label, label) tuples, edge_weights and ring as float64 arrays (all three dropped with keep_tours=False).

problem RoutingProblem or None

The instance the event belongs to (a reference, shared by the events of one run), so a recorded event can be drawn with plot_route like a live one.

timestamp float

time.perf_counter() when the event was recorded, in seconds: only the differences between two events of one recorder mean anything (they are the gaps a replay scales).

trips list of ndarray

best_tour decoded into closed label trips [depot, ..., depot] with the problem's split rule, as RouteEvent.trips; [] without a tour (or a problem).

route ndarray or None

best_tour as driven — depot, trip 1, depot, ..., depot — as RouteEvent.route; None without a tour.

drawable bool

Whether the event carries anything to draw — a tour, a best tour, edges or a ring — and so is a frame of animate, save and to_plotly.

trips property

trips: list[ndarray]

best_tour decoded into closed label trips [depot, ..., depot]; [] without a tour.

route property

route: ndarray | None

best_tour as driven — depot, trip 1, depot, ..., depot — or None without a tour.

drawable property

drawable: bool

Whether the event carries anything to draw: a tour, a best tour, edges or a ring.