traceratops 2.0.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 (48) hide show
  1. traceratops/__init__.py +0 -0
  2. traceratops/_version.py +21 -0
  3. traceratops/collect_files.py +388 -0
  4. traceratops/core/build_matrix.py +518 -0
  5. traceratops/core/chromatin_trace_table.py +1385 -0
  6. traceratops/core/him_matrix_operations.py +1508 -0
  7. traceratops/core/io_manager.py +66 -0
  8. traceratops/core/localization_table.py +774 -0
  9. traceratops/core/plotting_functions.py +404 -0
  10. traceratops/core/trace_resolver/__init__.py +37 -0
  11. traceratops/core/trace_resolver/beam.py +120 -0
  12. traceratops/core/trace_resolver/classifier.py +403 -0
  13. traceratops/core/trace_resolver/model.py +151 -0
  14. traceratops/core/trace_resolver/resolver.py +206 -0
  15. traceratops/core/trace_resolver/results.py +73 -0
  16. traceratops/core/trace_resolver/scoring.py +48 -0
  17. traceratops/localization_analyzer.py +88 -0
  18. traceratops/localization_merge.py +123 -0
  19. traceratops/plot_3way_coloc.py +216 -0
  20. traceratops/plot_4m.py +272 -0
  21. traceratops/plot_bootstrapping.py +267 -0
  22. traceratops/plot_compare2matrices.py +334 -0
  23. traceratops/plot_him_matrix.py +321 -0
  24. traceratops/plot_matrix_comparison.py +285 -0
  25. traceratops/pymol_script.py +144 -0
  26. traceratops/script_banner.py +56 -0
  27. traceratops/trace_3way_coloc.py +509 -0
  28. traceratops/trace_analyzer.py +776 -0
  29. traceratops/trace_assign_mask.py +249 -0
  30. traceratops/trace_export_to_fofct.py +281 -0
  31. traceratops/trace_filter.py +452 -0
  32. traceratops/trace_genomic_coordinates.py +216 -0
  33. traceratops/trace_import_from_fofct.py +187 -0
  34. traceratops/trace_merge.py +138 -0
  35. traceratops/trace_pearsons.py +393 -0
  36. traceratops/trace_physical_vs_genomic_distance.py +452 -0
  37. traceratops/trace_plot.py +226 -0
  38. traceratops/trace_sorter.py +107 -0
  39. traceratops/trace_split_labels.py +132 -0
  40. traceratops/trace_splitter.py +851 -0
  41. traceratops/trace_stats.py +83 -0
  42. traceratops/trace_to_matrix.py +159 -0
  43. traceratops-2.0.0.dist-info/METADATA +799 -0
  44. traceratops-2.0.0.dist-info/RECORD +48 -0
  45. traceratops-2.0.0.dist-info/WHEEL +5 -0
  46. traceratops-2.0.0.dist-info/entry_points.txt +30 -0
  47. traceratops-2.0.0.dist-info/licenses/LICENSE +674 -0
  48. traceratops-2.0.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,21 @@
1
+ """
2
+ New release versions are made using [Semantic Versioning](https://semver.org/).
3
+
4
+ > Given a version number MAJOR.MINOR.PATCH, increment the:
5
+
6
+ 1. MAJOR version when you make incompatible API changes,
7
+ 2. MINOR version when you add functionality in a backwards compatible manner, and
8
+ 3. PATCH version when you make backwards compatible bug fixes.
9
+
10
+ Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
11
+
12
+ Examples:
13
+ "0.0.0.dev0"
14
+ "1.2.34.dev0"
15
+ "1.2.34a0"
16
+ "1.2.34"
17
+ """
18
+
19
+ import importlib.metadata
20
+
21
+ __version__ = importlib.metadata.version("traceratops")
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ """Collect one file per subdirectory using pattern matching.
3
+
4
+ Walks the immediate subdirectories of a root folder, searches recursively
5
+ for files whose name matches an example, and copies exactly one match per
6
+ subdirectory to a destination folder.
7
+
8
+ Two matching modes:
9
+
10
+ **Exact match** (no ``--variable-part``)::
11
+
12
+ collect_files --root data/RUT \\
13
+ --example-file "localizations_3D_barcode.ecsv" \\
14
+ --copy-to collected/
15
+
16
+ Files are automatically renamed with the subdirectory name
17
+ (``localizations_3D_barcode_013_ROI.ecsv``) since they all share the
18
+ same name.
19
+
20
+ **Variable match** (with ``--variable-part``)::
21
+
22
+ collect_files --root data/RUT \\
23
+ --example-file "Trace_3D_barcode_mask-mask0_ROI-13.ecsv" \\
24
+ --variable-part "13" \\
25
+ --copy-to collected/
26
+
27
+ Matches ``ROI-14.ecsv`` but rejects ``ROI-021.ecsv`` (different length).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import shutil
34
+ import sys
35
+ from dataclasses import dataclass, field
36
+ from pathlib import Path
37
+
38
+ from traceratops.script_banner import print_script_banner
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Business logic
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FilePattern:
47
+ """Filename pattern with an optional fixed-length variable zone.
48
+
49
+ When *variable_length* is 0, the pattern performs exact matching
50
+ (the candidate must equal *prefix*). Otherwise a candidate matches
51
+ when its total length equals ``len(prefix) + variable_length +
52
+ len(suffix)`` and the prefix and suffix are identical.
53
+ """
54
+
55
+ prefix: str
56
+ suffix: str
57
+ variable_length: int
58
+
59
+ @property
60
+ def total_length(self) -> int:
61
+ return len(self.prefix) + self.variable_length + len(self.suffix)
62
+
63
+ def matches(self, filename: str) -> bool:
64
+ """Return True if *filename* matches this pattern."""
65
+ return (
66
+ len(filename) == self.total_length
67
+ and filename[: len(self.prefix)] == self.prefix
68
+ and filename[len(self.prefix) + self.variable_length :] == self.suffix
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class ScanResult:
74
+ """Outcome of scanning subdirectories for pattern matches."""
75
+
76
+ unique: dict[str, Path] = field(default_factory=dict)
77
+ ambiguous: dict[str, list[Path]] = field(default_factory=dict)
78
+ empty: list[str] = field(default_factory=list)
79
+
80
+
81
+ def build_pattern(example_file: str, variable_part: str | None = None) -> FilePattern:
82
+ """Derive a :class:`FilePattern` from *example_file*.
83
+
84
+ When *variable_part* is ``None``, the pattern matches the exact
85
+ filename. Otherwise it matches filenames that are identical to
86
+ *example_file* except at the position of *variable_part*, where any
87
+ characters of the same length are accepted.
88
+
89
+ Raises
90
+ ------
91
+ ValueError
92
+ If *variable_part* is the empty string, or appears zero or more
93
+ than one time in *example_file*.
94
+ """
95
+ if variable_part is None:
96
+ return FilePattern(prefix=example_file, suffix="", variable_length=0)
97
+
98
+ if not variable_part:
99
+ raise ValueError("variable_part must not be empty")
100
+
101
+ count = example_file.count(variable_part)
102
+ if count == 0:
103
+ raise ValueError(
104
+ f"Variable part {variable_part!r} not found in "
105
+ f"example file {example_file!r}"
106
+ )
107
+ if count > 1:
108
+ raise ValueError(
109
+ f"Variable part {variable_part!r} appears {count} times in "
110
+ f"example file {example_file!r} (must appear exactly once)"
111
+ )
112
+
113
+ pos = example_file.index(variable_part)
114
+ return FilePattern(
115
+ prefix=example_file[:pos],
116
+ suffix=example_file[pos + len(variable_part) :],
117
+ variable_length=len(variable_part),
118
+ )
119
+
120
+
121
+ def scan_subdirectories(root: Path, pattern: FilePattern) -> ScanResult:
122
+ """Scan immediate subdirectories of *root* for files matching *pattern*.
123
+
124
+ Each immediate child directory of *root* is an independent collection
125
+ unit. Files are searched **recursively** within each subdirectory.
126
+ """
127
+ result = ScanResult()
128
+
129
+ subdirs = sorted(d for d in root.iterdir() if d.is_dir())
130
+ for subdir in subdirs:
131
+ matches = sorted(
132
+ f for f in subdir.rglob("*") if f.is_file() and pattern.matches(f.name)
133
+ )
134
+ if len(matches) == 0:
135
+ result.empty.append(subdir.name)
136
+ elif len(matches) == 1:
137
+ result.unique[subdir.name] = matches[0]
138
+ else:
139
+ result.ambiguous[subdir.name] = matches
140
+
141
+ return result
142
+
143
+
144
+ def dest_name(source: Path, subdir_name: str, rename: bool) -> str:
145
+ """Compute the destination filename for a collected file.
146
+
147
+ When *rename* is True, the subdirectory name is inserted before the
148
+ extension: ``data.ecsv`` from ``013_ROI`` becomes ``data_013_ROI.ecsv``.
149
+ """
150
+ if not rename:
151
+ return source.name
152
+ return f"{source.stem}_{subdir_name}{source.suffix}"
153
+
154
+
155
+ def check_collisions(
156
+ files: dict[str, Path], copy_to: Path, rename: bool = False
157
+ ) -> list[str]:
158
+ """Return human-readable collision descriptions, if any.
159
+
160
+ Checks for:
161
+ * collisions between collected files (same destination name)
162
+ * collisions with files already present in *copy_to*
163
+ """
164
+ problems: list[str] = []
165
+ seen: dict[str, str] = {} # dest filename -> subdir name
166
+
167
+ for subdir_name, path in sorted(files.items()):
168
+ name = dest_name(path, subdir_name, rename)
169
+ if name in seen:
170
+ problems.append(
171
+ f" {name} from {subdir_name!r} collides with "
172
+ f"file from {seen[name]!r}"
173
+ )
174
+ seen[name] = subdir_name
175
+
176
+ if (copy_to / name).exists():
177
+ problems.append(
178
+ f" {name} from {subdir_name!r} collides with "
179
+ f"existing file in {copy_to}"
180
+ )
181
+
182
+ return problems
183
+
184
+
185
+ def format_report(result: ScanResult) -> str:
186
+ """Build a deterministic, sorted, human-readable scan report."""
187
+ lines: list[str] = []
188
+
189
+ if result.unique:
190
+ lines.append(f"Matched ({len(result.unique)}):")
191
+ for name in sorted(result.unique):
192
+ lines.append(f" {name} -> {result.unique[name]}")
193
+
194
+ if result.empty:
195
+ lines.append(f"No match ({len(result.empty)}):")
196
+ for name in result.empty:
197
+ lines.append(f" {name}")
198
+
199
+ if result.ambiguous:
200
+ lines.append(f"Ambiguous ({len(result.ambiguous)}):")
201
+ for name in sorted(result.ambiguous):
202
+ paths = result.ambiguous[name]
203
+ lines.append(f" {name} ({len(paths)} matches):")
204
+ for p in paths:
205
+ lines.append(f" {p}")
206
+
207
+ return "\n".join(lines)
208
+
209
+
210
+ def collect_files(
211
+ root: Path,
212
+ example_file: str,
213
+ copy_to: Path,
214
+ variable_part: str | None = None,
215
+ force: bool = False,
216
+ rename: bool = False,
217
+ ) -> list[Path]:
218
+ """Collect one file per subdirectory and copy to *copy_to*.
219
+
220
+ Parameters
221
+ ----------
222
+ root : Path
223
+ Root directory whose immediate children are the collection units.
224
+ example_file : str
225
+ Example filename. When *variable_part* is given it must contain
226
+ exactly one occurrence of it; otherwise an exact match is used.
227
+ copy_to : Path
228
+ Destination folder (created if missing).
229
+ variable_part : str or None
230
+ The substring that varies across subdirectories. When ``None``,
231
+ exact matching is used and files are automatically renamed with
232
+ the subdirectory name to avoid collisions.
233
+ force : bool
234
+ When True, subdirectories with no match are silently skipped.
235
+ rename : bool
236
+ When True, the subdirectory name is inserted before the file
237
+ extension (e.g. ``data.dat`` from ``013_ROI`` becomes
238
+ ``data_013_ROI.dat``). Automatically enabled when
239
+ *variable_part* is ``None``.
240
+
241
+ Returns
242
+ -------
243
+ list[Path]
244
+ Paths of the copied files in *copy_to*.
245
+
246
+ Raises
247
+ ------
248
+ ValueError
249
+ Bad arguments or ambiguous matches.
250
+ FileNotFoundError
251
+ Empty subdirectories without *force*, or nothing to copy.
252
+ FileExistsError
253
+ Name collisions in *copy_to*.
254
+ """
255
+ # Exact match → rename is mandatory (all files share the same name).
256
+ if variable_part is None:
257
+ rename = True
258
+
259
+ pattern = build_pattern(example_file, variable_part)
260
+ result = scan_subdirectories(root, pattern)
261
+
262
+ report = format_report(result)
263
+ if report:
264
+ print(report)
265
+
266
+ if result.ambiguous:
267
+ print("\nDecision: nothing copied.")
268
+ raise ValueError(
269
+ f"{len(result.ambiguous)} subdirectory(ies) have multiple matches. "
270
+ "Use a more specific --variable-part to disambiguate."
271
+ )
272
+
273
+ if result.empty and not force:
274
+ print("\nDecision: nothing copied.")
275
+ raise FileNotFoundError(
276
+ f"{len(result.empty)} subdirectory(ies) have no matching files. "
277
+ "Use --force to skip them."
278
+ )
279
+
280
+ if not result.unique:
281
+ print("\nDecision: nothing copied.")
282
+ raise FileNotFoundError("No files found to copy.")
283
+
284
+ copy_to.mkdir(parents=True, exist_ok=True)
285
+
286
+ collisions = check_collisions(result.unique, copy_to, rename)
287
+ if collisions:
288
+ print("\nDecision: nothing copied.")
289
+ raise FileExistsError(
290
+ "Name collisions in destination:\n" + "\n".join(collisions)
291
+ )
292
+
293
+ copied: list[Path] = []
294
+ for subdir_name, source in sorted(result.unique.items()):
295
+ dest = copy_to / dest_name(source, subdir_name, rename)
296
+ shutil.copy2(source, dest)
297
+ copied.append(dest)
298
+
299
+ print(f"\nCopied {len(copied)} file(s) to {copy_to}")
300
+ return copied
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # CLI layer
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def parse_arguments() -> argparse.ArgumentParser:
309
+ """Build and return the argument parser (used by sphinx-argparse)."""
310
+ parser = argparse.ArgumentParser(
311
+ prog="collect_files",
312
+ description=(
313
+ "Collect exactly one file per subdirectory of ROOT by matching "
314
+ "EXAMPLE_FILE. Without --variable-part the match is exact and "
315
+ "files are automatically renamed with the subdirectory name. "
316
+ "With --variable-part a fixed-length pattern match is used."
317
+ ),
318
+ )
319
+ parser.add_argument(
320
+ "--root",
321
+ type=Path,
322
+ required=True,
323
+ help="Root directory to scan (immediate subdirectories are collection units)",
324
+ )
325
+ parser.add_argument(
326
+ "--example-file",
327
+ required=True,
328
+ help="Example filename to search for in each subdirectory",
329
+ )
330
+ parser.add_argument(
331
+ "--variable-part",
332
+ default=None,
333
+ help=(
334
+ "Substring in EXAMPLE_FILE that varies across subdirectories. "
335
+ "When omitted, exact filename matching is used and files are "
336
+ "automatically renamed with the subdirectory name."
337
+ ),
338
+ )
339
+ parser.add_argument(
340
+ "--copy-to",
341
+ type=Path,
342
+ required=True,
343
+ help="Destination folder for collected files (created if missing)",
344
+ )
345
+ parser.add_argument(
346
+ "--force",
347
+ action="store_true",
348
+ help="Skip subdirectories with no match instead of failing",
349
+ )
350
+ parser.add_argument(
351
+ "--rename",
352
+ action="store_true",
353
+ help=(
354
+ "Rename collected files by inserting the subdirectory name "
355
+ "before the extension (e.g. data.dat from 013_ROI -> "
356
+ "data_013_ROI.dat). Automatic when --variable-part is omitted."
357
+ ),
358
+ )
359
+ return parser
360
+
361
+
362
+ def main(argv: list[str] | None = None) -> int:
363
+ """Entry point. Returns 0 on success, 1 on business-logic failure."""
364
+ print_script_banner(__file__, __doc__)
365
+ args = parse_arguments().parse_args(argv)
366
+
367
+ if not args.root.is_dir():
368
+ print(f"Error: {args.root} is not a directory", file=sys.stderr)
369
+ return 1
370
+
371
+ try:
372
+ collect_files(
373
+ root=args.root,
374
+ example_file=args.example_file,
375
+ copy_to=args.copy_to,
376
+ variable_part=args.variable_part,
377
+ force=args.force,
378
+ rename=args.rename,
379
+ )
380
+ except (ValueError, FileNotFoundError, FileExistsError) as exc:
381
+ print(f"Error: {exc}", file=sys.stderr)
382
+ return 1
383
+
384
+ return 0
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())