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.
@@ -0,0 +1,1240 @@
1
+ """Interactive Streamlit app for the Temporal Mapper pipeline.
2
+
3
+ Point-and-click equivalent of the scripted tknndigraph -> filtergraph ->
4
+ plot_tmgraph_interactive pipeline, mirroring the MATLAB toolbox's
5
+ gui/TemporalMapperApp.m. Launch with:
6
+
7
+ streamlit run app/streamlit_app.py
8
+
9
+ Architecture note (mirrors the MATLAB app): building the network
10
+ (tknndigraph/filtergraph) is the expensive step and only runs when
11
+ "Build Network" is clicked, cached by st.cache_data so re-clicking Build
12
+ with unchanged parameters is instant. Changing a Plot Options widget
13
+ (color/time axis, node size, label method, show recurrence) re-renders
14
+ the *cached* network on every Streamlit rerun -- cheap, no rebuild.
15
+ """
16
+
17
+ import inspect
18
+ import json
19
+ import warnings
20
+ import zipfile
21
+ from datetime import datetime
22
+ from io import BytesIO
23
+ from pathlib import Path
24
+
25
+ import matplotlib.dates as mdates
26
+ import networkx as nx
27
+ import numpy as np
28
+ import pandas as pd
29
+ import streamlit as st
30
+ import streamlit.components.v1 as components
31
+ from scipy.spatial.distance import cdist
32
+ from scipy.stats import zscore as scipy_zscore
33
+
34
+ from tmapper import (
35
+ tknndigraph, filtergraph, plot_tmgraph_interactive, tcm_distance,
36
+ sample_data_path,
37
+ )
38
+
39
+ SAMPLE_DATA_PATH = sample_data_path()
40
+
41
+ # cap on the main content column (see the CSS in main()) -- wide enough for
42
+ # the network to breathe, narrow enough that plots stay readable rather than
43
+ # spanning a whole large monitor
44
+ MAX_CONTENT_WIDTH_PX = 1100
45
+
46
+ DEFAULTS = {
47
+ "zscore": True,
48
+ "start_row": 0,
49
+ "end_row_str": "last",
50
+ "downsample": 1,
51
+ "tidx_var": "(from row order)",
52
+ "embed_lag": 0,
53
+ "embed_order": 1,
54
+ "k": 3,
55
+ "d": 3.0,
56
+ "texclude": 1,
57
+ "maxdistprct": 100.0,
58
+ "maxdist_str": "inf",
59
+ "reciprocal": True,
60
+ "color_var": "(row index)",
61
+ "time_var": "(row index)",
62
+ "nodesizemode": "log",
63
+ "labelmethod": "mode",
64
+ "show_recurrence": True,
65
+ }
66
+
67
+
68
+ DATA_FORMAT_HELP = """
69
+ **A table of time series: one row per time point, in time order.**
70
+
71
+ - **File** — `.csv` or `.txt`, comma-separated, with a header row of column names.
72
+ - **Rows** — one time point each, already sorted in time. Row order *is* the time order.
73
+ - **Columns** — one variable each. The ones you select together describe the
74
+ system's state at that moment.
75
+
76
+ ```
77
+ Date,tmax,tmin,prcp <- header row
78
+ 1990-01-01,31,-23,0 <- one time point
79
+ 1990-01-02,-3,-22,0
80
+ ```
81
+
82
+ **Which columns can be used where:**
83
+
84
+ - **Build variables** — numeric only. The pipeline measures Euclidean
85
+ distances between state vectors, which needs real numbers.
86
+ - **Time axis / time index** — numeric or dates.
87
+ - **Colour by** — anything, including text categories (condition, trial,
88
+ behavioural state). Categories are treated as purely nominal labels and
89
+ aggregated per node by majority vote.
90
+
91
+ Date strings are detected and parsed automatically, so a `Date` column works
92
+ as a time index or axis without any preparation.
93
+
94
+ You don't need to clean the file first:
95
+
96
+ - **Missing values** are handled automatically, and the app reports what it did.
97
+ - A leading **unnamed index column** (what `to_csv()` writes without
98
+ `index=False`) is dropped — it's just a counter, and would otherwise
99
+ dominate the distance computation.
100
+ - Sampling is assumed **evenly spaced**. If it isn't, or the recording has real
101
+ breaks in it, set a **time index** column under Variables & Preprocessing.
102
+
103
+ Every pair of time points is compared, so memory grows with the *square* of the
104
+ number of rows — long recordings need a row range or downsampling. The app
105
+ refuses a selection that would be too large and tells you the figure.
106
+ """
107
+
108
+
109
+ # ============================================================= data helpers
110
+
111
+ def read_csv_smart(path_or_buffer):
112
+ """pandas.read_csv, but drop a leading unnamed column (pandas' own
113
+ "Unnamed: 0" marker for a header-less first column) -- it's virtually
114
+ always a stray row-index column left over from a previous
115
+ ``to_csv()`` without ``index=False``, and including it as a
116
+ candidate build variable would silently dominate the distance
117
+ computation (it's just a monotonic ramp).
118
+
119
+ Always returns a plain 0..N-1 RangeIndex: build_network/render_network
120
+ use positional indexing (df[...].to_numpy()[rows]) throughout, which
121
+ requires the DataFrame's index to match row position exactly.
122
+
123
+ Returns
124
+ -------
125
+ df : pandas.DataFrame
126
+ dropped_index_col : bool
127
+ Whether a leading unnamed column was dropped -- the caller needs
128
+ this to emit matching code in the "Show equivalent code" panel.
129
+ """
130
+ df = pd.read_csv(path_or_buffer)
131
+ dropped_index_col = df.columns[0].startswith("Unnamed:")
132
+ if dropped_index_col:
133
+ df = df.drop(columns=df.columns[0])
134
+ df = df.reset_index(drop=True)
135
+
136
+ # MATLAB's readtable turns date strings into datetime automatically --
137
+ # which is why tmapper_demo.m can use dat.Date straight as its time
138
+ # axis. pandas leaves them as plain strings, so parse them here or a
139
+ # perfectly good time column stays unusable.
140
+ for c in df.columns:
141
+ parsed = _maybe_datetime(df[c])
142
+ if parsed is not None:
143
+ df[c] = parsed
144
+ return df, dropped_index_col
145
+
146
+
147
+ def _maybe_datetime(col):
148
+ """Parsed datetimes if this column looks like dates, else None.
149
+
150
+ Requires nearly every non-empty value to parse, so a categorical column
151
+ of short strings isn't mangled into nonsense timestamps.
152
+ """
153
+ # Excluding by dtype rather than requiring exactly `object`: pandas 3.x's
154
+ # read_csv defaults text columns to its own "string" dtype instead of
155
+ # object, so a strict `== object` check silently stopped matching any
156
+ # text column at all under a newer pandas -- dates just never parsed,
157
+ # with no error.
158
+ if (
159
+ pd.api.types.is_numeric_dtype(col)
160
+ or pd.api.types.is_datetime64_any_dtype(col)
161
+ or pd.api.types.is_bool_dtype(col)
162
+ ):
163
+ return None
164
+ present = int(col.notna().sum())
165
+ if present == 0:
166
+ return None
167
+ try:
168
+ with warnings.catch_warnings():
169
+ warnings.simplefilter("ignore")
170
+ parsed = pd.to_datetime(col, errors="coerce")
171
+ except Exception:
172
+ return None
173
+ if int(parsed.notna().sum()) >= 0.9 * present:
174
+ return parsed
175
+ return None
176
+
177
+
178
+ def numeric_columns(df):
179
+ """Columns usable as *state variables*: strictly numeric.
180
+
181
+ Dates and categories are deliberately excluded here -- the pipeline
182
+ measures Euclidean distances between state vectors, which needs real
183
+ numbers. They are still offered for colouring / time axis / time index
184
+ via the helpers below.
185
+ """
186
+ return [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
187
+
188
+
189
+ def datetime_columns(df):
190
+ return [c for c in df.columns if pd.api.types.is_datetime64_any_dtype(df[c])]
191
+
192
+
193
+ def categorical_columns(df):
194
+ """Text / categorical columns -- usable for colouring only."""
195
+ return [
196
+ c for c in df.columns
197
+ if not pd.api.types.is_numeric_dtype(df[c])
198
+ and not pd.api.types.is_datetime64_any_dtype(df[c])
199
+ ]
200
+
201
+
202
+ def color_column_options(df):
203
+ """Anything can colour a node: numbers, dates, or categories."""
204
+ return ["(row index)"] + numeric_columns(df) + datetime_columns(df) + categorical_columns(df)
205
+
206
+
207
+ def time_column_options(df):
208
+ """Axis labels / time index: ordered quantities only, so no categories."""
209
+ return ["(row index)"] + numeric_columns(df) + datetime_columns(df)
210
+
211
+
212
+ def resolve_data_action(sample_clicked, upload_token, claimed_token):
213
+ """Which data source, if any, to load on this run: 'sample', 'upload'
214
+ or None.
215
+
216
+ An explicit click always wins. An upload loads only when it is new --
217
+ identified by a name+size token the caller then claims. Keying off the
218
+ loaded *data source* instead (the earlier approach) made the uploader
219
+ re-fire on every rerun where anything else was loaded, silently
220
+ reloading an attached file over a sample the user had just chosen.
221
+
222
+ Split out as a plain function because Streamlit's AppTest cannot
223
+ simulate a file upload at all, so this is the only way to get the
224
+ branch under test.
225
+ """
226
+ if sample_clicked:
227
+ return "sample"
228
+ if upload_token is not None and upload_token != claimed_token:
229
+ return "upload"
230
+ return None
231
+
232
+
233
+ def set_data(df, source_label, source_code):
234
+ """Register a newly-loaded table, invalidating any cached network and
235
+ resetting variable selection -- mirrors TemporalMapperApp.loadData.
236
+
237
+ ``source_code`` is the runnable snippet that reproduces ``df`` as
238
+ ``dat``; it gets emitted verbatim at the top of the generated code
239
+ (mirroring the MATLAB app's DataSourceCode property) so the output
240
+ is a complete script rather than one that assumes ``dat`` exists.
241
+ """
242
+ numvars = numeric_columns(df)
243
+ if not numvars:
244
+ st.sidebar.error("That data has no numeric columns to build a network from.")
245
+ return
246
+ st.session_state.pop("built", None)
247
+ st.session_state["data"] = df
248
+ st.session_state["data_source"] = source_label
249
+ st.session_state["data_source_code"] = source_code
250
+ st.session_state["numeric_vars"] = numvars
251
+ st.session_state["selected_vars"] = numvars
252
+
253
+
254
+ # --------------------------------------------------------------- colouring
255
+
256
+ # Continuous data gets a sequential/diverging map; categories need a
257
+ # qualitative one, where adjacent colours are unrelated rather than a ramp.
258
+ CONTINUOUS_CMAPS = ["jet", "viridis", "plasma", "magma", "cividis", "turbo", "coolwarm"]
259
+ CATEGORICAL_CMAPS = ["tab10", "tab20", "Set1", "Set2", "Dark2", "Paired", "Accent"]
260
+
261
+
262
+ def resolve_color_values(df, color_var, rows, tidx):
263
+ """Per-time-point colour values, whatever the column's type.
264
+
265
+ Returns (values, label, categories). ``categories`` is None for
266
+ continuous data, or the ordered category names when the column is
267
+ categorical -- the caller needs them to pin the colour scale so each
268
+ category keeps its own band.
269
+
270
+ Categories are mapped to integer codes rather than any numeric value
271
+ read off the data: the codes are purely nominal, which is why the
272
+ aggregation is forced to 'mode' (a mean of category codes is
273
+ meaningless) and a qualitative colormap is used.
274
+ """
275
+ if color_var == "(row index)":
276
+ return tidx.astype(float), "row index", None
277
+
278
+ col = df[color_var]
279
+ if pd.api.types.is_datetime64_any_dtype(col):
280
+ # matplotlib date numbers keep the axis/colourbar human-readable
281
+ vals = mdates.date2num(col.to_numpy())[rows]
282
+ return vals, color_var, None
283
+ if pd.api.types.is_numeric_dtype(col):
284
+ return col.to_numpy()[rows], color_var, None
285
+
286
+ codes, categories = pd.factorize(col, sort=True)
287
+ return codes.astype(float)[rows], color_var, list(categories)
288
+
289
+
290
+ FIGURE_WIDTH_PX = 560
291
+
292
+ # Streamlit >=1.59 sizes st.pyplot via `width`; older releases only have
293
+ # `use_container_width`. Detect rather than try/except: older versions pass
294
+ # unknown kwargs straight through to fig.savefig(), so a wrong guess fails
295
+ # somewhere confusing instead of raising a clean TypeError here.
296
+ _PYPLOT_HAS_WIDTH = "width" in inspect.signature(st.pyplot).parameters
297
+
298
+
299
+ def show_figure(fig, width_px=FIGURE_WIDTH_PX):
300
+ """Render a matplotlib figure centered, at a fixed readable width
301
+ instead of stretched to fill the column -- a 6x5.5in figure blown up to
302
+ a wide monitor's width is unreadable, and left-aligning a narrow figure
303
+ under the full-width network plot looks lopsided.
304
+
305
+ Centering is done with padding columns rather than CSS so it doesn't
306
+ depend on Streamlit's internal DOM test-ids. Uses an explicit pixel
307
+ width rather than 'content'/native size, since the native size still
308
+ rendered near 1000px; Streamlit clamps it to the container on narrow
309
+ screens, so this stays responsive on a phone.
310
+
311
+ Note on newer Streamlit: `width` defaults to 'stretch' and *overrides*
312
+ the deprecated `use_container_width`, so passing only the old flag there
313
+ silently does nothing.
314
+ """
315
+ left, mid, right = st.columns([1, 4, 1])
316
+ with mid:
317
+ if _PYPLOT_HAS_WIDTH:
318
+ st.pyplot(fig, width=width_px)
319
+ else:
320
+ st.pyplot(fig, use_container_width=False)
321
+
322
+
323
+ # ==================================================== pre-flight size guard
324
+
325
+ MAX_WINDOW_ROWS = 8000 # cdist's pairwise distance matrix is O(N^2) in memory
326
+
327
+
328
+ def oversized_window_message(window_rows, downsample):
329
+ """Error text if a full pairwise distance matrix for this row range
330
+ would be unreasonably large, else None.
331
+
332
+ cdist allocates window_rows^2 float64s, so an untrimmed real dataset
333
+ can ask for tens of GB -- the bundled sample's full 57709 rows would
334
+ need ~25 GB. Better to refuse with a number the user can act on than
335
+ to let numpy raise (or the machine swap). Only applies when
336
+ downsample == 1, since downsampling is itself the fix.
337
+ """
338
+ if window_rows > MAX_WINDOW_ROWS and downsample == 1:
339
+ return (
340
+ f"The selected row range has {window_rows} rows -- computing a full "
341
+ f"pairwise distance matrix at this size needs "
342
+ f"~{8 * window_rows ** 2 / 1e9:.1f} GB of memory. Restrict the row range "
343
+ f"(start row/end row) or set downsample (N) > 1 first."
344
+ )
345
+ return None
346
+
347
+
348
+ # ========================================================= expensive: build
349
+
350
+ @st.cache_data(show_spinner=False)
351
+ def build_network(
352
+ df, selected_vars, zscore_on, start_row, end_row, downsample,
353
+ lag, order, k, d, texclude, maxdistprct, maxdist, reciprocal,
354
+ tidx_var=None,
355
+ ):
356
+ """tknndigraph -> filtergraph on the resolved parameters. Cached by
357
+ st.cache_data, keyed on every argument here -- re-calling with the
358
+ same parameters (even across reruns triggered by unrelated widgets)
359
+ returns instantly instead of recomputing.
360
+
361
+ Row indices throughout are 0-indexed positions into `df`, matching
362
+ this port's Pythonic convention (unlike the MATLAB app's 1-indexed
363
+ UI).
364
+ """
365
+ n_full = len(df)
366
+ end_row = n_full - 1 if end_row is None else min(end_row, n_full - 1)
367
+ if end_row < start_row:
368
+ raise ValueError("End row must be greater than or equal to start row.")
369
+
370
+ # -- a user-supplied time index. It can be combined with downsampling,
371
+ # but only after converting to decimated units: the column counts raw
372
+ # sampling intervals, so keeping every Nth row would make consecutive
373
+ # kept samples differ by N rather than 1 and tknndigraph would build no
374
+ # temporal edges at all. Dividing by (interval * N) restores a step of
375
+ # 1 between neighbours while scaling real breaks proportionally.
376
+ tidx_source = None
377
+ tidx_unit = 1
378
+ if tidx_var is not None:
379
+ col = df[tidx_var]
380
+ if col.isna().any():
381
+ raise ValueError(f"Time index column '{tidx_var}' contains missing values.")
382
+ if pd.api.types.is_datetime64_any_dtype(col):
383
+ # integer nanoseconds. Deliberately NOT routed through the float
384
+ # check below: epoch-nanosecond values are ~1e18, far past the
385
+ # 2^53 where float64 stops representing integers exactly, so a
386
+ # rounding test on them would be meaningless.
387
+ vals = col.to_numpy().astype("datetime64[ns]").astype(np.int64)
388
+ else:
389
+ if not np.allclose(col.to_numpy(), np.rint(col.to_numpy())):
390
+ raise ValueError(f"Time index column '{tidx_var}' must contain whole numbers.")
391
+ vals = np.rint(col.to_numpy()).astype(np.int64)
392
+ steps = np.diff(vals)
393
+ if np.any(steps <= 0):
394
+ raise ValueError(f"Time index column '{tidx_var}' must be strictly increasing.")
395
+
396
+ # the base sampling interval: the smallest step present. Every other
397
+ # step must be a whole multiple of it, i.e. the column is a uniform
398
+ # grid with holes. Genuinely irregular spacing has no well-defined
399
+ # interval to decimate by, so refuse rather than silently distort it.
400
+ interval = int(steps.min())
401
+ if downsample > 1 and np.any(steps % interval != 0):
402
+ raise ValueError(
403
+ f"Downsampling needs a regular time index, but '{tidx_var}' has "
404
+ f"steps that are not multiples of its smallest step ({interval}). "
405
+ "Set downsample (N) to 1, or supply an evenly-sampled index."
406
+ )
407
+ tidx_source = vals
408
+ tidx_unit = interval * downsample
409
+
410
+ window = df.iloc[start_row:end_row + 1]
411
+
412
+ cols = list(selected_vars)
413
+ missing_mask = window[cols].isna().any(axis=1)
414
+ n_dropped = int(missing_mask.sum())
415
+
416
+ # -- Decimate on the ORIGINAL row grid, never on the post-removal list.
417
+ # Striding the cleaned rows slides every later sample off the true time
418
+ # grid: after one dropped row, "every 4th surviving row" is no longer
419
+ # "every 4th time step", so spacings drift and phantom gaps appear at
420
+ # samples that were in fact evenly spaced.
421
+ #
422
+ # The lowpass runs over the raw window with pandas' NaN-skipping mean,
423
+ # so an isolated missing sample is simply left out of its window's
424
+ # average rather than knocking a grid point out entirely. Only a grid
425
+ # point whose whole window is missing ends up NaN, and those are the
426
+ # ones dropped below -- a genuine hole in the data.
427
+ if downsample > 1:
428
+ smoothed = window[cols].rolling(window=downsample, center=True, min_periods=1).mean()
429
+ grid = smoothed.iloc[::downsample]
430
+ else:
431
+ # no smoothing to lean on at stride 1, so missing rows drop outright
432
+ grid = window[cols]
433
+
434
+ keep = ~grid.isna().any(axis=1)
435
+ n_grid_dropped = int((~keep).sum())
436
+ values = grid[keep].to_numpy()
437
+ base_rows = grid.index.to_numpy()[keep.to_numpy()]
438
+ if len(base_rows) < 2:
439
+ raise ValueError(
440
+ f"Row range/downsampling/missing-data removal leaves only {len(base_rows)} "
441
+ "row(s) -- need at least 2."
442
+ )
443
+
444
+ if zscore_on:
445
+ # ddof=1 matches MATLAB's zscore convention (sample std, N-1) --
446
+ # see this package's own porting notes.
447
+ X_raw = scipy_zscore(values, ddof=1, axis=0)
448
+ else:
449
+ X_raw = values
450
+ n_raw = X_raw.shape[0]
451
+
452
+ # -- delay embedding: concatenate `order` copies of the state, each
453
+ # `lag` time points apart. order=1 (default) skips this.
454
+ if order > 1:
455
+ if lag < 1:
456
+ raise ValueError("Embed lag must be at least 1 when embed order > 1.")
457
+ n = n_raw - (order - 1) * lag
458
+ if n < 2:
459
+ raise ValueError(
460
+ f"Embed lag/order too large: only {n_raw} rows of data available."
461
+ )
462
+ nvars = X_raw.shape[1]
463
+ X = np.zeros((n, nvars * order))
464
+ for j in range(order):
465
+ X[:, j * nvars:(j + 1) * nvars] = X_raw[j * lag: j * lag + n, :]
466
+ else:
467
+ n = n_raw
468
+ X = X_raw
469
+
470
+ # original df row indices aligned with each embedded state (the most
471
+ # recent slice, since embedding stacks past->present), mapped back
472
+ # through base_rows since X_raw may already be a range/downsample subset.
473
+ rows = base_rows[(n_raw - n):]
474
+
475
+ # -- tidx must reflect *real* time position, not array position.
476
+ # tknndigraph links two points as temporal neighbours iff their tidx
477
+ # differs by exactly 1, so a plain arange would silently bridge a
478
+ # dropped stretch and fabricate an edge across it.
479
+ #
480
+ # Because decimation happens on the original grid above, every retained
481
+ # row is an exact multiple of `downsample` from the first, so this is
482
+ # integer-exact: consecutive grid points step by 1, and a dropped grid
483
+ # point (a real hole) leaves a jump of more than 1.
484
+ if tidx_source is None:
485
+ tidx = (rows - rows[0]) // downsample
486
+ else:
487
+ vals = np.asarray(tidx_source, dtype=np.int64)[rows]
488
+ # tidx_unit is (base sampling interval * downsample), so neighbouring
489
+ # kept samples land 1 apart and real breaks scale proportionally
490
+ tidx = (vals - vals[0]) // tidx_unit
491
+ if np.any(np.diff(tidx) < 1):
492
+ raise ValueError(
493
+ "Downsampling by this factor collapses distinct time points onto the "
494
+ "same index. Reduce downsample (N)."
495
+ )
496
+
497
+ D = cdist(X, X, metric="euclidean")
498
+ g, par = tknndigraph(
499
+ D, k, tidx,
500
+ time_exclude_range=texclude,
501
+ max_neighbor_dist_prct=maxdistprct,
502
+ max_neighbor_dist=maxdist,
503
+ reciprocal=reciprocal,
504
+ )
505
+ g_simp, members, _nodesize, _D_simp = filtergraph(g, d, reciprocal=reciprocal)
506
+
507
+ return {
508
+ "g_simp": g_simp, "members": members, "rows": rows, "tidx": tidx,
509
+ "par": par, "n_dropped": n_dropped, "n_window": len(window),
510
+ "n_grid_dropped": n_grid_dropped,
511
+ "k": k, "d": d, "texclude": texclude, "lag": lag, "order": order,
512
+ "downsample": downsample,
513
+ }
514
+
515
+
516
+ # ================================================================= exports
517
+ #
518
+ # Design principle: export only the *irreducible* state. Anything a user
519
+ # can regenerate in one line is deliberately left out, because those are
520
+ # the big files -- the geodesic recurrence matrix is O(n_timepoints^2)
521
+ # (~120 MB on the bundled sample) yet is just tcm_distance(g_simp,
522
+ # members), and D_simp is filtergraph's 4th return value. Likewise an
523
+ # edge-list CSV is fully contained in the GraphML. So the three files
524
+ # below are small, non-overlapping, and each has exactly one job.
525
+
526
+ def _jsonsafe(x):
527
+ """Convert numpy scalars to plain Python, and non-finite floats to
528
+ the same 'inf'/'nan' spellings the app's own text fields use --
529
+ json.dumps would otherwise emit bare `Infinity`, which is invalid
530
+ JSON to strict parsers (notably JavaScript's JSON.parse)."""
531
+ if isinstance(x, (np.integer,)):
532
+ return int(x)
533
+ if isinstance(x, (np.floating, float)):
534
+ x = float(x)
535
+ if np.isinf(x):
536
+ return "inf" if x > 0 else "-inf"
537
+ if np.isnan(x):
538
+ return "nan"
539
+ return x
540
+ if isinstance(x, (np.bool_, bool)):
541
+ return bool(x)
542
+ return x
543
+
544
+
545
+ def build_timeline_csv(built, df, color_var, time_var):
546
+ """Long-format node<->row mapping: one row per retained time point.
547
+
548
+ This is the join-back table -- 'which attractor was the system in at
549
+ time t' -- and is what downstream dwell-time/transition/occupancy
550
+ analysis actually needs. Carries the chosen color/time columns so it
551
+ can be used directly without re-reading the source file.
552
+ """
553
+ members, rows, tidx = built["members"], built["rows"], built["tidx"]
554
+ node_of_tidx = np.empty(len(tidx), dtype=int)
555
+ for n, m in enumerate(members):
556
+ node_of_tidx[np.asarray(m, dtype=int)] = n
557
+
558
+ out = pd.DataFrame({"tidx": tidx, "source_row": rows, "node": node_of_tidx})
559
+ if color_var != "(row index)":
560
+ out[color_var] = df[color_var].to_numpy()[rows]
561
+ if time_var != "(row index)" and time_var != color_var:
562
+ out[time_var] = df[time_var].to_numpy()[rows]
563
+ return out.to_csv(index=False)
564
+
565
+
566
+ def build_graphml(built, colorvar, labelmethod):
567
+ """The network itself: topology + edge weights + per-node attributes.
568
+
569
+ Members are deliberately NOT embedded here (they'd have to be
570
+ delimited strings, which reads badly in Gephi/Cytoscape) -- that
571
+ mapping lives in timeline.csv instead, so the two files don't overlap.
572
+ """
573
+ from tmapper import find_node_label
574
+
575
+ g = built["g_simp"].copy()
576
+ members, rows = built["members"], built["rows"]
577
+ node_values = find_node_label(members, colorvar, labelmethod=labelmethod)
578
+ for n, node in enumerate(g.nodes()):
579
+ m = np.asarray(members[n], dtype=int)
580
+ g.nodes[node]["n_members"] = int(len(m))
581
+ g.nodes[node]["color_value"] = float(node_values[n])
582
+ g.nodes[node]["first_source_row"] = int(rows[m.min()])
583
+ g.nodes[node]["last_source_row"] = int(rows[m.max()])
584
+ buf = BytesIO()
585
+ nx.write_graphml(g, buf)
586
+ return buf.getvalue()
587
+
588
+
589
+ def build_params_json(built, source_label, source_code, selected_vars, zscore_on,
590
+ start_row, end_row, downsample, lag, order, k, d, texclude,
591
+ maxdistprct, maxdist, reciprocal, color_var, time_var,
592
+ nodesizemode, labelmethod, show_recurrence, tidx_var=None, cmap="jet"):
593
+ """Full provenance: data source, every preprocessing/build/plot
594
+ setting, and the resulting network's shape -- enough to reproduce or
595
+ audit the build without the app."""
596
+ g_simp = built["g_simp"]
597
+ payload = {
598
+ "generated_by": "tmapper Streamlit app",
599
+ "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
600
+ "tmapper_version": _tmapper_version(),
601
+ "data_source": {
602
+ "label": source_label,
603
+ "loading_code": source_code,
604
+ },
605
+ "preprocessing": {
606
+ "selected_variables": list(selected_vars),
607
+ "zscore": bool(zscore_on),
608
+ "zscore_ddof": 1 if zscore_on else None,
609
+ "start_row": _jsonsafe(start_row),
610
+ "end_row": "last" if end_row is None else _jsonsafe(end_row),
611
+ "downsample": _jsonsafe(downsample),
612
+ "time_index_source": tidx_var or "row order",
613
+ "downsample_lowpass": "centered rolling mean, window = downsample" if downsample > 1 else None,
614
+ "embed_lag": _jsonsafe(lag),
615
+ "embed_order": _jsonsafe(order),
616
+ "rows_in_window": _jsonsafe(built["n_window"]),
617
+ "rows_dropped_missing": _jsonsafe(built["n_dropped"]),
618
+ },
619
+ "network_parameters": {
620
+ "k": _jsonsafe(k),
621
+ "d": _jsonsafe(d),
622
+ "texclude": _jsonsafe(texclude),
623
+ "max_neighbor_dist_prct": _jsonsafe(maxdistprct),
624
+ "max_neighbor_dist": _jsonsafe(maxdist),
625
+ "max_neighbor_dist_resolved": _jsonsafe(built["par"]["max_neighbor_dist"]),
626
+ "reciprocal": bool(reciprocal),
627
+ "distance_metric": "euclidean",
628
+ },
629
+ "plot_options": {
630
+ "color_by": color_var,
631
+ "time_axis": time_var,
632
+ "nodesizemode": nodesizemode,
633
+ "cmap": cmap,
634
+ "labelmethod": labelmethod,
635
+ "show_recurrence": bool(show_recurrence),
636
+ },
637
+ "result": {
638
+ "n_nodes": int(g_simp.number_of_nodes()),
639
+ "n_edges": int(g_simp.number_of_edges()),
640
+ "n_timepoints": int(len(built["tidx"])),
641
+ },
642
+ }
643
+ return json.dumps(payload, indent=2)
644
+
645
+
646
+ def _tmapper_version():
647
+ try:
648
+ from importlib.metadata import version
649
+ return version("tmapper")
650
+ except Exception:
651
+ return "unknown"
652
+
653
+
654
+ def build_export_zip(html, timeline_csv, graphml_bytes, params_json, code):
655
+ """Everything in one archive: the three data files plus the
656
+ self-contained interactive page and the script that reproduces it."""
657
+ buf = BytesIO()
658
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
659
+ z.writestr("network.graphml", graphml_bytes)
660
+ z.writestr("timeline.csv", timeline_csv)
661
+ z.writestr("params.json", params_json)
662
+ z.writestr("tmgraph.html", html)
663
+ z.writestr("reproduce.py", code)
664
+ return buf.getvalue()
665
+
666
+
667
+ # ============================================================ cheap: render
668
+
669
+ def render_network(built, df, color_var, time_var, nodesizemode, labelmethod,
670
+ show_recurrence, cmap="jet"):
671
+ """Render the (cached) network and optional recurrence plot.
672
+
673
+ Returns the rendered artifacts so the caller can offer them as
674
+ downloads without recomputing anything: the standalone interactive
675
+ HTML page, the recurrence-plot figure (or None), and the resolved
676
+ colorvar/colorlabel/title needed to render a static PNG on demand.
677
+ """
678
+ g_simp, members, rows, tidx, par = (
679
+ built["g_simp"], built["members"], built["rows"], built["tidx"], built["par"]
680
+ )
681
+
682
+ colorvar, colorlabel, categories = resolve_color_values(df, color_var, rows, tidx)
683
+ # pin the scale so each category owns a full band of the qualitative map,
684
+ # instead of K categories being squeezed onto a continuous ramp
685
+ nodeclim = (-0.5, len(categories) - 0.5) if categories else None
686
+
687
+ title = f"k={built['k']}, d={built['d']}, texclude={built['texclude']}, maxdist={par['max_neighbor_dist']:.4g}"
688
+ if built["order"] > 1:
689
+ title += f", lag={built['lag']}, order={built['order']}"
690
+ if built["downsample"] > 1:
691
+ title += f", downsample={built['downsample']}"
692
+
693
+ net, html = plot_tmgraph_interactive(
694
+ g_simp, colorvar, members,
695
+ colorlabel=colorlabel, nodesizemode=nodesizemode, labelmethod=labelmethod,
696
+ cmap=cmap, nodeclim=nodeclim, title=title,
697
+ )
698
+ components.html(html, height=800, scrolling=True)
699
+
700
+ fig = None
701
+ if show_recurrence:
702
+ if time_var == "(row index)":
703
+ t = tidx
704
+ elif pd.api.types.is_datetime64_any_dtype(df[time_var]):
705
+ t = mdates.date2num(df[time_var].to_numpy())[rows]
706
+ else:
707
+ t = df[time_var].to_numpy()[rows]
708
+
709
+ D_geo = tcm_distance(g_simp, members)
710
+
711
+ import matplotlib.pyplot as plt
712
+ fig, ax = plt.subplots(figsize=(6, 5.5))
713
+ im = ax.imshow(D_geo, cmap="hot", extent=[t.min(), t.max(), t.max(), t.min()])
714
+ ax.set_xlabel("time")
715
+ ax.set_ylabel("time")
716
+ ax.set_title("geodesic recurrence plot")
717
+ fig.colorbar(im, ax=ax, label="path length")
718
+ show_figure(fig)
719
+
720
+ return html, fig, colorvar, colorlabel, title, nodeclim
721
+
722
+
723
+ # ========================================================= code generation
724
+
725
+ def _coderepr(x):
726
+ """repr() for embedding in generated source, but emitting infinities
727
+ as ``np.inf`` -- plain repr(float('inf')) is the bare token ``inf``,
728
+ which is a NameError when the generated script is actually run."""
729
+ if isinstance(x, float) and np.isinf(x):
730
+ return "np.inf" if x > 0 else "-np.inf"
731
+ return repr(x)
732
+
733
+
734
+ def generate_code(source_code, selected_vars, zscore_on, start_row, end_row, downsample,
735
+ lag, order, k, d, texclude, maxdistprct, maxdist, reciprocal,
736
+ color_var, time_var, nodesizemode, labelmethod, show_recurrence,
737
+ tidx_var=None, cmap="jet", color_kind="numeric"):
738
+ end_row_repr = "None" if end_row is None else repr(end_row)
739
+ color_is_datetime = color_kind == "datetime"
740
+ color_is_category = color_kind == "category"
741
+
742
+ lines = [
743
+ "# Temporal Mapper -- generated by the Streamlit app's code view",
744
+ "import numpy as np",
745
+ "import pandas as pd",
746
+ *(["import matplotlib.dates as mdates"] if color_is_datetime else []),
747
+ "from scipy.spatial.distance import cdist",
748
+ "from scipy.stats import zscore",
749
+ "from tmapper import tknndigraph, filtergraph, plot_tmgraph_interactive, tcm_distance",
750
+ "",
751
+ source_code,
752
+ "",
753
+ f"selected_vars = {list(selected_vars)!r}",
754
+ f"start_row, end_row, downsample = {start_row!r}, {end_row_repr}, {downsample!r}",
755
+ "end_row = (len(dat) - 1) if end_row is None else min(end_row, len(dat) - 1)",
756
+ "window = dat.iloc[start_row:end_row + 1]",
757
+ "# decimate on the ORIGINAL row grid, not on the post-removal list:",
758
+ "# striding surviving rows slides later samples off the true time grid",
759
+ "if downsample > 1:",
760
+ " smoothed = window[selected_vars].rolling(window=downsample, center=True, min_periods=1).mean()",
761
+ " grid = smoothed.iloc[::downsample] # rolling().mean() skips NaNs",
762
+ "else:",
763
+ " grid = window[selected_vars]",
764
+ "keep = ~grid.isna().any(axis=1)",
765
+ "values = grid[keep].to_numpy()",
766
+ "base_rows = grid.index.to_numpy()[keep.to_numpy()]",
767
+ "",
768
+ ]
769
+ if zscore_on:
770
+ lines.append("X_raw = zscore(values, ddof=1, axis=0) # ddof=1 matches MATLAB's convention")
771
+ else:
772
+ lines.append("X_raw = values")
773
+ lines += [
774
+ "n_raw = X_raw.shape[0]",
775
+ f"lag, order = {lag!r}, {order!r}",
776
+ ]
777
+ if order > 1:
778
+ lines += [
779
+ "n = n_raw - (order - 1) * lag",
780
+ "nvars = X_raw.shape[1]",
781
+ "X = np.zeros((n, nvars * order))",
782
+ "for j in range(order):",
783
+ " X[:, j*nvars:(j+1)*nvars] = X_raw[j*lag : j*lag + n, :]",
784
+ ]
785
+ else:
786
+ lines += ["n = n_raw", "X = X_raw"]
787
+ lines += [
788
+ "rows = base_rows[(n_raw - n):]",
789
+ "# tidx marks real time position. Decimation happened on the original grid,",
790
+ "# so this is integer-exact: consecutive samples step by 1 and a genuinely",
791
+ "# dropped point leaves a jump, stopping tknndigraph from fabricating a",
792
+ "# temporal edge across it.",
793
+ ]
794
+ if tidx_var:
795
+ lines += [
796
+ f"tidx = dat[{tidx_var!r}].to_numpy().astype('int64')[rows]",
797
+ "tidx = tidx - tidx.min()",
798
+ ]
799
+ else:
800
+ lines += ["tidx = (rows - rows[0]) // downsample"]
801
+ lines += [
802
+ "",
803
+ "D = cdist(X, X, metric='euclidean')",
804
+ f"g, par = tknndigraph(D, {k!r}, tidx, time_exclude_range={texclude!r}, "
805
+ f"max_neighbor_dist_prct={_coderepr(maxdistprct)}, max_neighbor_dist={_coderepr(maxdist)}, "
806
+ f"reciprocal={reciprocal!r})",
807
+ f"g_simp, members, nodesize, D_simp = filtergraph(g, {d!r}, reciprocal={reciprocal!r})",
808
+ "",
809
+ ]
810
+ if color_var == "(row index)":
811
+ color_expr = "tidx.astype(float)"
812
+ elif color_is_datetime:
813
+ color_expr = f"mdates.date2num(dat['{color_var}'].to_numpy())[rows]"
814
+ elif color_is_category:
815
+ color_expr = f"pd.factorize(dat['{color_var}'], sort=True)[0].astype(float)[rows]"
816
+ else:
817
+ color_expr = f"dat['{color_var}'].to_numpy()[rows]"
818
+ lines += [
819
+ f"colorvar = {color_expr}",
820
+ f"net, html = plot_tmgraph_interactive(g_simp, colorvar, members, "
821
+ f"colorlabel={color_var!r}, nodesizemode={nodesizemode!r}, labelmethod={labelmethod!r}, "
822
+ f"cmap={cmap!r}, output_path='tmgraph.html')",
823
+ "# open tmgraph.html in a browser, or embed `html` directly (e.g. in Streamlit)",
824
+ ]
825
+ if show_recurrence:
826
+ time_expr = "tidx" if time_var == "(row index)" else f"dat['{time_var}'].to_numpy()[rows]"
827
+ lines += [
828
+ "",
829
+ f"t = {time_expr}",
830
+ "D_geo = tcm_distance(g_simp, members)",
831
+ ]
832
+ return "\n".join(lines)
833
+
834
+
835
+ # ===================================================================== UI
836
+
837
+ def main():
838
+ st.set_page_config(page_title="Temporal Mapper", layout="wide")
839
+ # layout="wide" is right for the sidebar, but lets the main column grow
840
+ # to the full width of a large monitor, which stretches the plots to an
841
+ # unreadable size. Cap the content column instead -- it still shrinks
842
+ # normally on narrow screens/phones, it just stops growing past this.
843
+ st.markdown(
844
+ f"<style>.block-container {{ max-width: {MAX_CONTENT_WIDTH_PX}px; }}</style>",
845
+ unsafe_allow_html=True,
846
+ )
847
+ st.title("Temporal Mapper")
848
+
849
+ # Reset must apply BEFORE any widget below is instantiated this run --
850
+ # Streamlit forbids writing session_state[key] once a run has already
851
+ # created the widget bound to that key. The Reset button (below) just
852
+ # sets this flag and calls st.rerun(); on the resulting fresh run, this
853
+ # check fires first, so the defaults land before any widget exists.
854
+ if st.session_state.pop("_do_reset", False):
855
+ for key, val in DEFAULTS.items():
856
+ st.session_state[key] = val
857
+
858
+ with st.sidebar:
859
+ st.header("Data")
860
+
861
+ # Your own data is the primary action, so it goes first. The sample
862
+ # button sits below it and is deliberately not full-width: as a
863
+ # prominent full-width button on top it was getting clicked by
864
+ # mistake by people who meant to upload their own file.
865
+ with st.expander("What format should my data be in?"):
866
+ st.markdown(DATA_FORMAT_HELP)
867
+ uploaded = st.file_uploader(
868
+ "Load a CSV file", type=["csv", "txt"],
869
+ help="One row per time point, in time order, with a header row. "
870
+ "See the format note above.",
871
+ )
872
+ sample_clicked = st.button(
873
+ "Try sample data",
874
+ help="Load the bundled East Lansing daily weather set (a recent slice), "
875
+ "to see the pipeline work before using your own data.",
876
+ )
877
+
878
+ # Identify the upload by name+size so it loads once, when it actually
879
+ # changes. Keying off data_source instead meant the uploader re-fired
880
+ # on every rerun where something else had been loaded -- which
881
+ # silently undid the sample button whenever a file was attached.
882
+ upload_token = None if uploaded is None else (uploaded.name, uploaded.size)
883
+ action = resolve_data_action(
884
+ sample_clicked, upload_token, st.session_state.get("_upload_token")
885
+ )
886
+
887
+ if action == "sample":
888
+ # the bundled CSV is the *full* historical daily record (57709
889
+ # rows) -- same recent-slice trim as this project's Quickstart/
890
+ # tmapper_demo.m (dat.iloc[53883:]), since the untrimmed file
891
+ # is unusable as-is: cdist's pairwise distance matrix is O(N^2)
892
+ # in memory (57709 rows would need ~25 GiB).
893
+ sample_df, dropped_col = read_csv_smart(SAMPLE_DATA_PATH)
894
+ sample_df = sample_df.iloc[53883:].reset_index(drop=True)
895
+ src = ["from tmapper import sample_data_path",
896
+ "dat = pd.read_csv(sample_data_path())"]
897
+ if dropped_col:
898
+ src.append("dat = dat.drop(columns=dat.columns[0]) # stray unnamed index column")
899
+ src.append("dat = dat.iloc[53883:].reset_index(drop=True) # recent slice, as in the Quickstart")
900
+ set_data(sample_df, f"sample data ({SAMPLE_DATA_PATH.name}, recent slice)", "\n".join(src))
901
+ # claim the attached upload (if any) so it doesn't reload over this
902
+ st.session_state["_upload_token"] = upload_token
903
+ elif action == "upload":
904
+ st.session_state["_upload_token"] = upload_token
905
+ up_df, dropped_col = read_csv_smart(uploaded)
906
+ # only the filename is available (Streamlit uploads are
907
+ # in-memory), so the generated line assumes the file sits in
908
+ # the working directory -- adjust the path when re-running.
909
+ src = [f'dat = pd.read_csv("{uploaded.name}")']
910
+ if dropped_col:
911
+ src.append("dat = dat.drop(columns=dat.columns[0]) # stray unnamed index column")
912
+ src.append("dat = dat.reset_index(drop=True)")
913
+ set_data(up_df, f"uploaded: {uploaded.name}", "\n".join(src))
914
+
915
+ if "data" not in st.session_state:
916
+ st.info("Load data to get started.")
917
+ return
918
+ df = st.session_state["data"]
919
+ st.caption(f"Loaded: {len(df)} rows from {st.session_state['data_source']}")
920
+
921
+ st.header("Variables & Preprocessing")
922
+ selected_vars = st.multiselect(
923
+ "Variables", st.session_state["numeric_vars"],
924
+ default=st.session_state["selected_vars"], key="selected_vars",
925
+ help="The columns that together define the system's state at each time "
926
+ "point. Distances between time points are computed across these.",
927
+ )
928
+ zscore_on = st.checkbox(
929
+ "z-score variables", value=DEFAULTS["zscore"], key="zscore",
930
+ help="Standardise each variable before measuring distances, so one "
931
+ "high-variance or large-unit column doesn't dominate. Uses ddof=1, "
932
+ "matching MATLAB's zscore.",
933
+ )
934
+ col1, col2 = st.columns(2)
935
+ start_row = col1.number_input(
936
+ "start row (0-indexed)", min_value=0, max_value=max(len(df) - 1, 0),
937
+ value=DEFAULTS["start_row"], key="start_row",
938
+ help="First row to include, counting from 0. Use this with 'end row' to "
939
+ "analyse a sub-range, or to cut a long recording down to a workable "
940
+ "size.",
941
+ )
942
+ end_row_str = col2.text_input(
943
+ "end row", value="last", key="end_row_str",
944
+ help="A number, or 'last' for the final row.",
945
+ )
946
+ end_row = None if end_row_str.strip().lower() == "last" else int(end_row_str)
947
+
948
+ tidx_choice = st.selectbox(
949
+ "time index",
950
+ ["(from row order)"] + time_column_options(df)[1:],
951
+ key="tidx_var",
952
+ help="Which samples count as temporally adjacent. Points are linked in time "
953
+ "only when their index differs by exactly 1, so gaps in this column "
954
+ "break the chain -- use it for data with real breaks (separate "
955
+ "sessions/trials) or irregular sampling. Default derives it from row "
956
+ "order.",
957
+ )
958
+ tidx_var = None if tidx_choice == "(from row order)" else tidx_choice
959
+
960
+ downsample = st.number_input(
961
+ "downsample (N)", min_value=1, value=DEFAULTS["downsample"], key="downsample",
962
+ help="Keep every Nth row; a centered rolling-mean lowpass is applied first "
963
+ "to avoid aliasing. Works alongside a time index column, provided that "
964
+ "column is evenly sampled (gaps are fine).",
965
+ )
966
+ col3, col4 = st.columns(2)
967
+ lag = col3.number_input(
968
+ "embed lag", min_value=0, value=DEFAULTS["embed_lag"], key="embed_lag",
969
+ help="Delay embedding: how many samples back each extra copy of the state "
970
+ "is taken from. Ignored when embed order is 1.",
971
+ )
972
+ order = col4.number_input(
973
+ "embed order", min_value=1, value=DEFAULTS["embed_order"], key="embed_order",
974
+ help="How many lagged copies of the state to stack. Order 1 (default) means "
975
+ "no embedding. Use this when the raw variables are too few to separate "
976
+ "distinct states -- it lifts the data into a higher-dimensional space "
977
+ "where cyclic structure becomes visible. Costs (order-1)*lag samples.",
978
+ )
979
+
980
+ st.header("Network Parameters")
981
+ col5, col6 = st.columns(2)
982
+ k = col5.number_input(
983
+ "k (neighbors)", min_value=1, value=DEFAULTS["k"], key="k",
984
+ help="Max spatial neighbours each time point may link to. This is the main "
985
+ "knob: it largely determines the network's topology, so explore it "
986
+ "first. Larger k gives a denser graph.",
987
+ )
988
+ d = col6.number_input(
989
+ "d (compression)", min_value=0.0, value=DEFAULTS["d"], key="d",
990
+ help="Compression threshold. Time points within this geodesic distance of "
991
+ "each other collapse into a single node, so loops shorter than d are "
992
+ "absorbed. Larger d gives a coarser network.",
993
+ )
994
+ texclude = st.number_input(
995
+ "texclude", min_value=1, value=DEFAULTS["texclude"], key="texclude",
996
+ help="How many following time points count as temporal neighbours and are "
997
+ "therefore barred from also counting as spatial neighbours -- this "
998
+ "stops adjacent moments being read as 'recurrence'. Set it from your "
999
+ "sampling density: the number of samples over which the system hasn't "
1000
+ "really moved. Values under 10 are typical.",
1001
+ )
1002
+ col7, col8 = st.columns(2)
1003
+ maxdistprct = col7.number_input(
1004
+ "max dist %ile", min_value=0.0, max_value=100.0,
1005
+ value=DEFAULTS["maxdistprct"], key="maxdistprct",
1006
+ help="Neighbour cutoff as a percentile of all pairwise distances (100 = no "
1007
+ "cutoff). Optional -- useful to stop outliers being linked as "
1008
+ "neighbours.",
1009
+ )
1010
+ # st.number_input rejects inf outright (enforces a finite JS-float
1011
+ # bound), so this is a text field with an "inf" sentinel instead --
1012
+ # same convention as "end row" above.
1013
+ maxdist_str = col8.text_input(
1014
+ "max dist", value="inf", key="maxdist_str",
1015
+ help="The same cutoff as an absolute distance; 'inf' means no cutoff. When "
1016
+ "both are set, the stricter of the two applies.",
1017
+ )
1018
+ try:
1019
+ maxdist = np.inf if maxdist_str.strip().lower() == "inf" else float(maxdist_str)
1020
+ except ValueError:
1021
+ st.error(f"max dist must be a number or 'inf', got {maxdist_str!r}.")
1022
+ return
1023
+ reciprocal = st.checkbox(
1024
+ "reciprocal", value=DEFAULTS["reciprocal"], key="reciprocal",
1025
+ help="Require the nearest-neighbour relation to hold in both directions "
1026
+ "(x is a neighbour of y AND y of x). The stricter, recommended "
1027
+ "setting for directed graphs.",
1028
+ )
1029
+
1030
+ st.header("Plot Options")
1031
+ color_var = st.selectbox(
1032
+ "Color by", color_column_options(df), key="color_var",
1033
+ help="Value used to colour nodes -- numeric, a date, or a category "
1034
+ "(condition, trial, behavioural state). Each node aggregates its "
1035
+ "member time points using the label method below.",
1036
+ )
1037
+ color_is_categorical = (
1038
+ color_var != "(row index)" and color_var in categorical_columns(df)
1039
+ )
1040
+ if color_var == "(row index)":
1041
+ color_kind = "index"
1042
+ elif color_is_categorical:
1043
+ color_kind = "category"
1044
+ elif color_var in datetime_columns(df):
1045
+ color_kind = "datetime"
1046
+ else:
1047
+ color_kind = "numeric"
1048
+ cmap_choices = CATEGORICAL_CMAPS if color_is_categorical else CONTINUOUS_CMAPS
1049
+ cmap = st.selectbox(
1050
+ "Colormap", cmap_choices, key="cmap_categorical" if color_is_categorical else "cmap",
1051
+ help=("Qualitative palette: adjacent colours are unrelated, which is what "
1052
+ "you want for categories." if color_is_categorical else
1053
+ "Continuous palette. 'jet' is the toolbox default; viridis/cividis are "
1054
+ "perceptually uniform and colour-blind friendlier."),
1055
+ )
1056
+ time_var = st.selectbox(
1057
+ "Time axis", time_column_options(df), key="time_var",
1058
+ help="Values labelling the recurrence plot's axes. Only affects that plot.",
1059
+ )
1060
+ nodesizemode = st.selectbox(
1061
+ "Node size", ["log", "rank", "original"], key="nodesizemode",
1062
+ help="How a node's member count maps to marker size. Node size reflects how "
1063
+ "long the system dwells in that state, so counts span a wide range -- "
1064
+ "'log' or 'rank' usually read better than 'original'.",
1065
+ )
1066
+ labelmethod = st.selectbox(
1067
+ "Label method",
1068
+ # averaging category codes is meaningless, so only mode/none apply
1069
+ ["mode", "none"] if color_is_categorical else ["mode", "mean", "median", "none"],
1070
+ key="labelmethod_categorical" if color_is_categorical else "labelmethod",
1071
+ help="How each node's colour is aggregated from its member time points. "
1072
+ "Use 'mode' for categorical labels, 'mean'/'median' for continuous "
1073
+ "values, 'none' for a single flat colour.",
1074
+ )
1075
+ show_recurrence = st.checkbox(
1076
+ "Show recurrence plot", value=DEFAULTS["show_recurrence"], key="show_recurrence",
1077
+ help="Also draw the geodesic recurrence plot: for every pair of time points, "
1078
+ "the shortest path between their nodes in the network.",
1079
+ )
1080
+
1081
+ col9, col10 = st.columns(2)
1082
+ build_clicked = col9.button(
1083
+ "Build Network", type="primary", use_container_width=True,
1084
+ help="Run the pipeline with the settings above. Plot Options re-render "
1085
+ "without needing this again.",
1086
+ )
1087
+ if col10.button("Reset", use_container_width=True,
1088
+ help="Restore every parameter to its default. Keeps your loaded data."):
1089
+ st.session_state["_do_reset"] = True
1090
+ st.rerun()
1091
+
1092
+ if build_clicked:
1093
+ resolved_end = (len(df) - 1) if end_row is None else min(end_row, len(df) - 1)
1094
+ window_rows = resolved_end - start_row + 1
1095
+ oversized = oversized_window_message(window_rows, downsample)
1096
+ if not selected_vars:
1097
+ st.error("Select at least one variable to build the network from.")
1098
+ elif oversized:
1099
+ st.error(oversized)
1100
+ else:
1101
+ with st.spinner("Building network..."):
1102
+ try:
1103
+ built = build_network(
1104
+ df, tuple(selected_vars), zscore_on, start_row, end_row, downsample,
1105
+ lag, order, k, d, texclude, maxdistprct, maxdist, reciprocal,
1106
+ tidx_var,
1107
+ )
1108
+ st.session_state["built"] = built
1109
+ except ValueError as e:
1110
+ st.session_state.pop("built", None)
1111
+ st.error(str(e))
1112
+
1113
+ if "built" in st.session_state:
1114
+ built = st.session_state["built"]
1115
+ if built["n_dropped"] > 0:
1116
+ # With downsampling the lowpass averages over whatever is present,
1117
+ # so a missing row usually costs no sample at all -- only a grid
1118
+ # point whose entire window is missing actually disappears. Say
1119
+ # which happened rather than implying rows are always discarded.
1120
+ msg = (f"{built['n_dropped']} of {built['n_window']} row(s) in the selected "
1121
+ "range have missing values in the selected variables.")
1122
+ if built["n_grid_dropped"] > 0:
1123
+ msg += (f" Dropped {built['n_grid_dropped']} sample(s), leaving a real "
1124
+ "gap in time — no temporal link is made across it.")
1125
+ else:
1126
+ msg += " The anti-aliasing average covered them, so no sample was lost."
1127
+ st.warning(msg)
1128
+ st.success(f"Built network: {built['g_simp'].number_of_nodes()} nodes, "
1129
+ f"{built['g_simp'].number_of_edges()} edges.")
1130
+ html, rec_fig, colorvar, colorlabel, title, nodeclim = render_network(
1131
+ built, df, color_var, time_var, nodesizemode, labelmethod,
1132
+ show_recurrence, cmap,
1133
+ )
1134
+
1135
+ # generated once: shown in the code panel below AND bundled into
1136
+ # the export zip as reproduce.py
1137
+ code = generate_code(
1138
+ st.session_state["data_source_code"], selected_vars, zscore_on, start_row, end_row,
1139
+ downsample, lag, order, k, d, texclude, maxdistprct, maxdist, reciprocal,
1140
+ color_var, time_var, nodesizemode, labelmethod, show_recurrence, tidx_var, cmap,
1141
+ color_kind,
1142
+ )
1143
+
1144
+ with st.expander("Export / share"):
1145
+ # the pyvis page is built with cdn_resources="in_line", so this
1146
+ # file is fully self-contained -- it stays interactive offline
1147
+ # and can just be emailed or dropped in a shared folder.
1148
+ st.download_button(
1149
+ "Interactive network (.html)", data=html, file_name="tmgraph.html",
1150
+ mime="text/html", use_container_width=True,
1151
+ help="Self-contained page: still draggable/zoomable with no internet or install.",
1152
+ )
1153
+
1154
+ if rec_fig is not None:
1155
+ buf = BytesIO()
1156
+ rec_fig.savefig(buf, format="png", dpi=200, bbox_inches="tight")
1157
+ st.download_button(
1158
+ "Recurrence plot (.png)", data=buf.getvalue(),
1159
+ file_name="recurrence_plot.png", mime="image/png",
1160
+ use_container_width=True,
1161
+ )
1162
+
1163
+ # Static network PNG is gated behind a checkbox: unlike the two
1164
+ # above (whose artifacts already exist), it re-runs the layout,
1165
+ # which would otherwise cost that on every single rerun.
1166
+ if st.checkbox("Render a static network PNG (for figures)"):
1167
+ import matplotlib.pyplot as plt
1168
+ from tmapper import plot_tmgraph
1169
+
1170
+ with st.spinner("Rendering static figure..."):
1171
+ fig_static, ax_static = plt.subplots(figsize=(7, 6))
1172
+ plot_tmgraph(
1173
+ built["g_simp"], colorvar, built["members"], ax=ax_static,
1174
+ nodesizemode=nodesizemode, labelmethod=labelmethod,
1175
+ colorlabel=colorlabel, cmap=cmap, nodeclim=nodeclim,
1176
+ )
1177
+ ax_static.set_title(title, fontsize=9)
1178
+ sbuf = BytesIO()
1179
+ fig_static.savefig(sbuf, format="png", dpi=200, bbox_inches="tight")
1180
+ show_figure(fig_static)
1181
+ plt.close(fig_static)
1182
+ st.download_button(
1183
+ "Network figure (.png)", data=sbuf.getvalue(),
1184
+ file_name="tmgraph.png", mime="image/png", use_container_width=True,
1185
+ )
1186
+
1187
+ st.markdown("**Data for downstream analysis**")
1188
+ st.caption(
1189
+ "Deliberately excludes the geodesic/simplified distance matrices: both are "
1190
+ "O(n²) (the recurrence matrix alone is ~120 MB on the sample data) and both "
1191
+ "are one line to regenerate from the files below."
1192
+ )
1193
+
1194
+ timeline_csv = build_timeline_csv(built, df, color_var, time_var)
1195
+ st.download_button(
1196
+ "Timeline (.csv)", data=timeline_csv, file_name="timeline.csv",
1197
+ mime="text/csv", use_container_width=True,
1198
+ help="One row per retained time point: source row, tidx, and which node it belongs to. "
1199
+ "Join this back to your data for dwell times, transition rates, occupancy stats.",
1200
+ )
1201
+
1202
+ graphml_bytes = build_graphml(built, colorvar, labelmethod)
1203
+ st.download_button(
1204
+ "Network (.graphml)", data=graphml_bytes, file_name="network.graphml",
1205
+ mime="application/xml", use_container_width=True,
1206
+ help="Topology + edge weights + per-node attributes. Opens in Gephi/Cytoscape, "
1207
+ "round-trips through networkx.read_graphml.",
1208
+ )
1209
+
1210
+ params_json = build_params_json(
1211
+ built, st.session_state["data_source"], st.session_state["data_source_code"],
1212
+ selected_vars, zscore_on, start_row, end_row, downsample, lag, order,
1213
+ k, d, texclude, maxdistprct, maxdist, reciprocal,
1214
+ color_var, time_var, nodesizemode, labelmethod, show_recurrence, tidx_var, cmap,
1215
+ )
1216
+ st.download_button(
1217
+ "Parameters (.json)", data=params_json, file_name="params.json",
1218
+ mime="application/json", use_container_width=True,
1219
+ help="Full provenance: data source, every preprocessing/build/plot setting, "
1220
+ "and the resulting network's shape.",
1221
+ )
1222
+
1223
+ st.markdown("---")
1224
+ st.download_button(
1225
+ "⬇ Everything (.zip)", data=build_export_zip(
1226
+ html, timeline_csv, graphml_bytes, params_json, code
1227
+ ),
1228
+ file_name="tmapper_export.zip", mime="application/zip",
1229
+ type="primary", use_container_width=True,
1230
+ help="All three files above, plus the interactive page and a reproduce.py script.",
1231
+ )
1232
+
1233
+ with st.expander("Show equivalent code"):
1234
+ st.code(code, language="python")
1235
+ else:
1236
+ st.info("Set parameters in the sidebar and click **Build Network** to get started.")
1237
+
1238
+
1239
+ if __name__ == "__main__":
1240
+ main()