TwoPy 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.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,166 @@
1
+ """Local cache support for Clark lab SQLite database files.
2
+
3
+ Inputs: mounted SQLite DB paths and a local cache folder.
4
+ Outputs: local DB copies plus JSON manifests that record source metadata.
5
+
6
+ Copying exists for performance. Database queries over network-mounted volumes
7
+ can be slow, but transferring the database file locally is usually fast.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ from twopy.database.types import DatabaseAccess
17
+
18
+ __all__ = [
19
+ "DEFAULT_DATABASE_CACHE_DIR",
20
+ "ensure_local_database_copy",
21
+ "validate_database_access",
22
+ ]
23
+
24
+ DEFAULT_DATABASE_CACHE_DIR = Path.home() / ".cache" / "twopy" / "database"
25
+
26
+
27
+ def validate_database_access(database_access: DatabaseAccess) -> None:
28
+ """Validate the database access mode.
29
+
30
+ Args:
31
+ database_access: Requested DB access mode.
32
+
33
+ Returns:
34
+ None when the mode is supported.
35
+
36
+ Raises:
37
+ ValueError: If the mode is not ``direct`` or ``copy``.
38
+ """
39
+ if database_access not in {"direct", "copy"}:
40
+ msg = f"database_access must be 'direct' or 'copy'; got {database_access!r}"
41
+ raise ValueError(msg)
42
+
43
+
44
+ def ensure_local_database_copy(source_path: Path, cache_dir: Path) -> Path:
45
+ """Return a local cached copy of a SQLite database.
46
+
47
+ Args:
48
+ source_path: Source SQLite database path.
49
+ cache_dir: Directory where local copies and manifests are stored.
50
+
51
+ Returns:
52
+ Local cached database path.
53
+
54
+ The manifest stores source path, size, mtime, and SHA-256. If size and mtime
55
+ match, twopy reuses the local copy. If metadata changed, twopy hashes source
56
+ and cached files and copies only when content changed. This keeps repeated
57
+ queries fast without repeatedly transferring unchanged DB files.
58
+ """
59
+ cache_dir.mkdir(parents=True, exist_ok=True)
60
+ cache_path = cache_dir / source_path.name
61
+ manifest_path = cache_path.with_suffix(cache_path.suffix + ".manifest.json")
62
+ source_stat = source_path.stat()
63
+ source_metadata: dict[str, object] = {
64
+ "source_path": str(source_path),
65
+ "source_size": source_stat.st_size,
66
+ "source_mtime_ns": source_stat.st_mtime_ns,
67
+ }
68
+
69
+ manifest = _read_copy_manifest(manifest_path)
70
+ if cache_path.is_file() and _manifest_metadata_matches(manifest, source_metadata):
71
+ return cache_path
72
+
73
+ source_hash = _file_sha256(source_path)
74
+ cached_hash = _file_sha256(cache_path) if cache_path.is_file() else None
75
+ if source_hash != cached_hash:
76
+ temp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
77
+ shutil.copy2(source_path, temp_path)
78
+ temp_path.replace(cache_path)
79
+
80
+ _write_copy_manifest(
81
+ manifest_path,
82
+ {
83
+ **source_metadata,
84
+ "source_sha256": source_hash,
85
+ },
86
+ )
87
+ return cache_path
88
+
89
+
90
+ def _read_copy_manifest(manifest_path: Path) -> dict[str, object] | None:
91
+ """Read a DB copy manifest.
92
+
93
+ Args:
94
+ manifest_path: Manifest JSON path.
95
+
96
+ Returns:
97
+ Parsed manifest dictionary or ``None`` when absent or invalid.
98
+
99
+ Invalid manifests are treated as cache misses because the source DB remains
100
+ the source of truth.
101
+ """
102
+ if not manifest_path.is_file():
103
+ return None
104
+
105
+ try:
106
+ loaded = json.loads(manifest_path.read_text(encoding="utf-8"))
107
+ except (OSError, json.JSONDecodeError):
108
+ return None
109
+
110
+ if not isinstance(loaded, dict):
111
+ return None
112
+
113
+ return cast(dict[str, object], loaded)
114
+
115
+
116
+ def _manifest_metadata_matches(
117
+ manifest: dict[str, object] | None,
118
+ source_metadata: dict[str, object],
119
+ ) -> bool:
120
+ """Check whether source file metadata matches a manifest.
121
+
122
+ Args:
123
+ manifest: Parsed manifest or ``None``.
124
+ source_metadata: Current source path, size, and mtime metadata.
125
+
126
+ Returns:
127
+ ``True`` when the manifest points at the same unchanged source file.
128
+ """
129
+ if manifest is None:
130
+ return False
131
+
132
+ return all(manifest.get(key) == value for key, value in source_metadata.items())
133
+
134
+
135
+ def _write_copy_manifest(manifest_path: Path, manifest: dict[str, object]) -> None:
136
+ """Write a DB copy manifest as stable JSON.
137
+
138
+ Args:
139
+ manifest_path: Manifest JSON path.
140
+ manifest: Manifest fields to write.
141
+
142
+ Returns:
143
+ None.
144
+ """
145
+ manifest_path.write_text(
146
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
147
+ encoding="utf-8",
148
+ )
149
+
150
+
151
+ def _file_sha256(path: Path) -> str:
152
+ """Compute SHA-256 for one file.
153
+
154
+ Args:
155
+ path: File path to hash.
156
+
157
+ Returns:
158
+ Hex SHA-256 digest.
159
+
160
+ The function streams chunks so database files do not need to fit in memory.
161
+ """
162
+ digest = hashlib.sha256()
163
+ with path.open("rb") as file:
164
+ while chunk := file.read(1024 * 1024):
165
+ digest.update(chunk)
166
+ return digest.hexdigest()
@@ -0,0 +1,90 @@
1
+ """Find and prepare Clark lab SQLite database files.
2
+
3
+ Inputs: a lab database folder and an access mode.
4
+ Outputs: a catalog of SQLite file paths, optionally copied to a local cache.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from twopy.database.cache import (
10
+ DEFAULT_DATABASE_CACHE_DIR,
11
+ ensure_local_database_copy,
12
+ validate_database_access,
13
+ )
14
+ from twopy.database.types import (
15
+ DEFAULT_DATABASE_FILES,
16
+ DatabaseAccess,
17
+ DatabaseCatalog,
18
+ )
19
+
20
+ __all__ = ["read_database_catalog"]
21
+
22
+
23
+ def read_database_catalog(
24
+ database_dir: Path,
25
+ *,
26
+ database_access: DatabaseAccess = "direct",
27
+ cache_dir: Path | None = None,
28
+ ) -> DatabaseCatalog:
29
+ """Find known SQLite database files in a database folder.
30
+
31
+ Args:
32
+ database_dir: Folder that should contain lab SQLite DB files.
33
+ database_access: ``direct`` reads DB files in place. ``copy`` uses local
34
+ cached copies because network DB queries can be slow while file
35
+ transfer is fast.
36
+ cache_dir: Optional local cache directory for copied DB files.
37
+
38
+ Returns:
39
+ A catalog listing known DB files that exist.
40
+
41
+ Raises:
42
+ FileNotFoundError: If the database folder is missing or no known DB file
43
+ exists.
44
+
45
+ The function checks only the known Clark lab DB filenames so macOS resource
46
+ fork files like ``._experimentLog.db`` are ignored.
47
+ """
48
+ root = database_dir.expanduser()
49
+ validate_database_access(database_access)
50
+
51
+ catalog = _read_direct_database_catalog(root)
52
+ if database_access == "direct":
53
+ return catalog
54
+
55
+ local_cache_dir = (cache_dir or DEFAULT_DATABASE_CACHE_DIR).expanduser()
56
+ return DatabaseCatalog(
57
+ database_dir=root,
58
+ sqlite_files=tuple(
59
+ ensure_local_database_copy(database_path, local_cache_dir)
60
+ for database_path in catalog.sqlite_files
61
+ ),
62
+ )
63
+
64
+
65
+ def _read_direct_database_catalog(database_dir: Path) -> DatabaseCatalog:
66
+ """Find known SQLite files without copying them.
67
+
68
+ Args:
69
+ database_dir: Database folder to inspect.
70
+
71
+ Returns:
72
+ A catalog with direct database file paths.
73
+
74
+ Raises:
75
+ FileNotFoundError: If the folder or known DB files are absent.
76
+ """
77
+ root = database_dir.expanduser()
78
+ if not root.is_dir():
79
+ msg = f"Missing database directory: {root}"
80
+ raise FileNotFoundError(msg)
81
+
82
+ sqlite_files = tuple(
83
+ path for name in DEFAULT_DATABASE_FILES if (path := root / name).is_file()
84
+ )
85
+ if not sqlite_files:
86
+ expected = ", ".join(DEFAULT_DATABASE_FILES)
87
+ msg = f"No known SQLite database files found in {root}; expected {expected}"
88
+ raise FileNotFoundError(msg)
89
+
90
+ return DatabaseCatalog(database_dir=root, sqlite_files=sqlite_files)
@@ -0,0 +1,27 @@
1
+ """Open SQLite databases with twopy's safety defaults.
2
+
3
+ Inputs: SQLite database file paths.
4
+ Outputs: read-only SQLite connections that return row dictionaries.
5
+ """
6
+
7
+ import sqlite3
8
+ from pathlib import Path
9
+
10
+ __all__ = ["connect_read_only"]
11
+
12
+
13
+ def connect_read_only(database_path: Path) -> sqlite3.Connection:
14
+ """Open one SQLite database in read-only mode.
15
+
16
+ Args:
17
+ database_path: SQLite file to open.
18
+
19
+ Returns:
20
+ SQLite connection configured for row dictionaries.
21
+
22
+ Read-only URI mode prevents accidental writes to the lab database.
23
+ """
24
+ uri = database_path.expanduser().resolve().as_uri() + "?mode=ro"
25
+ connection = sqlite3.connect(uri, uri=True, timeout=5)
26
+ connection.row_factory = sqlite3.Row
27
+ return connection
@@ -0,0 +1,254 @@
1
+ """Schema-aware generic SQLite table search.
2
+
3
+ Inputs: lab database folder, optional table names, and explicit filters.
4
+ Outputs: ``DatabaseRecord`` rows with source DB and table provenance.
5
+
6
+ This module supports exploratory queries before twopy has a modeled schema for a
7
+ table.
8
+ """
9
+
10
+ import sqlite3
11
+ from collections.abc import Mapping, Sequence
12
+ from contextlib import closing
13
+ from pathlib import Path
14
+
15
+ from twopy.database.catalog import read_database_catalog
16
+ from twopy.database.connection import connect_read_only
17
+ from twopy.database.sql import quote_identifier, validate_filter_columns
18
+ from twopy.database.types import (
19
+ DatabaseAccess,
20
+ DatabaseColumn,
21
+ DatabaseRecord,
22
+ DatabaseTable,
23
+ DatabaseValue,
24
+ )
25
+
26
+ __all__ = ["find_experiments"]
27
+
28
+
29
+ def find_experiments(
30
+ database_dir: Path,
31
+ *,
32
+ table_names: Sequence[str] | None = None,
33
+ exact_filters: Mapping[str, DatabaseValue] | None = None,
34
+ contains_filters: Mapping[str, str] | None = None,
35
+ search_text: str | None = None,
36
+ limit: int = 100,
37
+ database_access: DatabaseAccess = "direct",
38
+ cache_dir: Path | None = None,
39
+ ) -> tuple[DatabaseRecord, ...]:
40
+ """Find experiment rows across the known lab SQLite databases.
41
+
42
+ Args:
43
+ database_dir: Folder containing ``experimentLog.db`` and/or
44
+ ``experimentInitLog.db``.
45
+ table_names: Optional table names to search. ``None`` searches all user
46
+ tables in each DB.
47
+ exact_filters: Optional column-to-value equality filters.
48
+ contains_filters: Optional column-to-text substring filters.
49
+ search_text: Optional substring searched across all columns in each
50
+ selected table.
51
+ limit: Maximum number of records to return across all DB files.
52
+ database_access: ``direct`` queries DB files in place. ``copy`` queries
53
+ local cached copies because network DB queries can be slow while
54
+ file transfer is fast.
55
+ cache_dir: Optional cache directory used when ``database_access`` is
56
+ ``copy``.
57
+
58
+ Returns:
59
+ Matching records with source DB path, table name, and row values.
60
+
61
+ Raises:
62
+ FileNotFoundError: If no database files can be found.
63
+ ValueError: If filters reference columns absent from a selected table or
64
+ if ``limit`` is less than one.
65
+
66
+ Filters are combined with ``AND``. This conservative behavior keeps search
67
+ results auditable: every returned row satisfies every requested constraint.
68
+ """
69
+ if limit < 1:
70
+ msg = "Database query limit must be at least 1"
71
+ raise ValueError(msg)
72
+
73
+ catalog = read_database_catalog(
74
+ database_dir,
75
+ database_access=database_access,
76
+ cache_dir=cache_dir,
77
+ )
78
+ remaining = limit
79
+ records: list[DatabaseRecord] = []
80
+
81
+ for database_path in catalog.sqlite_files:
82
+ with closing(connect_read_only(database_path)) as connection:
83
+ tables = _matching_tables(connection, database_path, table_names)
84
+ for table in tables:
85
+ table_records = _query_table(
86
+ connection=connection,
87
+ table=table,
88
+ exact_filters=exact_filters or {},
89
+ contains_filters=contains_filters or {},
90
+ search_text=search_text,
91
+ limit=remaining,
92
+ )
93
+ records.extend(table_records)
94
+ remaining = limit - len(records)
95
+ if remaining == 0:
96
+ return tuple(records)
97
+
98
+ return tuple(records)
99
+
100
+
101
+ def _matching_tables(
102
+ connection: sqlite3.Connection,
103
+ database_path: Path,
104
+ table_names: Sequence[str] | None,
105
+ ) -> tuple[DatabaseTable, ...]:
106
+ """Return selected user tables from one SQLite database.
107
+
108
+ Args:
109
+ connection: Open SQLite connection.
110
+ database_path: Source DB path for returned metadata.
111
+ table_names: Optional table names requested by the caller.
112
+
113
+ Returns:
114
+ Table metadata for the selected tables.
115
+
116
+ Raises:
117
+ ValueError: If a requested table is absent.
118
+ """
119
+ available = _read_tables(connection, database_path)
120
+ if table_names is None:
121
+ return available
122
+
123
+ by_name = {table.name: table for table in available}
124
+ missing = tuple(name for name in table_names if name not in by_name)
125
+ if missing:
126
+ msg = f"Missing table(s) in {database_path}: {', '.join(missing)}"
127
+ raise ValueError(msg)
128
+
129
+ return tuple(by_name[name] for name in table_names)
130
+
131
+
132
+ def _read_tables(
133
+ connection: sqlite3.Connection,
134
+ database_path: Path,
135
+ ) -> tuple[DatabaseTable, ...]:
136
+ """Read table and column metadata from one SQLite database.
137
+
138
+ Args:
139
+ connection: Open SQLite connection.
140
+ database_path: Source DB path for returned metadata.
141
+
142
+ Returns:
143
+ User tables sorted by name.
144
+
145
+ Internal SQLite tables are ignored because they are storage metadata, not
146
+ experiment data.
147
+ """
148
+ rows = connection.execute(
149
+ """
150
+ SELECT name
151
+ FROM sqlite_master
152
+ WHERE type = 'table'
153
+ AND name NOT LIKE 'sqlite_%'
154
+ ORDER BY name
155
+ """,
156
+ ).fetchall()
157
+
158
+ return tuple(
159
+ DatabaseTable(
160
+ database_path=database_path,
161
+ name=str(row["name"]),
162
+ columns=_read_columns(connection, str(row["name"])),
163
+ )
164
+ for row in rows
165
+ )
166
+
167
+
168
+ def _read_columns(
169
+ connection: sqlite3.Connection,
170
+ table_name: str,
171
+ ) -> tuple[DatabaseColumn, ...]:
172
+ """Read column metadata for one SQLite table.
173
+
174
+ Args:
175
+ connection: Open SQLite connection.
176
+ table_name: Table to inspect.
177
+
178
+ Returns:
179
+ Column metadata in SQLite table order.
180
+ """
181
+ rows = connection.execute(
182
+ f"PRAGMA table_info({quote_identifier(table_name)})",
183
+ ).fetchall()
184
+ return tuple(
185
+ DatabaseColumn(
186
+ name=str(row["name"]),
187
+ sqlite_type=str(row["type"]),
188
+ is_primary_key=bool(row["pk"]),
189
+ )
190
+ for row in rows
191
+ )
192
+
193
+
194
+ def _query_table(
195
+ *,
196
+ connection: sqlite3.Connection,
197
+ table: DatabaseTable,
198
+ exact_filters: Mapping[str, DatabaseValue],
199
+ contains_filters: Mapping[str, str],
200
+ search_text: str | None,
201
+ limit: int,
202
+ ) -> tuple[DatabaseRecord, ...]:
203
+ """Query one table with validated filters.
204
+
205
+ Args:
206
+ connection: Open SQLite connection.
207
+ table: Table metadata.
208
+ exact_filters: Equality filters.
209
+ contains_filters: Substring filters for named columns.
210
+ search_text: Optional substring searched across all columns.
211
+ limit: Maximum rows returned from this table.
212
+
213
+ Returns:
214
+ Matching records from this table.
215
+
216
+ SQL identifiers are quoted after validation against discovered columns. SQL
217
+ values are always passed as parameters.
218
+ """
219
+ column_names = tuple(column.name for column in table.columns)
220
+ validate_filter_columns(table, tuple(exact_filters))
221
+ validate_filter_columns(table, tuple(contains_filters))
222
+
223
+ where_clauses: list[str] = []
224
+ parameters: list[DatabaseValue] = []
225
+
226
+ for column, value in exact_filters.items():
227
+ where_clauses.append(f"{quote_identifier(column)} = ?")
228
+ parameters.append(value)
229
+
230
+ for column, value in contains_filters.items():
231
+ where_clauses.append(f"CAST({quote_identifier(column)} AS TEXT) LIKE ?")
232
+ parameters.append(f"%{value}%")
233
+
234
+ if search_text:
235
+ search_clause = " OR ".join(
236
+ f"CAST({quote_identifier(column)} AS TEXT) LIKE ?"
237
+ for column in column_names
238
+ )
239
+ where_clauses.append(f"({search_clause})")
240
+ parameters.extend(f"%{search_text}%" for _ in column_names)
241
+
242
+ where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
243
+ sql = f"SELECT * FROM {quote_identifier(table.name)}{where_sql} LIMIT ?"
244
+ parameters.append(limit)
245
+
246
+ rows = connection.execute(sql, parameters).fetchall()
247
+ return tuple(
248
+ DatabaseRecord(
249
+ database_path=table.database_path,
250
+ table_name=table.name,
251
+ values={column: row[column] for column in tuple(row.keys())},
252
+ )
253
+ for row in rows
254
+ )