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/__init__.py +63 -0
- tmapper/_shortest_path.py +22 -0
- tmapper/app/__init__.py +10 -0
- tmapper/app/launcher.py +35 -0
- tmapper/app/streamlit_app.py +1240 -0
- tmapper/cknngraph.py +73 -0
- tmapper/cycle_cluster.py +111 -0
- tmapper/cycle_cluster_conn.py +112 -0
- tmapper/cycle_count.py +150 -0
- tmapper/cycle_count2p.py +72 -0
- tmapper/cycle_cutter.py +47 -0
- tmapper/cycle_overlap.py +71 -0
- tmapper/cycle_path_decomp.py +104 -0
- tmapper/cycles_to_paths.py +40 -0
- tmapper/filtergraph.py +107 -0
- tmapper/graph_utils.py +283 -0
- tmapper/knngraph.py +61 -0
- tmapper/labeling.py +45 -0
- tmapper/modularity.py +74 -0
- tmapper/path_traffic.py +35 -0
- tmapper/plotting.py +463 -0
- tmapper/sample_data.py +19 -0
- tmapper/sampledata/EL_temp.csv +57710 -0
- tmapper/tcm_distance.py +63 -0
- tmapper/tknndigraph.py +190 -0
- tmapper_py-0.1.0.dist-info/METADATA +108 -0
- tmapper_py-0.1.0.dist-info/RECORD +31 -0
- tmapper_py-0.1.0.dist-info/WHEEL +5 -0
- tmapper_py-0.1.0.dist-info/entry_points.txt +2 -0
- tmapper_py-0.1.0.dist-info/licenses/LICENSE +28 -0
- tmapper_py-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|