tmapper-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tmapper/modularity.py ADDED
@@ -0,0 +1,74 @@
1
+ """Port of tmapper_tools/Qasym.m and calMod.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def qasym(A, C):
7
+ """Modularity measure of an asymmetric, weighted network.
8
+
9
+ Port of MATLAB's ``Qasym.m``. Adapted from the canonical modularity
10
+ definition (Fortunato 2010, Physics Reports) for asymmetric networks
11
+ with weighted edges.
12
+
13
+ Parameters
14
+ ----------
15
+ A : array_like, shape (N, N)
16
+ Adjacency matrix, which may or may not be symmetric.
17
+ C : array_like, shape (N,)
18
+ Community assignment of each node.
19
+
20
+ Returns
21
+ -------
22
+ float
23
+ Modularity Q. Returns 0 for a zero-edge network (neither modular
24
+ nor non-modular).
25
+ """
26
+ A = np.asarray(A, dtype=float)
27
+ N_edges = A.sum() # "2m" in other notations
28
+
29
+ if N_edges == 0:
30
+ return 0.0
31
+
32
+ k_source = A.sum(axis=1, keepdims=True) # source degree
33
+ k_sink = A.sum(axis=0, keepdims=True) # sink degree
34
+ P_ij = k_source @ k_sink / N_edges # null model
35
+
36
+ C = np.asarray(C).ravel()
37
+ same_community = C[:, None] == C[None, :]
38
+
39
+ return float(np.sum((A - P_ij) * same_community) / N_edges)
40
+
41
+
42
+ def cal_mod(W, m0):
43
+ """Modularity score of a (typically symmetric) network given a
44
+ community assignment.
45
+
46
+ Port of MATLAB's ``calMod.m``.
47
+
48
+ Parameters
49
+ ----------
50
+ W : array_like, shape (N, N)
51
+ Graph adjacency matrix.
52
+ m0 : array_like, shape (N,)
53
+ Categorical node labels / community assignment.
54
+
55
+ Returns
56
+ -------
57
+ float
58
+ Modularity score. Returns 0 for a zero-edge network (neither
59
+ modular nor non-modular).
60
+ """
61
+ W = np.asarray(W, dtype=float)
62
+ s = W.sum() # sum of degrees of nodes (assuming symmetric)
63
+
64
+ if s == 0:
65
+ return 0.0
66
+
67
+ gamma = 1 # scaling parameter
68
+ B = (W - gamma * (W.sum(axis=1, keepdims=True) @ W.sum(axis=0, keepdims=True)) / s) / s
69
+ B = (B + B.T) / 2 # symmetrize
70
+
71
+ m0 = np.asarray(m0).ravel()
72
+ same_community = m0[:, None] == m0[None, :]
73
+
74
+ return float(B[same_community].sum())
@@ -0,0 +1,35 @@
1
+ """Port of tmapper_tools/pathtraffic.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def path_traffic(allpath, nodesize):
7
+ """Compute traffic statistics (based on node size) along each path.
8
+
9
+ Port of MATLAB's ``pathtraffic.m``. Note: matches MATLAB's ``std()``
10
+ convention of dividing by ``N-1`` (sample standard deviation), not
11
+ numpy's default ``N``.
12
+
13
+ Parameters
14
+ ----------
15
+ allpath : sequence of sequence of int
16
+ Each element is a path of node indices.
17
+ nodesize : array_like
18
+ Size of each node, indexed the same way as the indices in
19
+ ``allpath``.
20
+
21
+ Returns
22
+ -------
23
+ traf_mean, traf_med, traf_min, traf_max, traf_std : numpy.ndarray
24
+ One value per path in ``allpath``.
25
+ """
26
+ nodesize = np.asarray(nodesize, dtype=float)
27
+ path_nodesize = [nodesize[np.asarray(p, dtype=int)] for p in allpath]
28
+
29
+ traf_mean = np.array([p.mean() for p in path_nodesize])
30
+ traf_med = np.array([np.median(p) for p in path_nodesize])
31
+ traf_min = np.array([p.min() for p in path_nodesize])
32
+ traf_max = np.array([p.max() for p in path_nodesize])
33
+ traf_std = np.array([p.std(ddof=1) if len(p) > 1 else 0.0 for p in path_nodesize])
34
+
35
+ return traf_mean, traf_med, traf_min, traf_max, traf_std
tmapper/plotting.py ADDED
@@ -0,0 +1,463 @@
1
+ """Port of tmapper_tools/plottmgraph.m and plotgraphtcm.m from the MATLAB
2
+ toolbox."""
3
+
4
+ import numpy as np
5
+ import networkx as nx
6
+ from scipy import stats
7
+
8
+ from .labeling import find_node_label
9
+ from .tcm_distance import tcm_distance
10
+ from ._shortest_path import all_pairs_distance
11
+
12
+
13
+ def _rescale(x, lo, hi):
14
+ """Linearly map x's [min, max] to [lo, hi], matching MATLAB's rescale."""
15
+ x = np.asarray(x, dtype=float)
16
+ xmin, xmax = x.min(), x.max()
17
+ return lo + (x - xmin) / (xmax - xmin) * (hi - lo)
18
+
19
+
20
+ def _normalize_positions(pos):
21
+ """Rescale a layout so the farthest node sits at radius 1."""
22
+ pts = np.array(list(pos.values()))
23
+ max_r = np.max(np.linalg.norm(pts, axis=1))
24
+ scale = 1.0 / max_r if max_r > 0 else 1.0
25
+ return {n: xy * scale for n, xy in pos.items()}
26
+
27
+
28
+ def _expand_center(pos, gamma):
29
+ """Log-radial de-clumping: remap each node's radius r -> log1p(gamma*r).
30
+
31
+ Force-directed layouts (spring/ForceAtlas2) tend to pack the bulk of
32
+ nodes into a dense central clump with only a few outliers stretching
33
+ the frame. Since log1p(gamma*r)/r is largest at small r and flattens
34
+ out at large r, this pushes the dense core outward proportionally
35
+ more than the already-spread-out periphery, without disturbing
36
+ angular position or relative radial order.
37
+ """
38
+ out = {}
39
+ for n, xy in pos.items():
40
+ r = np.linalg.norm(xy)
41
+ if r > 0:
42
+ rn = np.log1p(gamma * r)
43
+ out[n] = xy * (rn / r)
44
+ else:
45
+ out[n] = xy
46
+ return out
47
+
48
+
49
+ def _prepare_node_size(nodemembers, nodesizerange, nodesizemode):
50
+ """Shared node-size computation for :func:`plot_tmgraph` and
51
+ :func:`plot_tmgraph_interactive`: member count -> optional
52
+ rank/log transform -> rescaled to ``nodesizerange``."""
53
+ n_nodes = len(nodemembers)
54
+ nodesize = np.array([len(m) for m in nodemembers], dtype=float)
55
+ buniform = len(set(nodesize.tolist())) == 1
56
+ if not buniform:
57
+ if nodesizemode == "rank":
58
+ nodesize = stats.rankdata(nodesize, method="average")
59
+ elif nodesizemode == "log":
60
+ nodesize = np.log10(nodesize)
61
+ # 'original' (or anything else): leave nodesize as-is, matching
62
+ # MATLAB's switch with no matching/default case
63
+ nodesize = _rescale(nodesize, nodesizerange[0], nodesizerange[1])
64
+ else:
65
+ span = nodesizerange[1] - nodesizerange[0]
66
+ nodesize = np.full(n_nodes, nodesizerange[0] + span / n_nodes)
67
+ return nodesize
68
+
69
+
70
+ def _compute_layout(g, nodelist, center_expand):
71
+ """Shared layout computation for :func:`plot_tmgraph` and
72
+ :func:`plot_tmgraph_interactive`: igraph's DrL layout (falls back to
73
+ spring_layout for the trivial 1-node case), normalized to the unit
74
+ circle and optionally de-clumped. See :func:`_expand_center`."""
75
+ n_nodes = len(nodelist)
76
+ if n_nodes > 1:
77
+ import igraph as ig
78
+
79
+ idx_of = {node: i for i, node in enumerate(nodelist)}
80
+ edges = [(idx_of[u], idx_of[v]) for u, v in g.edges()]
81
+ g_ig = ig.Graph(n=n_nodes, edges=edges, directed=g.is_directed())
82
+ coords = np.array(g_ig.layout_drl().coords)
83
+ pos = {nodelist[i]: coords[i] for i in range(n_nodes)}
84
+ else:
85
+ pos = nx.spring_layout(g, weight=None, seed=0)
86
+
87
+ pos = _normalize_positions(pos)
88
+ if center_expand:
89
+ pos = _expand_center(pos, center_expand)
90
+ return pos
91
+
92
+
93
+ def plot_tmgraph(
94
+ g,
95
+ x_label=None,
96
+ nodemembers=None,
97
+ *,
98
+ ax=None,
99
+ nodesizerange=(1, 10),
100
+ nodesizemode="log",
101
+ colorlabel="x_label",
102
+ cmap="jet",
103
+ labelmethod="mode",
104
+ nodeclim=None,
105
+ nodescatter=False,
106
+ center_expand=4.0,
107
+ ):
108
+ """Plot a temporal mapper graph (without a recurrence plot).
109
+
110
+ Port of MATLAB's ``plottmgraph.m``.
111
+
112
+ Parameters
113
+ ----------
114
+ g : networkx.Graph or networkx.DiGraph
115
+ The graph to plot.
116
+ x_label : array_like, optional
117
+ A label for each member of each node, indexed the same way as the
118
+ indices inside ``nodemembers``. Defaults to a constant array of
119
+ ones sized to the number of unique members across all nodes.
120
+ nodemembers : sequence of sequence of int, optional
121
+ ``nodemembers[n]`` gives the indices belonging to node n. Defaults
122
+ to one singleton member per node (0..N-1).
123
+ ax : matplotlib.axes.Axes, optional
124
+ Axes to draw into. A new figure/axes is created if not given.
125
+ nodesizerange : (float, float), default (1, 10)
126
+ (min, max) marker size.
127
+ nodesizemode : {'log', 'rank', 'original'}, default 'log'
128
+ How to transform node sizes before rescaling to ``nodesizerange``.
129
+ colorlabel : str, default 'x_label'
130
+ Colorbar label.
131
+ cmap : str or matplotlib.colors.Colormap, default 'jet'
132
+ Colormap for node coloring.
133
+ labelmethod : {'mode', 'mean', 'median', 'none'} or callable, default 'mode'
134
+ See :func:`tmapper.labeling.find_node_label`.
135
+ nodeclim : (float, float), optional
136
+ Color axis limits. Defaults to (min(x_label), max(x_label)).
137
+ nodescatter : bool, default False
138
+ Whether to overlay a scatter plot on top of the graph nodes.
139
+ center_expand : float, default 4.0
140
+ Log-radial de-clumping strength applied to the layout after
141
+ normalizing it to the unit circle (0 disables it). Force-directed
142
+ layouts tend to pack most nodes into a dense central clump with a
143
+ few outliers stretching the frame; this spreads the dense core
144
+ outward without disturbing the outer structure. See
145
+ :func:`_expand_center`.
146
+
147
+ Returns
148
+ -------
149
+ ax : matplotlib.axes.Axes
150
+ cbar : matplotlib.colorbar.Colorbar
151
+ node_collection : matplotlib.collections.PathCollection
152
+ The drawn graph nodes.
153
+ scatter_collection : matplotlib.collections.PathCollection or None
154
+ The scatter overlay, if ``nodescatter`` is True.
155
+ """
156
+ import matplotlib.pyplot as plt
157
+
158
+ nodelist = list(g.nodes())
159
+ n_nodes = len(nodelist)
160
+
161
+ if nodemembers is None:
162
+ nodemembers = [[i] for i in range(n_nodes)]
163
+
164
+ if x_label is None:
165
+ all_members = sorted({m for members in nodemembers for m in members})
166
+ x_label = np.ones(len(all_members))
167
+ x_label = np.asarray(x_label, dtype=float)
168
+
169
+ if nodeclim is None:
170
+ nodeclim = (float(np.min(x_label)), float(np.max(x_label)))
171
+
172
+ # -- define node size and labels/colors
173
+ nodesize = _prepare_node_size(nodemembers, nodesizerange, nodesizemode)
174
+ nodelabel = find_node_label(nodemembers, x_label, labelmethod=labelmethod)
175
+
176
+ # -- plotting
177
+ if ax is None:
178
+ _, ax = plt.subplots()
179
+
180
+ pos = _compute_layout(g, nodelist, center_expand)
181
+
182
+ xs = np.array([pos[n][0] for n in nodelist])
183
+ ys = np.array([pos[n][1] for n in nodelist])
184
+
185
+ # Edges visible but behind (zorder=1); nodes outlined and drawn in
186
+ # front (zorder=3), smallest last, so small nodes aren't buried
187
+ # under large ones and stay crisp regardless of edge clutter.
188
+ edge_collection = nx.draw_networkx_edges(
189
+ g, pos, ax=ax, alpha=0.6, edge_color="#888888", width=0.8,
190
+ arrowsize=6, nodelist=nodelist,
191
+ )
192
+ for obj in (edge_collection if isinstance(edge_collection, list) else [edge_collection]):
193
+ if obj is not None:
194
+ obj.set_zorder(1)
195
+
196
+ order = np.argsort(nodesize, kind="stable")[::-1] # largest first, smallest drawn last (on top)
197
+ node_collection = ax.scatter(
198
+ xs[order], ys[order], s=(nodesize[order] ** 2), c=nodelabel[order], cmap=cmap,
199
+ vmin=nodeclim[0] if nodeclim[1] != nodeclim[0] else None,
200
+ vmax=nodeclim[1] if nodeclim[1] != nodeclim[0] else None,
201
+ edgecolors="black", linewidths=0.5, zorder=3,
202
+ )
203
+ ax.set_aspect("equal")
204
+ ax.axis("off")
205
+
206
+ sm = plt.cm.ScalarMappable(cmap=cmap)
207
+ sm.set_array(nodelabel)
208
+ if nodeclim[1] != nodeclim[0]:
209
+ sm.set_clim(nodeclim[0], nodeclim[1])
210
+ cbar = plt.colorbar(sm, ax=ax)
211
+ cbar.set_label(colorlabel)
212
+
213
+ scatter_collection = None
214
+ if nodescatter:
215
+ order = np.argsort(nodesize, kind="stable")
216
+ scatter_collection = ax.scatter(
217
+ xs[order], ys[order], s=(nodesize[order] ** 2),
218
+ c=nodelabel[order], cmap=cmap,
219
+ vmin=nodeclim[0] if nodeclim[1] != nodeclim[0] else None,
220
+ vmax=nodeclim[1] if nodeclim[1] != nodeclim[0] else None,
221
+ edgecolors="k", linewidths=0.2,
222
+ )
223
+
224
+ return ax, cbar, node_collection, scatter_collection
225
+
226
+
227
+ def plot_tmgraph_interactive(
228
+ g,
229
+ x_label=None,
230
+ nodemembers=None,
231
+ *,
232
+ nodesizerange=(6, 40),
233
+ nodesizemode="log",
234
+ colorlabel="x_label",
235
+ cmap="jet",
236
+ labelmethod="mode",
237
+ nodeclim=None,
238
+ center_expand=4.0,
239
+ title="",
240
+ output_path=None,
241
+ ):
242
+ """Plot a temporal mapper graph as a draggable/zoomable/hoverable
243
+ standalone HTML page (via pyvis/vis.js), instead of a static image.
244
+
245
+ Uses the same layout, node-size, and coloring logic as
246
+ :func:`plot_tmgraph` (so the two should look like the same network),
247
+ with physics disabled -- nodes stay exactly where the layout put
248
+ them, but can still be dragged, and hovering shows each node's size
249
+ and color-label value.
250
+
251
+ Parameters
252
+ ----------
253
+ g : networkx.Graph or networkx.DiGraph
254
+ The graph to plot.
255
+ x_label : array_like, optional
256
+ See :func:`plot_tmgraph`.
257
+ nodemembers : sequence of sequence of int, optional
258
+ See :func:`plot_tmgraph`.
259
+ nodesizerange : (float, float), default (6, 40)
260
+ (min, max) node radius in pixels (pyvis/vis.js sizes nodes by
261
+ radius, unlike matplotlib's area-based marker size -- so this
262
+ default is not directly comparable to :func:`plot_tmgraph`'s).
263
+ nodesizemode : {'log', 'rank', 'original'}, default 'log'
264
+ See :func:`plot_tmgraph`.
265
+ colorlabel : str, default 'x_label'
266
+ Legend label (rendered as a small embedded colorbar image).
267
+ cmap : str or matplotlib.colors.Colormap, default 'jet'
268
+ Colormap for node coloring -- anything matplotlib accepts,
269
+ including a discrete :class:`~matplotlib.colors.ListedColormap`
270
+ for categorical labels.
271
+ labelmethod : {'mode', 'mean', 'median', 'none'} or callable, default 'mode'
272
+ See :func:`tmapper.labeling.find_node_label`.
273
+ nodeclim : (float, float), optional
274
+ Color axis limits. Defaults to (min(x_label), max(x_label)).
275
+ center_expand : float, default 4.0
276
+ See :func:`plot_tmgraph`.
277
+ title : str, default ''
278
+ Page heading (e.g. summarize the construction parameters here).
279
+ output_path : str, optional
280
+ If given, also write the standalone HTML page to this path.
281
+ Default None -- no file is written; use the returned ``html``
282
+ string instead (e.g. pass it directly to
283
+ ``streamlit.components.v1.html(html, height=900)`` rather than
284
+ writing to disk and reading it back).
285
+
286
+ Returns
287
+ -------
288
+ net : pyvis.network.Network
289
+ The populated network.
290
+ html : str
291
+ The standalone HTML page as a string (already includes the
292
+ embedded colorbar legend) -- identical to what gets written to
293
+ ``output_path`` when one is given.
294
+ """
295
+ import base64
296
+ from io import BytesIO
297
+
298
+ import matplotlib.pyplot as plt
299
+ import matplotlib.colors as mcolors
300
+ from pyvis.network import Network
301
+
302
+ nodelist = list(g.nodes())
303
+ n_nodes = len(nodelist)
304
+
305
+ if nodemembers is None:
306
+ nodemembers = [[i] for i in range(n_nodes)]
307
+
308
+ if x_label is None:
309
+ all_members = sorted({m for members in nodemembers for m in members})
310
+ x_label = np.ones(len(all_members))
311
+ x_label = np.asarray(x_label, dtype=float)
312
+
313
+ if nodeclim is None:
314
+ nodeclim = (float(np.min(x_label)), float(np.max(x_label)))
315
+
316
+ nodesize = _prepare_node_size(nodemembers, nodesizerange, nodesizemode)
317
+ nodelabel = find_node_label(nodemembers, x_label, labelmethod=labelmethod)
318
+ pos = _compute_layout(g, nodelist, center_expand)
319
+
320
+ # -- per-node hex colors via matplotlib's colormap machinery, so this
321
+ # accepts the exact same `cmap`/`nodeclim` (continuous or discrete)
322
+ # as plot_tmgraph.
323
+ cmap_obj = plt.get_cmap(cmap) if isinstance(cmap, str) else cmap
324
+ vmin, vmax = nodeclim if nodeclim[1] != nodeclim[0] else (nodeclim[0] - 0.5, nodeclim[0] + 0.5)
325
+ norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
326
+ node_colors_hex = [mcolors.to_hex(cmap_obj(norm(v))) for v in nodelabel]
327
+
328
+ net = Network(
329
+ height="900px", width="100%", directed=g.is_directed(), bgcolor="#ffffff",
330
+ cdn_resources="in_line", notebook=False, heading=title,
331
+ )
332
+ net.set_options('{"physics": {"enabled": false}}')
333
+
334
+ SCALE = 800 # pyvis coordinates are pixels; our layout is normalized to roughly [-1.x, 1.x]
335
+ order = np.argsort(nodesize, kind="stable")[::-1] # largest first, smallest added last (drawn on top)
336
+ for i in order:
337
+ i = int(i)
338
+ n = nodelist[i]
339
+ x, y = pos[n]
340
+ net.add_node(
341
+ i,
342
+ label="",
343
+ title=f"node {n} | members={int(len(nodemembers[i]))} | {colorlabel}={nodelabel[i]:.3g}",
344
+ x=float(x * SCALE), y=float(-y * SCALE), # flip y: vis.js y-axis points down
345
+ size=float(nodesize[i]), color=node_colors_hex[i], physics=False,
346
+ )
347
+ idx_of = {n: i for i, n in enumerate(nodelist)}
348
+ edge_kwargs = {"arrows": "to"} if g.is_directed() else {}
349
+ for u, v in g.edges():
350
+ net.add_edge(idx_of[u], idx_of[v], color="#bbbbbb", width=1, **edge_kwargs)
351
+
352
+ html = net.generate_html(notebook=False)
353
+
354
+ # pyvis's own template renders "<h1>{{heading}}</h1>" twice
355
+ # unconditionally -- drop the second copy.
356
+ if title:
357
+ heading_block = f"<h1>{title}</h1>"
358
+ first = html.find(heading_block)
359
+ second = html.find(heading_block, first + 1)
360
+ if second != -1:
361
+ html = html[:second] + html[second + len(heading_block):]
362
+
363
+ # -- embed a small matplotlib colorbar as the legend, reusing the
364
+ # exact cmap/norm above so it always matches the node colors.
365
+ fig_cb, ax_cb = plt.subplots(figsize=(4, 0.5))
366
+ cbar = fig_cb.colorbar(
367
+ plt.cm.ScalarMappable(cmap=cmap_obj, norm=norm), cax=ax_cb, orientation="horizontal"
368
+ )
369
+ cbar.set_label(colorlabel)
370
+ buf = BytesIO()
371
+ fig_cb.savefig(buf, format="png", dpi=130, bbox_inches="tight", transparent=True)
372
+ plt.close(fig_cb)
373
+ legend_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
374
+ legend_html = (
375
+ f'<div style="text-align:center;">'
376
+ f'<img src="data:image/png;base64,{legend_b64}"></div>'
377
+ )
378
+ anchor = "</h1>" if title else "<body>"
379
+ html = html.replace(anchor, anchor + "\n" + legend_html, 1)
380
+
381
+ if output_path is not None:
382
+ with open(output_path, "w", encoding="utf-8") as f:
383
+ f.write(html)
384
+
385
+ return net, html
386
+
387
+
388
+ def plot_tmgraph_tcm(g, x_label, t, nodemembers, **kwargs):
389
+ """Plot a temporal mapper graph alongside its geodesic recurrence plot.
390
+
391
+ Port of MATLAB's ``plotgraphtcm.m``. Calls :func:`plot_tmgraph`
392
+ internally for the left subplot (all ``**kwargs`` are passed through),
393
+ so it accepts the same styling parameters.
394
+
395
+ Parameters
396
+ ----------
397
+ g : networkx.Graph or networkx.DiGraph
398
+ The (simplified) graph to plot.
399
+ x_label : array_like
400
+ A label for each time point in the time series.
401
+ t : array_like
402
+ Actual time (or a time index) associated with each time point,
403
+ used as the recurrence plot's axis labels.
404
+ nodemembers : sequence of sequence of int
405
+ ``nodemembers[n]`` gives the original time-point indices
406
+ belonging to node n.
407
+ **kwargs
408
+ Passed through to :func:`plot_tmgraph`.
409
+
410
+ Returns
411
+ -------
412
+ ax1, ax2 : matplotlib.axes.Axes
413
+ The network plot and recurrence plot axes.
414
+ cbar, cbar2 : matplotlib.colorbar.Colorbar
415
+ node_collection, scatter_collection
416
+ As returned by :func:`plot_tmgraph`.
417
+ D_geo : numpy.ndarray
418
+ The recurrence plot matrix.
419
+ """
420
+ import matplotlib.pyplot as plt
421
+ import matplotlib.dates as mdates
422
+
423
+ nodesize = np.array([len(m) for m in nodemembers])
424
+ bsinglemember = np.all(nodesize == 1)
425
+
426
+ # constrained_layout resolves spacing between subplots/colorbars
427
+ # automatically, avoiding overlap between cbar's label and ax2's ylabel.
428
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5), constrained_layout=True)
429
+
430
+ ax1, cbar, node_collection, scatter_collection = plot_tmgraph(
431
+ g, x_label, nodemembers, ax=ax1, **kwargs
432
+ )
433
+
434
+ if bsinglemember:
435
+ D_geo = all_pairs_distance(g, list(g.nodes()), weight=None)
436
+ else:
437
+ D_geo = tcm_distance(g, nodemembers)
438
+
439
+ t = np.asarray(t)
440
+ is_datetime = np.issubdtype(t.dtype, np.datetime64)
441
+ t_num = mdates.date2num(t) if is_datetime else t.astype(float)
442
+
443
+ im = ax2.imshow(
444
+ D_geo, cmap="hot",
445
+ extent=[t_num.min(), t_num.max(), t_num.max(), t_num.min()],
446
+ aspect="equal", interpolation="nearest", # matches MATLAB's imagesc (flat, unsmoothed cells)
447
+ )
448
+ cbar2 = plt.colorbar(im, ax=ax2)
449
+ cbar2.set_label("path length")
450
+
451
+ if is_datetime:
452
+ for axis in (ax2.xaxis, ax2.yaxis):
453
+ locator = mdates.AutoDateLocator()
454
+ axis.set_major_locator(locator)
455
+ axis.set_major_formatter(mdates.ConciseDateFormatter(locator))
456
+ ax2.set_xlabel("time")
457
+ ax2.set_ylabel("time")
458
+ else:
459
+ ax2.set_xlabel("time (s)")
460
+ ax2.set_ylabel("time (s)")
461
+ ax2.set_title("geodesic recurrence plot")
462
+
463
+ return ax1, ax2, cbar, cbar2, node_collection, scatter_collection, D_geo
tmapper/sample_data.py ADDED
@@ -0,0 +1,19 @@
1
+ """Locating the bundled sample dataset."""
2
+
3
+ from pathlib import Path
4
+
5
+ SAMPLE_DATA_FILE = "EL_temp.csv"
6
+
7
+
8
+ def sample_data_path():
9
+ """Path to the bundled East Lansing daily weather CSV.
10
+
11
+ Ships inside the package, so this resolves the same way whether tmapper
12
+ was pip-installed or is being run from a clone -- code that hard-codes
13
+ ``"sampledata/EL_temp.csv"`` only works from the repository root.
14
+
15
+ Note this is the *full* historical record (57709 daily rows). Building a
16
+ network needs a slice of it: the pairwise distance matrix is O(N^2), so
17
+ the whole thing would ask for tens of GB. See the Quickstart.
18
+ """
19
+ return Path(__file__).resolve().parent / "sampledata" / SAMPLE_DATA_FILE