canns 0.12.7__py3-none-any.whl → 0.13.1__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.
Files changed (36) hide show
  1. canns/analyzer/data/__init__.py +3 -11
  2. canns/analyzer/data/asa/__init__.py +84 -0
  3. canns/analyzer/data/asa/cohospace.py +905 -0
  4. canns/analyzer/data/asa/config.py +246 -0
  5. canns/analyzer/data/asa/decode.py +445 -0
  6. canns/analyzer/data/asa/embedding.py +269 -0
  7. canns/analyzer/data/asa/filters.py +208 -0
  8. canns/analyzer/data/{cann1d.py → asa/fly_roi.py} +98 -45
  9. canns/analyzer/data/asa/fr.py +431 -0
  10. canns/analyzer/data/asa/path.py +389 -0
  11. canns/analyzer/data/asa/plotting.py +1287 -0
  12. canns/analyzer/data/asa/tda.py +901 -0
  13. canns/analyzer/visualization/core/backend.py +1 -1
  14. canns/analyzer/visualization/core/config.py +77 -0
  15. canns/analyzer/visualization/core/rendering.py +10 -6
  16. canns/analyzer/visualization/energy_plots.py +22 -8
  17. canns/analyzer/visualization/spatial_plots.py +31 -11
  18. canns/analyzer/visualization/theta_sweep_plots.py +15 -6
  19. canns/pipeline/__init__.py +4 -8
  20. canns/pipeline/asa/__init__.py +21 -0
  21. canns/pipeline/asa/__main__.py +11 -0
  22. canns/pipeline/asa/app.py +1000 -0
  23. canns/pipeline/asa/runner.py +1095 -0
  24. canns/pipeline/asa/screens.py +215 -0
  25. canns/pipeline/asa/state.py +248 -0
  26. canns/pipeline/asa/styles.tcss +221 -0
  27. canns/pipeline/asa/widgets.py +233 -0
  28. canns/pipeline/gallery/__init__.py +7 -0
  29. canns/task/open_loop_navigation.py +3 -1
  30. {canns-0.12.7.dist-info → canns-0.13.1.dist-info}/METADATA +6 -3
  31. {canns-0.12.7.dist-info → canns-0.13.1.dist-info}/RECORD +34 -17
  32. {canns-0.12.7.dist-info → canns-0.13.1.dist-info}/entry_points.txt +1 -0
  33. canns/analyzer/data/cann2d.py +0 -2565
  34. canns/pipeline/theta_sweep.py +0 -573
  35. {canns-0.12.7.dist-info → canns-0.13.1.dist-info}/WHEEL +0 -0
  36. {canns-0.12.7.dist-info → canns-0.13.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1000 @@
1
+ """Main ASA TUI application with two-page workflow.
2
+
3
+ This module provides the main Textual application for ASA analysis,
4
+ following the original GUI's two-page structure:
5
+ 1. PreprocessPage - File selection and preprocessing
6
+ 2. AnalysisPage - Analysis mode selection and execution
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical, VerticalScroll
14
+ from textual.widgets import (
15
+ Button,
16
+ Checkbox,
17
+ DirectoryTree,
18
+ Footer,
19
+ Header,
20
+ Input,
21
+ Label,
22
+ ProgressBar,
23
+ Select,
24
+ Static,
25
+ TabbedContent,
26
+ TabPane,
27
+ )
28
+ from textual.worker import Worker
29
+
30
+ from .runner import PipelineRunner
31
+ from .screens import ErrorScreen, HelpScreen, TerminalSizeWarning, WorkdirScreen
32
+ from .state import WorkflowState, get_preset_params, relative_path, validate_files
33
+ from .widgets import ImagePreview, LogViewer, ParamGroup
34
+
35
+
36
+ class ASAApp(App):
37
+ """Main TUI application for ASA analysis."""
38
+
39
+ CSS_PATH = "styles.tcss"
40
+ TITLE = "Attractor Structure Analyzer (ASA)"
41
+
42
+ # Terminal size requirements
43
+ MIN_WIDTH = 100
44
+ RECOMMENDED_WIDTH = 120
45
+ MIN_HEIGHT = 30
46
+ RECOMMENDED_HEIGHT = 40
47
+
48
+ BINDINGS = [
49
+ Binding("ctrl+w", "change_workdir", "Workdir"),
50
+ Binding("ctrl+r", "run_action", "Run"),
51
+ Binding("f5", "refresh", "Refresh"),
52
+ Binding("question_mark", "help", "Help"),
53
+ Binding("escape", "quit", "Quit"),
54
+ ]
55
+
56
+ def __init__(self):
57
+ super().__init__()
58
+ self.state = WorkflowState()
59
+ self.runner = PipelineRunner()
60
+ self.current_worker: Worker = None
61
+ self._size_warning_shown = False
62
+ self.current_page = "preprocess" # "preprocess" or "analysis"
63
+
64
+ def compose(self) -> ComposeResult:
65
+ """Compose the main UI layout."""
66
+ yield Header()
67
+
68
+ with Horizontal(id="main-container"):
69
+ # Left panel (controls)
70
+ with Vertical(id="left-panel"):
71
+ yield Label(f"Workdir: {self.state.workdir}", id="workdir-label")
72
+ yield Button("Change Workdir", id="change-workdir-btn")
73
+
74
+ # Page indicator
75
+ yield Label("Page: Preprocess", id="page-indicator")
76
+
77
+ # Action buttons and progress (OUTSIDE scroll area)
78
+ with Vertical(id="actions-bar"):
79
+ yield Button("Continue →", variant="primary", id="continue-btn")
80
+ yield Button("← Back", variant="primary", id="back-btn", classes="hidden")
81
+ yield Button(
82
+ "Run Analysis", variant="primary", id="run-analysis-btn", classes="hidden"
83
+ )
84
+ yield Button(
85
+ "Stop", variant="error", id="stop-btn", classes="hidden", disabled=True
86
+ )
87
+ yield ProgressBar(id="progress-bar")
88
+ yield Static("Status: Idle", id="run-status")
89
+
90
+ # Middle panel (parameters + file browser)
91
+ with Horizontal(id="middle-panel"):
92
+ with Vertical(id="params-panel"):
93
+ yield Label("Parameters", id="params-header")
94
+ with VerticalScroll(id="controls-scroll"):
95
+ # Preprocess controls - single param group
96
+ with Vertical(id="preprocess-controls"):
97
+ with ParamGroup("Input & Preprocess"):
98
+ # Input section
99
+ yield Label("Input Mode:")
100
+ yield Select(
101
+ [("ASA File", "asa"), ("Neuron + Traj", "neuron_traj")],
102
+ value="asa",
103
+ id="input-mode-select",
104
+ )
105
+
106
+ yield Label("Preset:")
107
+ yield Select(
108
+ [("Grid", "grid"), ("HD", "hd"), ("None", "none")],
109
+ value="grid",
110
+ id="preset-select",
111
+ )
112
+
113
+ # Preprocess section
114
+ yield Label("Method:")
115
+ yield Select(
116
+ [
117
+ ("None", "none"),
118
+ ("Embed Spike Trains", "embed_spike_trains"),
119
+ ],
120
+ value="none",
121
+ id="preprocess-method-select",
122
+ )
123
+
124
+ # Preprocessing parameters (enabled when method is embed_spike_trains)
125
+ with Vertical(id="emb-params"):
126
+ yield Label("res:", id="emb-res-label")
127
+ yield Input(value="100000", id="emb-res", disabled=True)
128
+
129
+ yield Label("dt:", id="emb-dt-label")
130
+ yield Input(value="1000", id="emb-dt", disabled=True)
131
+
132
+ yield Label("sigma:", id="emb-sigma-label")
133
+ yield Input(value="5000", id="emb-sigma", disabled=True)
134
+
135
+ yield Checkbox(
136
+ "smooth", id="emb-smooth", value=True, disabled=True
137
+ )
138
+ yield Checkbox(
139
+ "speed_filter",
140
+ id="emb-speed-filter",
141
+ value=False,
142
+ disabled=True,
143
+ )
144
+
145
+ yield Label("min_speed:", id="emb-min-speed-label")
146
+ yield Input(value="2.5", id="emb-min-speed", disabled=True)
147
+
148
+ # Analysis controls (initially hidden)
149
+ with Vertical(id="analysis-controls", classes="hidden"):
150
+ preset_params = get_preset_params(self.state.preset)
151
+ tda_defaults = preset_params.get("tda", {})
152
+ grid_defaults = preset_params.get("gridscore", {})
153
+
154
+ with ParamGroup("Analysis Mode"):
155
+ yield Label("Mode:")
156
+ yield Select(
157
+ [
158
+ ("TDA", "tda"),
159
+ ("CohoMap", "cohomap"),
160
+ ("PathCompare", "pathcompare"),
161
+ ("CohoSpace", "cohospace"),
162
+ ("FR", "fr"),
163
+ ("FRM", "frm"),
164
+ ("GridScore", "gridscore"),
165
+ ],
166
+ value=self.state.analysis_mode,
167
+ id="analysis-mode-select",
168
+ )
169
+
170
+ with ParamGroup("TDA Parameters", id="analysis-params-tda"):
171
+ yield Label("dim:")
172
+ yield Input(value=str(tda_defaults.get("dim", 6)), id="tda-dim")
173
+ yield Label("num_times:")
174
+ yield Input(
175
+ value=str(tda_defaults.get("num_times", 5)), id="tda-num-times"
176
+ )
177
+ yield Label("active_times:")
178
+ yield Input(
179
+ value=str(tda_defaults.get("active_times", 15000)),
180
+ id="tda-active-times",
181
+ )
182
+ yield Label("k:")
183
+ yield Input(value=str(tda_defaults.get("k", 1000)), id="tda-k")
184
+ yield Label("n_points:")
185
+ yield Input(
186
+ value=str(tda_defaults.get("n_points", 1200)), id="tda-n-points"
187
+ )
188
+ yield Label("metric:")
189
+ yield Select(
190
+ [
191
+ ("cosine", "cosine"),
192
+ ("euclidean", "euclidean"),
193
+ ("correlation", "correlation"),
194
+ ],
195
+ value=str(tda_defaults.get("metric", "cosine")),
196
+ id="tda-metric",
197
+ )
198
+ yield Label("nbs:")
199
+ yield Input(value=str(tda_defaults.get("nbs", 800)), id="tda-nbs")
200
+ yield Label("maxdim:")
201
+ yield Input(
202
+ value=str(tda_defaults.get("maxdim", 1)), id="tda-maxdim"
203
+ )
204
+ yield Label("coeff:")
205
+ yield Input(
206
+ value=str(tda_defaults.get("coeff", 47)), id="tda-coeff"
207
+ )
208
+ yield Checkbox(
209
+ "do_shuffle",
210
+ id="tda-do-shuffle",
211
+ value=tda_defaults.get("do_shuffle", False),
212
+ )
213
+ yield Label("num_shuffles:")
214
+ yield Input(
215
+ value=str(tda_defaults.get("num_shuffles", 1000)),
216
+ id="tda-num-shuffles",
217
+ )
218
+ yield Checkbox(
219
+ "standardize (StandardScaler)", id="tda-standardize", value=True
220
+ )
221
+
222
+ with ParamGroup(
223
+ "Decode / CohoMap", id="analysis-params-decode", classes="hidden"
224
+ ):
225
+ yield Label("decode_version:")
226
+ yield Select(
227
+ [
228
+ ("v2 (multi)", "v2"),
229
+ ("v0 (legacy)", "v0"),
230
+ ],
231
+ value="v2",
232
+ id="decode-version",
233
+ )
234
+ yield Label("num_circ:")
235
+ yield Input(value="2", id="decode-num-circ")
236
+ yield Label("cohomap_subsample:")
237
+ yield Input(value="10", id="cohomap-subsample")
238
+ yield Checkbox(
239
+ "real_ground (v0)", id="decode-real-ground", value=True
240
+ )
241
+ yield Checkbox("real_of (v0)", id="decode-real-of", value=True)
242
+
243
+ with ParamGroup(
244
+ "PathCompare Parameters",
245
+ id="analysis-params-pathcompare",
246
+ classes="hidden",
247
+ ):
248
+ yield Checkbox(
249
+ "use_box (coordsbox/times_box)", id="pc-use-box", value=True
250
+ )
251
+ yield Checkbox(
252
+ "interp_to_full (use_box)", id="pc-interp-full", value=True
253
+ )
254
+ yield Label("dim_mode:")
255
+ yield Select(
256
+ [("2d", "2d"), ("1d", "1d")],
257
+ value="2d",
258
+ id="pc-dim-mode",
259
+ )
260
+ yield Label("dim (1d):")
261
+ yield Input(value="1", id="pc-dim")
262
+ yield Label("dim1 (2d):")
263
+ yield Input(value="1", id="pc-dim1")
264
+ yield Label("dim2 (2d):")
265
+ yield Input(value="2", id="pc-dim2")
266
+ yield Label("coords_key (optional):")
267
+ yield Input(value="", id="pc-coords-key")
268
+ yield Label("times_box_key (optional):")
269
+ yield Input(value="", id="pc-times-key")
270
+ yield Label("slice_mode:")
271
+ yield Select(
272
+ [("time", "time"), ("index", "index")],
273
+ value="time",
274
+ id="pc-slice-mode",
275
+ )
276
+ yield Label("tmin (sec, -1=auto):")
277
+ yield Input(value="-1", id="pc-tmin")
278
+ yield Label("tmax (sec, -1=auto):")
279
+ yield Input(value="-1", id="pc-tmax")
280
+ yield Label("imin (-1=auto):")
281
+ yield Input(value="-1", id="pc-imin")
282
+ yield Label("imax (-1=auto):")
283
+ yield Input(value="-1", id="pc-imax")
284
+ yield Label("stride:")
285
+ yield Input(value="1", id="pc-stride")
286
+ yield Label("theta_scale (rad/deg/unit/auto):")
287
+ yield Input(value="rad", id="pathcompare-angle-scale")
288
+
289
+ with ParamGroup(
290
+ "CohoSpace Parameters",
291
+ id="analysis-params-cohospace",
292
+ classes="hidden",
293
+ ):
294
+ yield Label("dim_mode:")
295
+ yield Select(
296
+ [("2d", "2d"), ("1d", "1d")],
297
+ value="2d",
298
+ id="coho-dim-mode",
299
+ )
300
+ yield Label("dim (1d):")
301
+ yield Input(value="1", id="coho-dim")
302
+ yield Label("dim1 (2d):")
303
+ yield Input(value="1", id="coho-dim1")
304
+ yield Label("dim2 (2d):")
305
+ yield Input(value="2", id="coho-dim2")
306
+ yield Label("mode (fr/spike):")
307
+ yield Select(
308
+ [("fr", "fr"), ("spike", "spike")],
309
+ value="fr",
310
+ id="coho-mode",
311
+ )
312
+ yield Label("top_percent (fr):")
313
+ yield Input(value="5.0", id="coho-top-percent")
314
+ yield Label("view:")
315
+ yield Select(
316
+ [
317
+ ("both", "both"),
318
+ ("single", "single"),
319
+ ("population", "population"),
320
+ ],
321
+ value="both",
322
+ id="coho-view",
323
+ )
324
+ yield Label("neuron_id (optional):")
325
+ yield Input(value="", id="cohospace-neuron-id")
326
+ yield Label("subsample (trajectory):")
327
+ yield Input(value="2", id="coho-subsample")
328
+ yield Label("unfold:")
329
+ yield Select(
330
+ [("square", "square"), ("skew", "skew")],
331
+ value="square",
332
+ id="coho-unfold",
333
+ )
334
+ yield Checkbox(
335
+ "skew_show_grid", id="coho-skew-show-grid", value=True
336
+ )
337
+ yield Label("skew_tiles:")
338
+ yield Input(value="0", id="coho-skew-tiles")
339
+
340
+ with ParamGroup(
341
+ "FR Parameters", id="analysis-params-fr", classes="hidden"
342
+ ):
343
+ yield Label("neuron_start:")
344
+ yield Input(value="", id="fr-neuron-start")
345
+ yield Label("neuron_end:")
346
+ yield Input(value="", id="fr-neuron-end")
347
+ yield Label("time_start:")
348
+ yield Input(value="", id="fr-time-start")
349
+ yield Label("time_end:")
350
+ yield Input(value="", id="fr-time-end")
351
+ yield Label("mode (fr/spike):")
352
+ yield Select(
353
+ [("fr", "fr"), ("spike", "spike")],
354
+ value="fr",
355
+ id="fr-mode",
356
+ )
357
+ yield Label("normalize:")
358
+ yield Select(
359
+ [
360
+ ("zscore_per_neuron", "zscore_per_neuron"),
361
+ ("minmax_per_neuron", "minmax_per_neuron"),
362
+ ("none", "none"),
363
+ ],
364
+ value="none",
365
+ id="fr-normalize",
366
+ )
367
+
368
+ with ParamGroup(
369
+ "FRM Parameters", id="analysis-params-frm", classes="hidden"
370
+ ):
371
+ yield Label("neuron_id:")
372
+ yield Input(value="0", id="frm-neuron-id")
373
+ yield Label("bins:")
374
+ yield Input(value="50", id="frm-bins")
375
+ yield Label("min_occupancy:")
376
+ yield Input(value="1", id="frm-min-occupancy")
377
+ yield Checkbox("smoothing", id="frm-smoothing", value=False)
378
+ yield Label("smooth_sigma:")
379
+ yield Input(value="2.0", id="frm-smooth-sigma")
380
+ yield Label("mode (fr/spike):")
381
+ yield Select(
382
+ [("fr", "fr"), ("spike", "spike")],
383
+ value="fr",
384
+ id="frm-mode",
385
+ )
386
+
387
+ with ParamGroup(
388
+ "GridScore Parameters",
389
+ id="analysis-params-gridscore",
390
+ classes="hidden",
391
+ ):
392
+ yield Label("annulus_inner:")
393
+ yield Input(
394
+ value=str(grid_defaults.get("annulus_inner", 0.3)),
395
+ id="gridscore-annulus-inner",
396
+ )
397
+ yield Label("annulus_outer:")
398
+ yield Input(
399
+ value=str(grid_defaults.get("annulus_outer", 0.7)),
400
+ id="gridscore-annulus-outer",
401
+ )
402
+ yield Label("bin_size:")
403
+ yield Input(
404
+ value=str(grid_defaults.get("bin_size", 2.5)),
405
+ id="gridscore-bin-size",
406
+ )
407
+ yield Label("smooth_sigma:")
408
+ yield Input(
409
+ value=str(grid_defaults.get("smooth_sigma", 2.0)),
410
+ id="gridscore-smooth-sigma",
411
+ )
412
+
413
+ with Vertical(id="file-tree-panel"):
414
+ yield Label("Files in Workdir", id="files-header")
415
+ yield DirectoryTree(self.state.workdir, id="file-tree")
416
+
417
+ # Right panel (results + log at bottom)
418
+ with Vertical(id="right-panel"):
419
+ with TabbedContent(id="results-tabs"):
420
+ with TabPane("Setup", id="setup-tab"):
421
+ yield Static(
422
+ "1. Select working directory (Ctrl-W)\n"
423
+ "2. Choose input mode and files\n"
424
+ "3. Configure preprocessing\n"
425
+ "4. Click 'Continue' to proceed to analysis",
426
+ id="setup-content",
427
+ )
428
+
429
+ with TabPane("Results", id="results-tab"):
430
+ yield ImagePreview(id="result-preview")
431
+ yield Static(
432
+ "No results yet. Complete preprocessing and run analysis.",
433
+ id="result-status",
434
+ )
435
+
436
+ # Log viewer at bottom (25% height)
437
+ yield LogViewer(id="log-viewer")
438
+
439
+ yield Footer()
440
+
441
+ def on_mount(self) -> None:
442
+ """Handle app mount event."""
443
+ self.update_workdir_label()
444
+ self.check_terminal_size()
445
+ self.apply_preset_params()
446
+ self.update_analysis_params_visibility()
447
+ self.update_decode_controls()
448
+ self.update_pathcompare_controls()
449
+ self.update_cohospace_controls()
450
+
451
+ def check_terminal_size(self) -> None:
452
+ """Check terminal size and show warning if too small."""
453
+ size = self.size
454
+ width = size.width
455
+ height = size.height
456
+
457
+ # Adjust layout based on terminal size
458
+ left_panel = self.query_one("#left-panel")
459
+
460
+ if width < self.RECOMMENDED_WIDTH:
461
+ if width < self.MIN_WIDTH:
462
+ # Very small terminal
463
+ left_panel.styles.width = 20
464
+ else:
465
+ # Small terminal
466
+ left_panel.styles.width = 22
467
+ else:
468
+ # Normal/large terminal
469
+ left_panel.styles.width = 22
470
+
471
+ # Show warning if terminal is too small (only once)
472
+ if not self._size_warning_shown and (width < self.MIN_WIDTH or height < self.MIN_HEIGHT):
473
+ self._size_warning_shown = True
474
+ self.push_screen(TerminalSizeWarning(width, height))
475
+
476
+ def on_resize(self, event) -> None:
477
+ """Handle terminal resize events."""
478
+ self.check_terminal_size()
479
+
480
+ def action_change_workdir(self) -> None:
481
+ """Change working directory."""
482
+ self.push_screen(WorkdirScreen(), self.on_workdir_selected)
483
+
484
+ def on_workdir_selected(self, path: Path | None) -> None:
485
+ """Handle workdir selection."""
486
+ if path:
487
+ self.state.workdir = path
488
+ self.runner.reset_input()
489
+ self.update_workdir_label()
490
+
491
+ # Update file tree in middle panel
492
+ tree = self.query_one("#file-tree", DirectoryTree)
493
+ tree.path = path
494
+ tree.reload()
495
+
496
+ def update_workdir_label(self) -> None:
497
+ """Update the workdir label."""
498
+ label = self.query_one("#workdir-label", Label)
499
+ label.update(f"Workdir: {self.state.workdir}")
500
+
501
+ def on_button_pressed(self, event: Button.Pressed) -> None:
502
+ """Handle button presses."""
503
+ if event.button.id == "change-workdir-btn":
504
+ self.action_change_workdir()
505
+ elif event.button.id == "continue-btn":
506
+ self.action_continue_to_analysis()
507
+ elif event.button.id == "back-btn":
508
+ self.action_back_to_preprocess()
509
+ elif event.button.id == "run-analysis-btn":
510
+ self.action_run_analysis()
511
+ elif event.button.id == "stop-btn":
512
+ self.action_stop()
513
+
514
+ def on_select_changed(self, event: Select.Changed) -> None:
515
+ """Handle select changes."""
516
+ if event.select.id == "input-mode-select":
517
+ self.state.input_mode = str(event.value)
518
+ self.runner.reset_input()
519
+ elif event.select.id == "preset-select":
520
+ self.state.preset = str(event.value)
521
+ self.apply_preset_params()
522
+ elif event.select.id == "preprocess-method-select":
523
+ self.state.preprocess_method = str(event.value)
524
+ # Enable/disable preprocessing parameters
525
+ is_embed = event.value == "embed_spike_trains"
526
+ self.query_one("#emb-dt", Input).disabled = not is_embed
527
+ self.query_one("#emb-sigma", Input).disabled = not is_embed
528
+ self.query_one("#emb-smooth", Checkbox).disabled = not is_embed
529
+ self.query_one("#emb-speed-filter", Checkbox).disabled = not is_embed
530
+ self.query_one("#emb-min-speed", Input).disabled = not is_embed
531
+ self.query_one("#emb-res", Input).disabled = not is_embed
532
+ elif event.select.id == "analysis-mode-select":
533
+ self.state.analysis_mode = str(event.value)
534
+ self.update_analysis_params_visibility()
535
+ elif event.select.id == "decode-version":
536
+ self.update_decode_controls()
537
+ elif event.select.id == "pc-dim-mode" or event.select.id == "pc-slice-mode":
538
+ self.update_pathcompare_controls()
539
+ elif event.select.id == "coho-dim-mode" or event.select.id == "coho-unfold":
540
+ self.update_cohospace_controls()
541
+
542
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
543
+ """Handle checkbox changes."""
544
+ if event.checkbox.id == "tda-do-shuffle":
545
+ self.query_one("#tda-num-shuffles", Input).disabled = not event.value
546
+ elif event.checkbox.id == "pc-use-box":
547
+ self.update_pathcompare_controls()
548
+
549
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
550
+ """Handle file selection from tree."""
551
+ selected_path = event.path
552
+
553
+ if self.state.input_mode == "asa" and selected_path.suffix == ".npz":
554
+ self.state.asa_file = relative_path(self.state, selected_path)
555
+ self.runner.reset_input()
556
+ self.log_message(f"Selected ASA file: {self.state.asa_file}")
557
+ return
558
+
559
+ if selected_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".bmp"}:
560
+ preview = self.query_one("#result-preview", ImagePreview)
561
+ preview.update_image(selected_path)
562
+ self.log_message(f"Previewing image: {selected_path}")
563
+
564
+ def action_continue_to_analysis(self) -> None:
565
+ """Continue from preprocessing to analysis page."""
566
+ if self.current_worker and not self.current_worker.is_finished:
567
+ self.log_message("Preprocessing already running. Please wait.")
568
+ return
569
+
570
+ # Validate files
571
+ is_valid, error = validate_files(self.state)
572
+ if not is_valid:
573
+ self.push_screen(ErrorScreen("Validation Error", error))
574
+ return
575
+
576
+ # Collect preprocessing parameters
577
+ if self.state.preprocess_method == "embed_spike_trains":
578
+ try:
579
+ res_val = int(self.query_one("#emb-res", Input).value)
580
+ dt_val = int(self.query_one("#emb-dt", Input).value)
581
+ sigma_val = int(self.query_one("#emb-sigma", Input).value)
582
+ smooth_val = self.query_one("#emb-smooth", Checkbox).value
583
+ speed_filter_val = self.query_one("#emb-speed-filter", Checkbox).value
584
+ min_speed_val = float(self.query_one("#emb-min-speed", Input).value)
585
+
586
+ self.state.preprocess_params = {
587
+ "res": res_val,
588
+ "dt": dt_val,
589
+ "sigma": sigma_val,
590
+ "smooth": smooth_val,
591
+ "speed_filter": speed_filter_val,
592
+ "min_speed": min_speed_val,
593
+ }
594
+ except ValueError as e:
595
+ self.push_screen(ErrorScreen("Parameter Error", f"Invalid parameter value: {e}"))
596
+ return
597
+
598
+ self.log_message("Loading and preprocessing data...")
599
+ self.set_run_status("Status: Preprocessing...", "running")
600
+ self.query_one("#continue-btn", Button).disabled = True
601
+
602
+ # Run preprocessing in worker
603
+ self.current_worker = self.run_worker(
604
+ self.runner.run_preprocessing(
605
+ self.state,
606
+ log_callback=self.log_message,
607
+ progress_callback=self.update_progress,
608
+ ),
609
+ name="preprocessing_worker",
610
+ thread=True,
611
+ )
612
+
613
+ def action_back_to_preprocess(self) -> None:
614
+ """Go back to preprocessing page."""
615
+ self.current_page = "preprocess"
616
+ self.query_one("#page-indicator", Label).update("Page: Preprocess")
617
+ self.query_one("#preprocess-controls").remove_class("hidden")
618
+ self.query_one("#analysis-controls").add_class("hidden")
619
+
620
+ # Show/hide appropriate buttons
621
+ self.query_one("#continue-btn").remove_class("hidden")
622
+ self.query_one("#back-btn").add_class("hidden")
623
+ self.query_one("#run-analysis-btn").add_class("hidden")
624
+ self.query_one("#stop-btn").add_class("hidden")
625
+ self.query_one("#stop-btn", Button).disabled = True
626
+
627
+ self.log_message("Returned to preprocessing page")
628
+
629
+ def action_run_analysis(self) -> None:
630
+ """Run analysis on preprocessed data."""
631
+ if self.current_worker and not self.current_worker.is_finished:
632
+ self.log_message("Another task is already running. Please wait.")
633
+ return
634
+
635
+ if not self.runner.has_preprocessed_data():
636
+ self.push_screen(
637
+ ErrorScreen("Error", "No preprocessed data. Please complete preprocessing first.")
638
+ )
639
+ return
640
+
641
+ try:
642
+ self.collect_analysis_params()
643
+ except ValueError as e:
644
+ self.push_screen(ErrorScreen("Parameter Error", f"Invalid analysis parameter: {e}"))
645
+ return
646
+
647
+ self.log_message(f"Starting {self.state.analysis_mode} analysis...")
648
+ self.set_run_status(f"Status: Running {self.state.analysis_mode}...", "running")
649
+ self.query_one("#run-analysis-btn", Button).disabled = True
650
+ self.query_one("#stop-btn", Button).disabled = False
651
+
652
+ # Run analysis in worker
653
+ self.current_worker = self.run_worker(
654
+ self.runner.run_analysis(
655
+ self.state,
656
+ log_callback=self.log_message,
657
+ progress_callback=self.update_progress,
658
+ ),
659
+ name="analysis_worker",
660
+ thread=True,
661
+ )
662
+
663
+ def action_run_action(self) -> None:
664
+ """Run current page action (Continue or Run Analysis)."""
665
+ if self.current_page == "preprocess":
666
+ self.action_continue_to_analysis()
667
+ else:
668
+ self.action_run_analysis()
669
+
670
+ def action_stop(self) -> None:
671
+ """Request cancellation of the running worker."""
672
+ if not self.current_worker or self.current_worker.is_finished:
673
+ self.log_message("No running task to stop.")
674
+ return
675
+ self.current_worker.cancel()
676
+ self.set_run_status("Status: Cancel requested.", "error")
677
+ self.log_message("Stop requested. Waiting for worker to exit...")
678
+ self.query_one("#stop-btn", Button).disabled = True
679
+
680
+ def log_message(self, message: str) -> None:
681
+ """Add log message."""
682
+ log_viewer = self.query_one("#log-viewer", LogViewer)
683
+ log_viewer.add_log(message)
684
+ self.append_log_file(message)
685
+
686
+ def _log_file_path(self) -> Path:
687
+ try:
688
+ return self.runner.results_dir(self.state) / "asa_tui.log"
689
+ except Exception:
690
+ return self.state.workdir / "Results" / "asa_tui.log"
691
+
692
+ def append_log_file(self, message: str) -> None:
693
+ """Append log message to file for easy copying."""
694
+ try:
695
+ path = self._log_file_path()
696
+ path.parent.mkdir(parents=True, exist_ok=True)
697
+ path.open("a", encoding="utf-8").write(f"{message}\n")
698
+ except Exception:
699
+ pass
700
+
701
+ def update_progress(self, percent: int) -> None:
702
+ """Update progress bar."""
703
+ progress = self.query_one("#progress-bar", ProgressBar)
704
+ progress.update(total=100, progress=percent)
705
+
706
+ def apply_preset_params(self) -> None:
707
+ """Apply preset defaults to analysis inputs."""
708
+ preset_params = get_preset_params(self.state.preset)
709
+ tda = preset_params.get("tda", {})
710
+ grid = preset_params.get("gridscore", {})
711
+
712
+ if tda:
713
+ self.query_one("#tda-dim", Input).value = str(tda.get("dim", 6))
714
+ self.query_one("#tda-num-times", Input).value = str(tda.get("num_times", 5))
715
+ self.query_one("#tda-active-times", Input).value = str(tda.get("active_times", 15000))
716
+ self.query_one("#tda-k", Input).value = str(tda.get("k", 1000))
717
+ self.query_one("#tda-n-points", Input).value = str(tda.get("n_points", 1200))
718
+ self.query_one("#tda-metric", Select).value = str(tda.get("metric", "cosine"))
719
+ self.query_one("#tda-nbs", Input).value = str(tda.get("nbs", 800))
720
+ self.query_one("#tda-maxdim", Input).value = str(tda.get("maxdim", 1))
721
+ self.query_one("#tda-coeff", Input).value = str(tda.get("coeff", 47))
722
+ self.query_one("#tda-do-shuffle", Checkbox).value = bool(tda.get("do_shuffle", False))
723
+ self.query_one("#tda-num-shuffles", Input).value = str(tda.get("num_shuffles", 1000))
724
+ self.query_one("#tda-num-shuffles", Input).disabled = not self.query_one(
725
+ "#tda-do-shuffle", Checkbox
726
+ ).value
727
+
728
+ if grid:
729
+ self.query_one("#gridscore-annulus-inner", Input).value = str(
730
+ grid.get("annulus_inner", 0.3)
731
+ )
732
+ self.query_one("#gridscore-annulus-outer", Input).value = str(
733
+ grid.get("annulus_outer", 0.7)
734
+ )
735
+ self.query_one("#gridscore-bin-size", Input).value = str(grid.get("bin_size", 2.5))
736
+ self.query_one("#gridscore-smooth-sigma", Input).value = str(
737
+ grid.get("smooth_sigma", 2.0)
738
+ )
739
+
740
+ def update_analysis_params_visibility(self) -> None:
741
+ """Show params for the selected analysis mode."""
742
+ mode = self.state.analysis_mode
743
+
744
+ groups = {
745
+ "analysis-params-tda": mode == "tda",
746
+ "analysis-params-decode": mode in {"cohomap", "pathcompare", "cohospace"},
747
+ "analysis-params-pathcompare": mode == "pathcompare",
748
+ "analysis-params-cohospace": mode == "cohospace",
749
+ "analysis-params-fr": mode == "fr",
750
+ "analysis-params-frm": mode == "frm",
751
+ "analysis-params-gridscore": mode == "gridscore",
752
+ }
753
+
754
+ for group_id, should_show in groups.items():
755
+ group = self.query_one(f"#{group_id}")
756
+ if should_show:
757
+ group.remove_class("hidden")
758
+ else:
759
+ group.add_class("hidden")
760
+
761
+ def update_decode_controls(self) -> None:
762
+ """Enable/disable decode controls based on decode version."""
763
+ version = str(self.query_one("#decode-version", Select).value)
764
+ is_v0 = version == "v0"
765
+ self.query_one("#decode-real-ground", Checkbox).disabled = not is_v0
766
+ self.query_one("#decode-real-of", Checkbox).disabled = not is_v0
767
+
768
+ def update_pathcompare_controls(self) -> None:
769
+ """Enable/disable PathCompare controls based on mode."""
770
+ dim_mode = str(self.query_one("#pc-dim-mode", Select).value)
771
+ is_1d = dim_mode == "1d"
772
+ self.query_one("#pc-dim", Input).disabled = not is_1d
773
+ self.query_one("#pc-dim1", Input).disabled = is_1d
774
+ self.query_one("#pc-dim2", Input).disabled = is_1d
775
+
776
+ slice_mode = str(self.query_one("#pc-slice-mode", Select).value)
777
+ is_time = slice_mode == "time"
778
+ self.query_one("#pc-tmin", Input).disabled = not is_time
779
+ self.query_one("#pc-tmax", Input).disabled = not is_time
780
+ self.query_one("#pc-imin", Input).disabled = is_time
781
+ self.query_one("#pc-imax", Input).disabled = is_time
782
+
783
+ use_box = self.query_one("#pc-use-box", Checkbox).value
784
+ self.query_one("#pc-interp-full", Checkbox).disabled = not use_box
785
+
786
+ def update_cohospace_controls(self) -> None:
787
+ """Enable/disable CohoSpace controls based on mode."""
788
+ dim_mode = str(self.query_one("#coho-dim-mode", Select).value)
789
+ is_1d = dim_mode == "1d"
790
+ self.query_one("#coho-dim", Input).disabled = not is_1d
791
+ self.query_one("#coho-dim1", Input).disabled = is_1d
792
+ self.query_one("#coho-dim2", Input).disabled = is_1d
793
+
794
+ unfold = str(self.query_one("#coho-unfold", Select).value)
795
+ is_skew = unfold == "skew"
796
+ self.query_one("#coho-skew-show-grid", Checkbox).disabled = not is_skew
797
+ self.query_one("#coho-skew-tiles", Input).disabled = not is_skew
798
+
799
+ def _parse_optional_number(self, raw: str, cast) -> int | float | None:
800
+ text = raw.strip()
801
+ if text == "" or text == "-1":
802
+ return None
803
+ return cast(text)
804
+
805
+ def _parse_optional_int(self, raw: str) -> int | None:
806
+ return self._parse_optional_number(raw, int)
807
+
808
+ def _parse_optional_float(self, raw: str) -> float | None:
809
+ return self._parse_optional_number(raw, float)
810
+
811
+ def collect_analysis_params(self) -> None:
812
+ """Collect analysis parameters from UI into state."""
813
+ params: dict[str, object] = {}
814
+ mode = self.state.analysis_mode
815
+
816
+ if mode == "tda":
817
+ params["dim"] = int(self.query_one("#tda-dim", Input).value)
818
+ params["num_times"] = int(self.query_one("#tda-num-times", Input).value)
819
+ params["active_times"] = int(self.query_one("#tda-active-times", Input).value)
820
+ params["k"] = int(self.query_one("#tda-k", Input).value)
821
+ params["n_points"] = int(self.query_one("#tda-n-points", Input).value)
822
+ params["metric"] = str(self.query_one("#tda-metric", Select).value)
823
+ params["nbs"] = int(self.query_one("#tda-nbs", Input).value)
824
+ params["maxdim"] = int(self.query_one("#tda-maxdim", Input).value)
825
+ params["coeff"] = int(self.query_one("#tda-coeff", Input).value)
826
+ params["do_shuffle"] = self.query_one("#tda-do-shuffle", Checkbox).value
827
+ params["num_shuffles"] = int(self.query_one("#tda-num-shuffles", Input).value)
828
+ params["standardize"] = self.query_one("#tda-standardize", Checkbox).value
829
+ elif mode == "cohomap":
830
+ params["decode_version"] = str(self.query_one("#decode-version", Select).value)
831
+ params["num_circ"] = int(self.query_one("#decode-num-circ", Input).value)
832
+ params["cohomap_subsample"] = int(self.query_one("#cohomap-subsample", Input).value)
833
+ params["real_ground"] = self.query_one("#decode-real-ground", Checkbox).value
834
+ params["real_of"] = self.query_one("#decode-real-of", Checkbox).value
835
+ elif mode == "pathcompare":
836
+ params["decode_version"] = str(self.query_one("#decode-version", Select).value)
837
+ params["num_circ"] = int(self.query_one("#decode-num-circ", Input).value)
838
+ params["real_ground"] = self.query_one("#decode-real-ground", Checkbox).value
839
+ params["real_of"] = self.query_one("#decode-real-of", Checkbox).value
840
+ params["use_box"] = self.query_one("#pc-use-box", Checkbox).value
841
+ params["interp_full"] = self.query_one("#pc-interp-full", Checkbox).value
842
+ params["dim_mode"] = str(self.query_one("#pc-dim-mode", Select).value)
843
+ params["dim"] = int(self.query_one("#pc-dim", Input).value)
844
+ params["dim1"] = int(self.query_one("#pc-dim1", Input).value)
845
+ params["dim2"] = int(self.query_one("#pc-dim2", Input).value)
846
+ params["coords_key"] = self.query_one("#pc-coords-key", Input).value.strip() or None
847
+ params["times_key"] = self.query_one("#pc-times-key", Input).value.strip() or None
848
+ params["slice_mode"] = str(self.query_one("#pc-slice-mode", Select).value)
849
+ params["tmin"] = self._parse_optional_float(self.query_one("#pc-tmin", Input).value)
850
+ params["tmax"] = self._parse_optional_float(self.query_one("#pc-tmax", Input).value)
851
+ params["imin"] = self._parse_optional_int(self.query_one("#pc-imin", Input).value)
852
+ params["imax"] = self._parse_optional_int(self.query_one("#pc-imax", Input).value)
853
+ params["stride"] = int(self.query_one("#pc-stride", Input).value)
854
+ params["angle_scale"] = (
855
+ self.query_one("#pathcompare-angle-scale", Input).value.strip() or "rad"
856
+ )
857
+ elif mode == "cohospace":
858
+ params["decode_version"] = str(self.query_one("#decode-version", Select).value)
859
+ params["num_circ"] = int(self.query_one("#decode-num-circ", Input).value)
860
+ params["real_ground"] = self.query_one("#decode-real-ground", Checkbox).value
861
+ params["real_of"] = self.query_one("#decode-real-of", Checkbox).value
862
+ params["dim_mode"] = str(self.query_one("#coho-dim-mode", Select).value)
863
+ params["dim"] = int(self.query_one("#coho-dim", Input).value)
864
+ params["dim1"] = int(self.query_one("#coho-dim1", Input).value)
865
+ params["dim2"] = int(self.query_one("#coho-dim2", Input).value)
866
+ params["mode"] = str(self.query_one("#coho-mode", Select).value)
867
+ params["top_percent"] = float(self.query_one("#coho-top-percent", Input).value)
868
+ params["view"] = str(self.query_one("#coho-view", Select).value)
869
+ neuron_id_raw = self.query_one("#cohospace-neuron-id", Input).value.strip()
870
+ if neuron_id_raw:
871
+ params["neuron_id"] = int(neuron_id_raw)
872
+ params["subsample"] = int(self.query_one("#coho-subsample", Input).value)
873
+ params["unfold"] = str(self.query_one("#coho-unfold", Select).value)
874
+ params["skew_show_grid"] = self.query_one("#coho-skew-show-grid", Checkbox).value
875
+ params["skew_tiles"] = int(self.query_one("#coho-skew-tiles", Input).value)
876
+ elif mode == "fr":
877
+ n_start = self._parse_optional_int(self.query_one("#fr-neuron-start", Input).value)
878
+ n_end = self._parse_optional_int(self.query_one("#fr-neuron-end", Input).value)
879
+ t_start = self._parse_optional_int(self.query_one("#fr-time-start", Input).value)
880
+ t_end = self._parse_optional_int(self.query_one("#fr-time-end", Input).value)
881
+ params["neuron_range"] = None if n_start is None and n_end is None else (n_start, n_end)
882
+ params["time_range"] = None if t_start is None and t_end is None else (t_start, t_end)
883
+ params["mode"] = str(self.query_one("#fr-mode", Select).value)
884
+ normalize = str(self.query_one("#fr-normalize", Select).value)
885
+ params["normalize"] = None if normalize == "none" else normalize
886
+ elif mode == "frm":
887
+ params["neuron_id"] = int(self.query_one("#frm-neuron-id", Input).value)
888
+ params["bin_size"] = int(self.query_one("#frm-bins", Input).value)
889
+ params["min_occupancy"] = int(self.query_one("#frm-min-occupancy", Input).value)
890
+ params["smoothing"] = self.query_one("#frm-smoothing", Checkbox).value
891
+ params["smooth_sigma"] = float(self.query_one("#frm-smooth-sigma", Input).value)
892
+ params["mode"] = str(self.query_one("#frm-mode", Select).value)
893
+ elif mode == "gridscore":
894
+ params["annulus_inner"] = float(self.query_one("#gridscore-annulus-inner", Input).value)
895
+ params["annulus_outer"] = float(self.query_one("#gridscore-annulus-outer", Input).value)
896
+ params["bin_size"] = float(self.query_one("#gridscore-bin-size", Input).value)
897
+ params["smooth_sigma"] = float(self.query_one("#gridscore-smooth-sigma", Input).value)
898
+
899
+ self.state.analysis_params = params
900
+
901
+ def set_run_status(self, message: str, status_class: str | None = None) -> None:
902
+ """Update run status label and styling."""
903
+ status = self.query_one("#run-status", Static)
904
+ status.update(message)
905
+ status.remove_class("running", "success", "error")
906
+ if status_class:
907
+ status.add_class(status_class)
908
+
909
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
910
+ """Handle worker state changes."""
911
+ if event.worker.name == "preprocessing_worker" and event.worker.is_finished:
912
+ result = event.worker.result
913
+ self.query_one("#continue-btn", Button).disabled = False
914
+ self.query_one("#stop-btn", Button).disabled = True
915
+
916
+ if result.success:
917
+ self.log_message(result.summary)
918
+ self.set_run_status("Status: Preprocessing complete.", "success")
919
+ # Switch to analysis page
920
+ self.current_page = "analysis"
921
+ self.query_one("#page-indicator", Label).update("Page: Analysis")
922
+ self.query_one("#preprocess-controls").add_class("hidden")
923
+ self.query_one("#analysis-controls").remove_class("hidden")
924
+
925
+ # Show/hide appropriate buttons
926
+ self.query_one("#continue-btn").add_class("hidden")
927
+ self.query_one("#back-btn").remove_class("hidden")
928
+ self.query_one("#run-analysis-btn").remove_class("hidden")
929
+ self.query_one("#stop-btn").remove_class("hidden")
930
+ self.query_one("#run-analysis-btn", Button).disabled = False
931
+
932
+ self.log_message("Preprocessing complete. Ready for analysis.")
933
+ tree = self.query_one("#file-tree", DirectoryTree)
934
+ tree.reload()
935
+ else:
936
+ self.set_run_status("Status: Preprocessing failed.", "error")
937
+ self.push_screen(
938
+ ErrorScreen("Preprocessing Error", result.error or "Unknown error")
939
+ )
940
+
941
+ elif event.worker.name == "analysis_worker" and event.worker.is_finished:
942
+ result = event.worker.result
943
+ self.query_one("#run-analysis-btn", Button).disabled = False
944
+ self.query_one("#stop-btn", Button).disabled = True
945
+
946
+ if result.success:
947
+ self.log_message(result.summary)
948
+ self.set_run_status("Status: Analysis complete.", "success")
949
+ self.log_message(f"Artifacts: {list(result.artifacts.keys())}")
950
+
951
+ # Update results tab + preview
952
+ tabs = self.query_one("#results-tabs", TabbedContent)
953
+ tabs.active = "results-tab"
954
+ preview_path = self._select_result_image(result.artifacts)
955
+ if preview_path is not None:
956
+ preview = self.query_one("#result-preview", ImagePreview)
957
+ preview.update_image(preview_path)
958
+
959
+ status = self.query_one("#result-status", Static)
960
+ status.update(f"Analysis completed: {result.summary}")
961
+ tree = self.query_one("#file-tree", DirectoryTree)
962
+ tree.reload()
963
+ else:
964
+ self.set_run_status("Status: Analysis failed.", "error")
965
+ self.push_screen(ErrorScreen("Analysis Error", result.error or "Unknown error"))
966
+
967
+ def action_refresh(self) -> None:
968
+ """Refresh the UI."""
969
+ self.update_workdir_label()
970
+ tree = self.query_one("#file-tree", DirectoryTree)
971
+ tree.reload()
972
+
973
+ def _select_result_image(self, artifacts: dict[str, Path]) -> Path | None:
974
+ """Pick a result image to preview, preferring known keys."""
975
+ priority_keys = [
976
+ "barcode",
977
+ "cohomap",
978
+ "path_compare",
979
+ "trajectory",
980
+ "cohospace_trajectory",
981
+ "fr_heatmap",
982
+ "frm",
983
+ "distribution",
984
+ ]
985
+ for key in priority_keys:
986
+ path = artifacts.get(key)
987
+ if path and path.exists() and path.suffix.lower() in {".png", ".jpg", ".jpeg"}:
988
+ return path
989
+ for path in artifacts.values():
990
+ if path and path.exists() and path.suffix.lower() in {".png", ".jpg", ".jpeg"}:
991
+ return path
992
+ return None
993
+
994
+ def action_help(self) -> None:
995
+ """Show help screen."""
996
+ self.push_screen(HelpScreen())
997
+
998
+ def action_quit(self) -> None:
999
+ """Quit the application."""
1000
+ self.exit()