TwoPy 0.1.5__tar.gz → 0.1.6__tar.gz

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 (139) hide show
  1. {twopy-0.1.5 → twopy-0.1.6}/PKG-INFO +1 -1
  2. {twopy-0.1.5 → twopy-0.1.6}/pyproject.toml +1 -1
  3. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/__init__.py +16 -0
  4. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/modeled.py +7 -0
  5. twopy-0.1.6/src/twopy/database/search.py +433 -0
  6. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/controls.py +224 -45
  7. twopy-0.1.6/src/twopy/napari/database_search.py +491 -0
  8. twopy-0.1.6/src/twopy/napari/errors.py +24 -0
  9. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/interactive.py +2 -2
  10. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/data.py +43 -0
  11. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/dynamic_options.py +7 -1
  12. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/response_plot_widget.py +87 -22
  13. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/widgets.py +23 -7
  14. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/roi.py +41 -0
  15. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/PKG-INFO +1 -1
  16. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/SOURCES.txt +3 -0
  17. {twopy-0.1.5 → twopy-0.1.6}/tests/test_database.py +204 -0
  18. {twopy-0.1.5 → twopy-0.1.6}/tests/test_napari.py +589 -0
  19. {twopy-0.1.5 → twopy-0.1.6}/README.md +0 -0
  20. {twopy-0.1.5 → twopy-0.1.6}/setup.cfg +0 -0
  21. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/__init__.py +0 -0
  22. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/_segments.py +0 -0
  23. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/_version.py +0 -0
  24. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/__init__.py +0 -0
  25. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/background_subtraction.py +0 -0
  26. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/dff.py +0 -0
  27. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/dff_options.py +0 -0
  28. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/epoch_mapping.py +0 -0
  29. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/motion.py +0 -0
  30. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/persistence.py +0 -0
  31. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/__init__.py +0 -0
  32. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/apply.py +0 -0
  33. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/options.py +0 -0
  34. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/persistence.py +0 -0
  35. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/qc.py +0 -0
  36. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_processing/signals.py +0 -0
  37. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/response_window_options.py +0 -0
  38. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/responses.py +0 -0
  39. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/trials.py +0 -0
  40. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/analysis/workflow.py +0 -0
  41. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/api.py +0 -0
  42. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/config.py +0 -0
  43. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/__init__.py +0 -0
  44. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/alignment_crop.py +0 -0
  45. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/api.py +0 -0
  46. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/hdf5_writing.py +0 -0
  47. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/matlab_values.py +0 -0
  48. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/source_loading.py +0 -0
  49. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/stimulus_code.py +0 -0
  50. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/conversion/types.py +0 -0
  51. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/converted.py +0 -0
  52. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/cache.py +0 -0
  53. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/catalog.py +0 -0
  54. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/connection.py +0 -0
  55. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/generic.py +0 -0
  56. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/sql.py +0 -0
  57. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/database/types.py +0 -0
  58. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/frame_ranges.py +0 -0
  59. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/inspection.py +0 -0
  60. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/matlab.py +0 -0
  61. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/__init__.py +0 -0
  62. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/__main__.py +0 -0
  63. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/constants.py +0 -0
  64. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/display.py +0 -0
  65. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/display_paths.py +0 -0
  66. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/launcher.py +0 -0
  67. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/loading.py +0 -0
  68. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/movie.py +0 -0
  69. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/paths.py +0 -0
  70. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/__init__.py +0 -0
  71. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/dff_options.py +0 -0
  72. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/__init__.py +0 -0
  73. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/api.py +0 -0
  74. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/options_panel.py +0 -0
  75. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/paths.py +0 -0
  76. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/plot_area.py +0 -0
  77. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/save_actions.py +0 -0
  78. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/docks/visibility_state.py +0 -0
  79. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/export.py +0 -0
  80. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/export_controls.py +0 -0
  81. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/form_controls.py +0 -0
  82. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/label_visibility.py +0 -0
  83. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/options.py +0 -0
  84. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/panels.py +0 -0
  85. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/processing_options.py +0 -0
  86. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/plotting/response_window_options.py +0 -0
  87. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/protocols.py +0 -0
  88. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/responses.py +0 -0
  89. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/session.py +0 -0
  90. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/sidebar.py +0 -0
  91. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/state.py +0 -0
  92. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/text.py +0 -0
  93. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/types.py +0 -0
  94. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/napari/viewer.py +0 -0
  95. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/parity/__init__.py +0 -0
  96. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/parity/dff.py +0 -0
  97. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/parity/psycho5.py +0 -0
  98. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/parity/roi.py +0 -0
  99. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/parity/saved_analysis.py +0 -0
  100. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/photodiode.py +0 -0
  101. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/photodiode_classification.py +0 -0
  102. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/roi.py +0 -0
  103. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/session.py +0 -0
  104. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/spatial.py +0 -0
  105. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/stimulus.py +0 -0
  106. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/synchronization.py +0 -0
  107. {twopy-0.1.5 → twopy-0.1.6}/src/twopy/typing_guards.py +0 -0
  108. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/dependency_links.txt +0 -0
  109. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/entry_points.txt +0 -0
  110. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/requires.txt +0 -0
  111. {twopy-0.1.5 → twopy-0.1.6}/src/twopy.egg-info/top_level.txt +0 -0
  112. {twopy-0.1.5 → twopy-0.1.6}/tests/test_api.py +0 -0
  113. {twopy-0.1.5 → twopy-0.1.6}/tests/test_background_subtraction.py +0 -0
  114. {twopy-0.1.5 → twopy-0.1.6}/tests/test_config.py +0 -0
  115. {twopy-0.1.5 → twopy-0.1.6}/tests/test_conversion.py +0 -0
  116. {twopy-0.1.5 → twopy-0.1.6}/tests/test_converted.py +0 -0
  117. {twopy-0.1.5 → twopy-0.1.6}/tests/test_dff.py +0 -0
  118. {twopy-0.1.5 → twopy-0.1.6}/tests/test_epoch_mapping.py +0 -0
  119. {twopy-0.1.5 → twopy-0.1.6}/tests/test_frame_ranges.py +0 -0
  120. {twopy-0.1.5 → twopy-0.1.6}/tests/test_import.py +0 -0
  121. {twopy-0.1.5 → twopy-0.1.6}/tests/test_inspection.py +0 -0
  122. {twopy-0.1.5 → twopy-0.1.6}/tests/test_matlab.py +0 -0
  123. {twopy-0.1.5 → twopy-0.1.6}/tests/test_motion.py +0 -0
  124. {twopy-0.1.5 → twopy-0.1.6}/tests/test_packaging.py +0 -0
  125. {twopy-0.1.5 → twopy-0.1.6}/tests/test_parity.py +0 -0
  126. {twopy-0.1.5 → twopy-0.1.6}/tests/test_parity_psycho5.py +0 -0
  127. {twopy-0.1.5 → twopy-0.1.6}/tests/test_persistence.py +0 -0
  128. {twopy-0.1.5 → twopy-0.1.6}/tests/test_photodiode_classification.py +0 -0
  129. {twopy-0.1.5 → twopy-0.1.6}/tests/test_real_fixture.py +0 -0
  130. {twopy-0.1.5 → twopy-0.1.6}/tests/test_response_processing.py +0 -0
  131. {twopy-0.1.5 → twopy-0.1.6}/tests/test_response_window_options.py +0 -0
  132. {twopy-0.1.5 → twopy-0.1.6}/tests/test_responses.py +0 -0
  133. {twopy-0.1.5 → twopy-0.1.6}/tests/test_roi.py +0 -0
  134. {twopy-0.1.5 → twopy-0.1.6}/tests/test_saved_analysis.py +0 -0
  135. {twopy-0.1.5 → twopy-0.1.6}/tests/test_session.py +0 -0
  136. {twopy-0.1.5 → twopy-0.1.6}/tests/test_stimulus.py +0 -0
  137. {twopy-0.1.5 → twopy-0.1.6}/tests/test_synchronization.py +0 -0
  138. {twopy-0.1.5 → twopy-0.1.6}/tests/test_trials.py +0 -0
  139. {twopy-0.1.5 → twopy-0.1.6}/tests/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twopy
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Two-photon imaging analysis tool with a napari interface.
5
5
  Author-email: Gustavo Madeira Santana <fromweb1@gumadeiras.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "twopy"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Two-photon imaging analysis tool with a napari interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -10,6 +10,15 @@ focused modules.
10
10
  from twopy.database.catalog import read_database_catalog
11
11
  from twopy.database.generic import find_experiments
12
12
  from twopy.database.modeled import find_recordings, find_stimulus_presentations
13
+ from twopy.database.search import (
14
+ ExperimentHierarchyField,
15
+ ExperimentSearchFilters,
16
+ ExperimentSearchNode,
17
+ build_experiment_search_tree,
18
+ find_recording_search_results,
19
+ recording_path_for_database_experiment,
20
+ recording_paths_for_database_experiments,
21
+ )
13
22
  from twopy.database.types import (
14
23
  DatabaseAccess,
15
24
  DatabaseCatalog,
@@ -26,8 +35,15 @@ __all__ = [
26
35
  "DatabaseExperiment",
27
36
  "DatabaseRecord",
28
37
  "DatabaseTable",
38
+ "ExperimentHierarchyField",
39
+ "ExperimentSearchFilters",
40
+ "ExperimentSearchNode",
41
+ "build_experiment_search_tree",
29
42
  "find_experiments",
30
43
  "find_recordings",
44
+ "find_recording_search_results",
31
45
  "find_stimulus_presentations",
32
46
  "read_database_catalog",
47
+ "recording_path_for_database_experiment",
48
+ "recording_paths_for_database_experiments",
33
49
  ]
@@ -25,6 +25,7 @@ def find_stimulus_presentations(
25
25
  date_year: int | None = None,
26
26
  date_month: int | None = None,
27
27
  date_day: int | None = None,
28
+ date_contains: str | None = None,
28
29
  path_contains: str | None = None,
29
30
  stimulus_contains: str | None = None,
30
31
  genotype_contains: str | None = None,
@@ -43,6 +44,8 @@ def find_stimulus_presentations(
43
44
  date_year: Optional four-digit year matched against experiment date.
44
45
  date_month: Optional month number matched against experiment date.
45
46
  date_day: Optional day-of-month matched against experiment date.
47
+ date_contains: Optional substring matched against full experiment date
48
+ text.
46
49
  path_contains: Optional substring matched against ``relativeDataPath``.
47
50
  stimulus_contains: Optional substring matched against ``stimulusFunction``.
48
51
  genotype_contains: Optional substring matched against fly ``genotype``.
@@ -87,6 +90,7 @@ def find_stimulus_presentations(
87
90
  date_year=date_year,
88
91
  date_month=date_month,
89
92
  date_day=date_day,
93
+ date_contains=date_contains,
90
94
  path_contains=path_contains,
91
95
  stimulus_contains=stimulus_contains,
92
96
  genotype_contains=genotype_contains,
@@ -195,6 +199,7 @@ def _query_stimulus_presentations(
195
199
  date_year: int | None,
196
200
  date_month: int | None,
197
201
  date_day: int | None,
202
+ date_contains: str | None,
198
203
  path_contains: str | None,
199
204
  stimulus_contains: str | None,
200
205
  genotype_contains: str | None,
@@ -211,6 +216,7 @@ def _query_stimulus_presentations(
211
216
  date_year: Optional experiment year.
212
217
  date_month: Optional experiment month.
213
218
  date_day: Optional experiment day-of-month.
219
+ date_contains: Optional substring matched against experiment date.
214
220
  path_contains: Optional relative path substring.
215
221
  stimulus_contains: Optional stimulus-function substring.
216
222
  genotype_contains: Optional genotype substring.
@@ -241,6 +247,7 @@ def _query_stimulus_presentations(
241
247
  parameters=parameters,
242
248
  filters={
243
249
  'CAST(sp."relativeDataPath" AS TEXT) LIKE ?': path_contains,
250
+ 'CAST(sp."date" AS TEXT) LIKE ?': date_contains,
244
251
  'CAST(sp."stimulusFunction" AS TEXT) LIKE ?': stimulus_contains,
245
252
  'CAST(f."genotype" AS TEXT) LIKE ?': genotype_contains,
246
253
  'CAST(f."fluorescentProtein" AS TEXT) LIKE ?': sensor_contains,
@@ -0,0 +1,433 @@
1
+ """Database search helpers for browsing recording experiments.
2
+
3
+ Inputs: configured lab database paths, user-facing text filters, and typed
4
+ ``DatabaseExperiment`` rows.
5
+ Outputs: filtered experiment rows, browsable hierarchy nodes, and source
6
+ recording paths.
7
+
8
+ The napari search dialog uses this module so database querying, grouping, and
9
+ path resolution stay testable without Qt imports.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from pathlib import Path, PurePosixPath
17
+ from typing import TYPE_CHECKING, Literal
18
+
19
+ from twopy.database.modeled import find_stimulus_presentations
20
+ from twopy.database.types import DatabaseExperiment
21
+
22
+ if TYPE_CHECKING:
23
+ from twopy.config import TwopyConfig
24
+
25
+ __all__ = [
26
+ "ExperimentHierarchyField",
27
+ "ExperimentSearchFilters",
28
+ "ExperimentSearchNode",
29
+ "build_experiment_search_tree",
30
+ "date_label_for_experiment",
31
+ "find_recording_search_results",
32
+ "recording_path_for_database_experiment",
33
+ "recording_paths_for_database_experiments",
34
+ "time_label_for_experiment",
35
+ ]
36
+
37
+ ExperimentHierarchyField = Literal[
38
+ "user",
39
+ "cell_type",
40
+ "sensor",
41
+ "stimulus",
42
+ "date",
43
+ "time",
44
+ ]
45
+
46
+ UNKNOWN_LABEL = "(unknown)"
47
+ DEFAULT_EXPERIMENT_SEARCH_LIMIT = 50_000
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ExperimentSearchFilters:
52
+ """User-facing filters for recording database search.
53
+
54
+ Inputs: optional text from the database-search dialog.
55
+ Outputs: immutable normalized filter values passed to database search.
56
+
57
+ Blank strings mean no filtering, matching the GUI contract.
58
+ """
59
+
60
+ user: str | None = None
61
+ cell_type: str | None = None
62
+ sensor: str | None = None
63
+ stimulus: str | None = None
64
+ date: str | None = None
65
+ limit: int = DEFAULT_EXPERIMENT_SEARCH_LIMIT
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ExperimentSearchNode:
70
+ """One node in the recording search hierarchy.
71
+
72
+ Inputs: a hierarchy label, the field it represents, child nodes, and the
73
+ experiments below it.
74
+ Outputs: an immutable tree the Qt dialog can render directly.
75
+
76
+ Every non-root node carries all descendant experiments so selecting a user,
77
+ cell type, stimulus, date, or time can load the matching recordings.
78
+ """
79
+
80
+ label: str
81
+ field: ExperimentHierarchyField | None
82
+ experiments: tuple[DatabaseExperiment, ...]
83
+ children: tuple[ExperimentSearchNode, ...] = ()
84
+
85
+
86
+ def find_recording_search_results(
87
+ config: TwopyConfig,
88
+ filters: ExperimentSearchFilters,
89
+ *,
90
+ cache_dir: Path | None = None,
91
+ ) -> tuple[DatabaseExperiment, ...]:
92
+ """Find recording experiments for the database-search dialog.
93
+
94
+ Args:
95
+ config: Loaded twopy configuration.
96
+ filters: User-facing text filters. Blank values are ignored.
97
+ cache_dir: Optional local database-copy cache.
98
+
99
+ Returns:
100
+ Typed database experiments matching all active filters.
101
+
102
+ The SQL query stays in the database package. This helper maps GUI labels
103
+ such as ``user`` and ``sensor`` onto the stable modeled query fields.
104
+ """
105
+ experiments = find_stimulus_presentations(
106
+ config.database_path,
107
+ date_contains=_clean_filter(filters.date),
108
+ stimulus_contains=_clean_filter(filters.stimulus),
109
+ sensor_contains=_clean_filter(filters.sensor),
110
+ cell_type_contains=_clean_filter(filters.cell_type),
111
+ person_contains=_clean_filter(filters.user),
112
+ limit=filters.limit,
113
+ database_access=config.database_access,
114
+ cache_dir=cache_dir,
115
+ )
116
+ return _unique_experiments_by_relative_path(experiments)
117
+
118
+
119
+ def build_experiment_search_tree(
120
+ experiments: tuple[DatabaseExperiment, ...],
121
+ ) -> ExperimentSearchNode:
122
+ """Build the folder-like hierarchy for database search results.
123
+
124
+ Args:
125
+ experiments: Database rows to group.
126
+
127
+ Returns:
128
+ Root node with descendants grouped by user, cell type, sensor, stimulus,
129
+ date, then experiment time.
130
+
131
+ Nodes are sorted by display label so repeated searches render in a stable
132
+ order. Unknown values are grouped explicitly instead of disappearing.
133
+ """
134
+ return ExperimentSearchNode(
135
+ label="Results",
136
+ field=None,
137
+ experiments=experiments,
138
+ children=_group_nodes(
139
+ experiments,
140
+ field="user",
141
+ label_for_experiment=lambda experiment: _optional_label(experiment.person),
142
+ next_builder=_cell_type_nodes,
143
+ ),
144
+ )
145
+
146
+
147
+ def recording_path_for_database_experiment(
148
+ config: TwopyConfig,
149
+ experiment: DatabaseExperiment,
150
+ ) -> Path:
151
+ """Resolve one database experiment into a source recording folder.
152
+
153
+ Args:
154
+ config: Loaded twopy configuration with ``data_path``.
155
+ experiment: Database row with a relative data path.
156
+
157
+ Returns:
158
+ Source recording folder under the configured data root.
159
+
160
+ Raises:
161
+ ValueError: If the database row does not contain a safe relative path.
162
+
163
+ The database stores Windows-style separators in observed rows. Twopy treats
164
+ the value as relative path text and resolves it beneath ``data_path`` before
165
+ passing it to the existing recording loader.
166
+ """
167
+ relative_path = _normalized_relative_path(experiment.relative_data_path)
168
+ if relative_path == "":
169
+ msg = (
170
+ "Database experiment has an empty relativeDataPath: "
171
+ f"{experiment.stimulus_presentation_id}"
172
+ )
173
+ raise ValueError(msg)
174
+
175
+ path_parts = PurePosixPath(relative_path).parts
176
+ if any(part in {"", ".", ".."} for part in path_parts):
177
+ msg = (
178
+ "Database experiment relativeDataPath must stay under data_path: "
179
+ f"{experiment.relative_data_path!r}"
180
+ )
181
+ raise ValueError(msg)
182
+
183
+ return config.data_path.expanduser().joinpath(*path_parts)
184
+
185
+
186
+ def recording_paths_for_database_experiments(
187
+ config: TwopyConfig,
188
+ experiments: tuple[DatabaseExperiment, ...],
189
+ ) -> tuple[Path, ...]:
190
+ """Resolve database experiments into unique source recording folders.
191
+
192
+ Args:
193
+ config: Loaded twopy configuration with ``data_path``.
194
+ experiments: Database rows to load.
195
+
196
+ Returns:
197
+ Unique source recording folders in search-result order.
198
+
199
+ Parent hierarchy nodes can contain multiple database rows. Deduplicating
200
+ here prevents the GUI from loading the same folder twice when duplicate
201
+ rows point at one recording.
202
+ """
203
+ paths: list[Path] = []
204
+ seen: set[Path] = set()
205
+ for experiment in experiments:
206
+ path = recording_path_for_database_experiment(config, experiment)
207
+ if path not in seen:
208
+ paths.append(path)
209
+ seen.add(path)
210
+ return tuple(paths)
211
+
212
+
213
+ def date_label_for_experiment(experiment: DatabaseExperiment) -> str:
214
+ """Return the date folder label for one experiment.
215
+
216
+ Args:
217
+ experiment: Database experiment row.
218
+
219
+ Returns:
220
+ ``YYYY-MM-DD`` when available, otherwise ``(unknown)``.
221
+ """
222
+ date_parts = _date_time_parts_from_relative_path(experiment.relative_data_path)
223
+ if date_parts is not None:
224
+ year, month_day, _time = date_parts
225
+ return f"{year}-{month_day.replace('_', '-')}"
226
+
227
+ if experiment.date is not None and len(experiment.date) >= 10:
228
+ return experiment.date[:10]
229
+ return UNKNOWN_LABEL
230
+
231
+
232
+ def time_label_for_experiment(experiment: DatabaseExperiment) -> str:
233
+ """Return the experiment-time label beneath one date node.
234
+
235
+ Args:
236
+ experiment: Database experiment row.
237
+
238
+ Returns:
239
+ ``HH:MM:SS`` when available, otherwise a stable experiment id label.
240
+ """
241
+ date_parts = _date_time_parts_from_relative_path(experiment.relative_data_path)
242
+ if date_parts is not None:
243
+ _year, _month_day, time = date_parts
244
+ return time.replace("_", ":")
245
+
246
+ if experiment.date is not None and len(experiment.date) >= 19:
247
+ return experiment.date[11:19]
248
+ return f"id {experiment.stimulus_presentation_id}"
249
+
250
+
251
+ def _unique_experiments_by_relative_path(
252
+ experiments: tuple[DatabaseExperiment, ...],
253
+ ) -> tuple[DatabaseExperiment, ...]:
254
+ """Return the first experiment row for each source recording path.
255
+
256
+ Args:
257
+ experiments: Database rows from all configured SQLite files.
258
+
259
+ Returns:
260
+ Deduplicated rows in original query order.
261
+
262
+ The lab keeps the same stimulus rows in ``experimentLog.db`` and
263
+ ``experimentInitLog.db``. The GUI browses source recordings, so duplicate DB
264
+ rows for one ``relativeDataPath`` should appear as one experiment.
265
+ """
266
+ unique: list[DatabaseExperiment] = []
267
+ seen: set[str] = set()
268
+ for experiment in experiments:
269
+ key = _normalized_relative_path(experiment.relative_data_path)
270
+ if key in seen:
271
+ continue
272
+ unique.append(experiment)
273
+ seen.add(key)
274
+ return tuple(unique)
275
+
276
+
277
+ def _date_time_parts_from_relative_path(
278
+ relative_data_path: str,
279
+ ) -> tuple[str, str, str] | None:
280
+ """Return ``YYYY``, ``MM_DD``, and ``HH_MM_SS`` from a source path."""
281
+ parts = PurePosixPath(_normalized_relative_path(relative_data_path)).parts
282
+ for index in range(len(parts) - 2):
283
+ year = parts[index]
284
+ month_day = parts[index + 1]
285
+ time = parts[index + 2]
286
+ if (
287
+ len(year) == 4
288
+ and year.isdigit()
289
+ and _is_underscore_date(month_day)
290
+ and _is_underscore_time(time)
291
+ ):
292
+ return year, month_day, time
293
+ return None
294
+
295
+
296
+ def _cell_type_nodes(
297
+ experiments: tuple[DatabaseExperiment, ...],
298
+ ) -> tuple[ExperimentSearchNode, ...]:
299
+ """Group user children by cell type."""
300
+ return _group_nodes(
301
+ experiments,
302
+ field="cell_type",
303
+ label_for_experiment=lambda experiment: _optional_label(experiment.cell_type),
304
+ next_builder=_sensor_nodes,
305
+ )
306
+
307
+
308
+ def _sensor_nodes(
309
+ experiments: tuple[DatabaseExperiment, ...],
310
+ ) -> tuple[ExperimentSearchNode, ...]:
311
+ """Group cell-type children by sensor."""
312
+ return _group_nodes(
313
+ experiments,
314
+ field="sensor",
315
+ label_for_experiment=lambda experiment: _optional_label(
316
+ experiment.fluorescent_protein
317
+ ),
318
+ next_builder=_stimulus_nodes,
319
+ )
320
+
321
+
322
+ def _stimulus_nodes(
323
+ experiments: tuple[DatabaseExperiment, ...],
324
+ ) -> tuple[ExperimentSearchNode, ...]:
325
+ """Group sensor children by stimulus function."""
326
+ return _group_nodes(
327
+ experiments,
328
+ field="stimulus",
329
+ label_for_experiment=lambda experiment: _optional_label(
330
+ experiment.stimulus_function
331
+ ),
332
+ next_builder=_date_nodes,
333
+ )
334
+
335
+
336
+ def _date_nodes(
337
+ experiments: tuple[DatabaseExperiment, ...],
338
+ ) -> tuple[ExperimentSearchNode, ...]:
339
+ """Group stimulus children by date."""
340
+ return _group_nodes(
341
+ experiments,
342
+ field="date",
343
+ label_for_experiment=date_label_for_experiment,
344
+ next_builder=_time_nodes,
345
+ )
346
+
347
+
348
+ def _time_nodes(
349
+ experiments: tuple[DatabaseExperiment, ...],
350
+ ) -> tuple[ExperimentSearchNode, ...]:
351
+ """Group date children by experiment time."""
352
+ return _group_nodes(
353
+ experiments,
354
+ field="time",
355
+ label_for_experiment=time_label_for_experiment,
356
+ next_builder=lambda grouped: (),
357
+ )
358
+
359
+
360
+ def _group_nodes(
361
+ experiments: tuple[DatabaseExperiment, ...],
362
+ *,
363
+ field: ExperimentHierarchyField,
364
+ label_for_experiment: Callable[[DatabaseExperiment], str],
365
+ next_builder: Callable[
366
+ [tuple[DatabaseExperiment, ...]],
367
+ tuple[ExperimentSearchNode, ...],
368
+ ],
369
+ ) -> tuple[ExperimentSearchNode, ...]:
370
+ """Group experiments for one hierarchy level."""
371
+ grouped: dict[str, list[DatabaseExperiment]] = {}
372
+ for experiment in experiments:
373
+ grouped.setdefault(label_for_experiment(experiment), []).append(experiment)
374
+
375
+ nodes: list[ExperimentSearchNode] = []
376
+ for label in sorted(grouped, key=_sort_label):
377
+ node_experiments = tuple(grouped[label])
378
+ nodes.append(
379
+ ExperimentSearchNode(
380
+ label=label,
381
+ field=field,
382
+ experiments=node_experiments,
383
+ children=next_builder(node_experiments),
384
+ )
385
+ )
386
+ return tuple(nodes)
387
+
388
+
389
+ def _optional_label(value: str | None) -> str:
390
+ """Return a display label for a nullable database value."""
391
+ if value is None or value == "":
392
+ return UNKNOWN_LABEL
393
+ return value
394
+
395
+
396
+ def _normalized_relative_path(relative_data_path: str) -> str:
397
+ """Normalize a database source path for grouping and deduplication."""
398
+ return relative_data_path.replace("\\", "/").strip("/")
399
+
400
+
401
+ def _is_underscore_date(value: str) -> bool:
402
+ """Return whether text looks like a source-folder ``MM_DD`` date."""
403
+ return (
404
+ len(value) == 5
405
+ and value[2] == "_"
406
+ and value[:2].isdigit()
407
+ and value[3:].isdigit()
408
+ )
409
+
410
+
411
+ def _is_underscore_time(value: str) -> bool:
412
+ """Return whether text looks like a source-folder ``HH_MM_SS`` time."""
413
+ return (
414
+ len(value) == 8
415
+ and value[2] == "_"
416
+ and value[5] == "_"
417
+ and value[:2].isdigit()
418
+ and value[3:5].isdigit()
419
+ and value[6:].isdigit()
420
+ )
421
+
422
+
423
+ def _clean_filter(value: str | None) -> str | None:
424
+ """Return a non-empty stripped filter string or ``None``."""
425
+ if value is None:
426
+ return None
427
+ cleaned = value.strip()
428
+ return cleaned or None
429
+
430
+
431
+ def _sort_label(label: str) -> tuple[bool, str]:
432
+ """Sort unknown values last and all other labels case-insensitively."""
433
+ return (label == UNKNOWN_LABEL, label.casefold())