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 |
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 ( |
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; |
True
|
**line_kwargs
|
Any
|
Forwarded to |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
ax |
matplotlib Axes
|
Equal aspect, no ticks, one |
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 ¶
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 |
required |
ax
|
matplotlib Axes
|
Draw into these axes; a new figure otherwise. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
ax |
matplotlib Axes
|
A step plot ( |
Raises:
| Type | Description |
|---|---|
NotFittedError
|
For an estimator that was not fitted. |
ValueError
|
For a fitted estimator without |
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 |
required |
coords
|
(n, 2) array-like
|
|
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 |
None
|
trip_names
|
sequence of str
|
One name per trip, shown in a legend ( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
|
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 |
required |
coords
|
(n, 2) array-like
|
|
None
|
labels
|
sequence of n hashables
|
For a plain route: the label of each row of |
None
|
mode
|
('driving', 'walking', 'bicycling', 'transit')
|
The |
"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 |
9
|
Returns:
| Name | Type | Description |
|---|---|---|
urls |
list of list of str
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
Without coordinates, for coordinates outside the latitude/longitude ranges or with the
wrong number of rows, for |
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
|
As in |
required |
coords
|
(n, 2) array-like
|
|
None
|
path
|
str or path - like
|
Where to write the file ( |
None
|
labels
|
sequence of n hashables
|
For a plain route: the label of each row of |
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 |
None
|
colors
|
sequence of str
|
Hex colours ( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
path |
Path
|
The file written. |
Raises:
| Type | Description |
|---|---|
TypeError
|
Without |
ValueError
|
For the inputs |
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
|
As in |
required |
coords
|
(n, 2) array-like
|
|
None
|
path
|
str or path - like
|
Where to write the page ( |
None
|
labels
|
sequence of n hashables
|
For a plain route: the label of each row of |
None
|
api_key
|
str
|
A Maps JavaScript API key (Directions API enabled); default: the |
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
|
trip_names
|
sequence of str
|
One name per day; default |
None
|
title
|
str
|
Page title; default |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
path |
Path
|
The file written. |
Raises:
| Type | Description |
|---|---|
TypeError
|
Without |
ValueError
|
Without a key: |
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
|
required |
backend
|
('matplotlib', 'plotly')
|
|
"matplotlib"
|
map
|
bool
|
Draw on OpenStreetMap tiles (Plotly |
False
|
every
|
int >= 1
|
Redraw on every |
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 |
"both"
|
trail
|
int >= 0
|
Keep the last |
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 |
0.001
|
trip_colors
|
bool
|
One colour per trip on the final route; |
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 ¶
Ask the solver to stop: the callback returns True at its next event (reset by a new run).
Recorder ¶
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 matplotlibFuncAnimationdrawing the current tour thin, the best tour thick and the structures, frame by frame in the recorded order, at the recorded pace divided byspeed(a time-lapse) or at a fixedfps;replay— the same events driven through aLivePlotatspeedtimes 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 |
1
|
keep_tours
|
bool
|
Store |
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
|
|
best_costs |
ndarray of shape (n_events,), float64
|
|
iterations |
ndarray of shape (n_events,), int64
|
|
timestamps |
ndarray of shape (n_events,), float64
|
|
n_frames |
int
|
Kept events with something to draw — a tour, a best tour, edges or a ring: the frames of
|
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
timestamps
property
¶
time.perf_counter() of every kept event (seconds; non-decreasing).
n_frames
property
¶
Kept events with something to draw: the frames of animate, save and to_plotly.
frame_delays ¶
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 |
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 |
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 |
None
|
fps
|
float
|
A fixed frame rate: every frame lasts |
None
|
speed
|
float > 0
|
Without |
1.0
|
hold
|
float >= 0
|
Seconds the final picture stays before the animation loops. |
1.0
|
trail
|
int >= 0
|
Fading copies of the last |
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 ( |
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 ( |
required |
coords
|
(n, 2) array-like
|
Node positions (x, y); default: the recorded problem's |
None
|
fps
|
int > 0
|
Frames per second of the file: without |
20
|
dpi
|
int
|
Resolution of the frames ( |
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 |
None
|
hold
|
float >= 0
|
Seconds the final picture stays at the end of the file. |
1.0
|
trail
|
int
|
As in |
0
|
figsize
|
int
|
As in |
0
|
trip_colors
|
int
|
As in |
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 |
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 |
None
|
speed
|
float > 0
|
Time-lapse factor ( |
10.0
|
every
|
int >= 1
|
Redraw every |
1
|
backend
|
('matplotlib', 'plotly')
|
Drawing backend of the |
"matplotlib"
|
show
|
('both', 'best', 'current')
|
Which tours to draw. |
"both"
|
**live_kwargs
|
Any
|
Other |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
live |
LivePlot
|
The plot that was driven ( |
Raises:
| Type | Description |
|---|---|
ValueError
|
Without events, without a |
to_plotly ¶
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 |
None
|
map
|
bool
|
Draw on OpenStreetMap tiles ( |
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 ( |
20
|
Returns:
| Name | Type | Description |
|---|---|---|
fig |
Figure
|
Nodes, depot and the first frame's current, best, edges and ring traces as base traces;
|
plot_history ¶
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 |
best_cost |
float
|
Best objective so far. |
tour |
ndarray or None
|
Label-space open giant tour of the current solution ( |
best_tour |
ndarray or None
|
Label-space best-so-far tour (same conventions). |
extra |
dict
|
Solver-specific facts ( |
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 |
timestamp |
float
|
|
trips |
list of ndarray
|
|
route |
ndarray or None
|
|
drawable |
bool
|
Whether the event carries anything to draw — a tour, a best tour, edges or a ring — and
so is a frame of |