spatialcore 0.1.9__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.
- spatialcore/__init__.py +122 -0
- spatialcore/annotation/__init__.py +253 -0
- spatialcore/annotation/acquisition.py +529 -0
- spatialcore/annotation/annotate.py +603 -0
- spatialcore/annotation/cellxgene.py +365 -0
- spatialcore/annotation/confidence.py +802 -0
- spatialcore/annotation/discovery.py +529 -0
- spatialcore/annotation/expression.py +363 -0
- spatialcore/annotation/loading.py +529 -0
- spatialcore/annotation/markers.py +297 -0
- spatialcore/annotation/ontology.py +1282 -0
- spatialcore/annotation/patterns.py +247 -0
- spatialcore/annotation/pipeline.py +620 -0
- spatialcore/annotation/synapse.py +380 -0
- spatialcore/annotation/training.py +1457 -0
- spatialcore/annotation/validation.py +422 -0
- spatialcore/core/__init__.py +34 -0
- spatialcore/core/cache.py +118 -0
- spatialcore/core/logging.py +135 -0
- spatialcore/core/metadata.py +149 -0
- spatialcore/core/utils.py +768 -0
- spatialcore/data/gene_mappings/ensembl_to_hugo_human.tsv +86372 -0
- spatialcore/data/markers/canonical_markers.json +83 -0
- spatialcore/data/ontology_mappings/ontology_index.json +63865 -0
- spatialcore/plotting/__init__.py +109 -0
- spatialcore/plotting/benchmark.py +477 -0
- spatialcore/plotting/celltype.py +329 -0
- spatialcore/plotting/confidence.py +413 -0
- spatialcore/plotting/spatial.py +505 -0
- spatialcore/plotting/utils.py +411 -0
- spatialcore/plotting/validation.py +1342 -0
- spatialcore-0.1.9.dist-info/METADATA +213 -0
- spatialcore-0.1.9.dist-info/RECORD +36 -0
- spatialcore-0.1.9.dist-info/WHEEL +5 -0
- spatialcore-0.1.9.dist-info/licenses/LICENSE +201 -0
- spatialcore-0.1.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cell type column validation for CellTypist training.
|
|
3
|
+
|
|
4
|
+
This module provides comprehensive validation of cell type annotation columns
|
|
5
|
+
before training to catch data quality issues early:
|
|
6
|
+
|
|
7
|
+
1. Column existence
|
|
8
|
+
2. Null/empty value detection
|
|
9
|
+
3. Cardinality checks (2-500 cell types is reasonable)
|
|
10
|
+
4. Minimum cells per type
|
|
11
|
+
5. Suspicious pattern detection (e.g., numeric-only labels, placeholder values)
|
|
12
|
+
6. Class imbalance detection
|
|
13
|
+
|
|
14
|
+
References:
|
|
15
|
+
- CellTypist: https://www.celltypist.org/
|
|
16
|
+
- Best practices for cell type annotation in single-cell genomics
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Dict, List, Optional, Any, Literal
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import anndata as ad
|
|
25
|
+
|
|
26
|
+
from spatialcore.core.logging import get_logger
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Known placeholder/suspicious patterns
|
|
32
|
+
SUSPICIOUS_PATTERNS = [
|
|
33
|
+
(r"^[0-9]+$", "Numeric-only cell type labels"),
|
|
34
|
+
(r"^(unknown|unassigned|na|n/a|none|null)$", "Placeholder values"),
|
|
35
|
+
(r"^cluster_?[0-9]+$", "Cluster-based labels (not biological)"),
|
|
36
|
+
(r"^cell_?[0-9]+$", "Generic cell labels"),
|
|
37
|
+
(r"^type_?[0-9]+$", "Generic type labels"),
|
|
38
|
+
(r"^leiden_?[0-9]*$", "Leiden clustering labels"),
|
|
39
|
+
(r"^louvain_?[0-9]*$", "Louvain clustering labels"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ValidationIssue:
|
|
45
|
+
"""A single validation issue.
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
severity : str
|
|
50
|
+
One of "error", "warning", or "info".
|
|
51
|
+
code : str
|
|
52
|
+
Machine-readable code (e.g., "NULL_VALUES", "LOW_CELL_COUNTS").
|
|
53
|
+
message : str
|
|
54
|
+
Human-readable description of the issue.
|
|
55
|
+
details : dict, optional
|
|
56
|
+
Additional details about the issue.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
severity: Literal["error", "warning", "info"]
|
|
60
|
+
code: str
|
|
61
|
+
message: str
|
|
62
|
+
details: Optional[Dict[str, Any]] = None
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return f"[{self.severity.upper()}] {self.code}: {self.message}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CellTypeValidationResult:
|
|
70
|
+
"""Complete validation result for a cell type column.
|
|
71
|
+
|
|
72
|
+
Attributes
|
|
73
|
+
----------
|
|
74
|
+
column_name : str
|
|
75
|
+
Name of the validated column.
|
|
76
|
+
is_valid : bool
|
|
77
|
+
True if no errors were found (warnings are OK).
|
|
78
|
+
n_cells : int
|
|
79
|
+
Total number of cells.
|
|
80
|
+
n_cell_types : int
|
|
81
|
+
Number of unique cell types.
|
|
82
|
+
issues : list
|
|
83
|
+
List of ValidationIssue objects.
|
|
84
|
+
cell_type_counts : pd.Series, optional
|
|
85
|
+
Counts per cell type, sorted descending.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
column_name: str
|
|
89
|
+
is_valid: bool
|
|
90
|
+
n_cells: int
|
|
91
|
+
n_cell_types: int
|
|
92
|
+
issues: List[ValidationIssue] = field(default_factory=list)
|
|
93
|
+
cell_type_counts: Optional[pd.Series] = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def errors(self) -> List[ValidationIssue]:
|
|
97
|
+
"""Get all error-level issues."""
|
|
98
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def warnings(self) -> List[ValidationIssue]:
|
|
102
|
+
"""Get all warning-level issues."""
|
|
103
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def infos(self) -> List[ValidationIssue]:
|
|
107
|
+
"""Get all info-level issues."""
|
|
108
|
+
return [i for i in self.issues if i.severity == "info"]
|
|
109
|
+
|
|
110
|
+
def summary(self) -> str:
|
|
111
|
+
"""Return human-readable summary of validation results."""
|
|
112
|
+
status = "PASSED" if self.is_valid else "FAILED"
|
|
113
|
+
lines = [
|
|
114
|
+
f"Validation: {status}",
|
|
115
|
+
f" Column: {self.column_name}",
|
|
116
|
+
f" Cells: {self.n_cells:,}",
|
|
117
|
+
f" Cell types: {self.n_cell_types}",
|
|
118
|
+
]
|
|
119
|
+
if self.errors:
|
|
120
|
+
lines.append(f" Errors: {len(self.errors)}")
|
|
121
|
+
for e in self.errors:
|
|
122
|
+
lines.append(f" - {e.message}")
|
|
123
|
+
if self.warnings:
|
|
124
|
+
lines.append(f" Warnings: {len(self.warnings)}")
|
|
125
|
+
for w in self.warnings:
|
|
126
|
+
lines.append(f" - {w.message}")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_cell_type_column(
|
|
131
|
+
adata: ad.AnnData,
|
|
132
|
+
column: str,
|
|
133
|
+
min_cells_per_type: int = 10,
|
|
134
|
+
max_cell_types: int = 500,
|
|
135
|
+
min_cell_types: int = 2,
|
|
136
|
+
allow_nulls: bool = False,
|
|
137
|
+
max_null_fraction: float = 0.05,
|
|
138
|
+
check_suspicious_patterns: bool = True,
|
|
139
|
+
) -> CellTypeValidationResult:
|
|
140
|
+
"""
|
|
141
|
+
Validate a cell type annotation column for CellTypist training.
|
|
142
|
+
|
|
143
|
+
Performs comprehensive checks:
|
|
144
|
+
1. Column existence
|
|
145
|
+
2. Null/empty values (error if >5% by default)
|
|
146
|
+
3. Cardinality (2-500 cell types)
|
|
147
|
+
4. Minimum cells per type (10 by default)
|
|
148
|
+
5. Suspicious patterns (numeric-only, placeholders)
|
|
149
|
+
6. Class imbalance (warn if >1000x ratio)
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
adata : AnnData
|
|
154
|
+
AnnData object to validate.
|
|
155
|
+
column : str
|
|
156
|
+
Name of cell type column in adata.obs.
|
|
157
|
+
min_cells_per_type : int, default 10
|
|
158
|
+
Minimum cells required per cell type. Types with fewer cells
|
|
159
|
+
will generate a warning.
|
|
160
|
+
max_cell_types : int, default 500
|
|
161
|
+
Maximum number of cell types (warn if exceeded).
|
|
162
|
+
min_cell_types : int, default 2
|
|
163
|
+
Minimum number of cell types required (error if not met).
|
|
164
|
+
allow_nulls : bool, default False
|
|
165
|
+
If True, allow null values (still warns if >max_null_fraction).
|
|
166
|
+
max_null_fraction : float, default 0.05
|
|
167
|
+
Maximum fraction of null values before error (if allow_nulls=False).
|
|
168
|
+
check_suspicious_patterns : bool, default True
|
|
169
|
+
Check for suspicious label patterns like numeric-only or cluster labels.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
CellTypeValidationResult
|
|
174
|
+
Complete validation result with issues and statistics.
|
|
175
|
+
|
|
176
|
+
Examples
|
|
177
|
+
--------
|
|
178
|
+
>>> from spatialcore.annotation import validate_cell_type_column
|
|
179
|
+
>>> result = validate_cell_type_column(adata, "cell_type")
|
|
180
|
+
>>> if not result.is_valid:
|
|
181
|
+
... print(result.summary())
|
|
182
|
+
... raise ValueError("Validation failed")
|
|
183
|
+
>>> print(f"Found {result.n_cell_types} cell types")
|
|
184
|
+
|
|
185
|
+
>>> # Inspect low-count types
|
|
186
|
+
>>> low_count = result.cell_type_counts[result.cell_type_counts < 100]
|
|
187
|
+
>>> print(low_count)
|
|
188
|
+
"""
|
|
189
|
+
issues: List[ValidationIssue] = []
|
|
190
|
+
|
|
191
|
+
# Check 1: Column exists
|
|
192
|
+
if column not in adata.obs.columns:
|
|
193
|
+
available = list(adata.obs.columns)
|
|
194
|
+
return CellTypeValidationResult(
|
|
195
|
+
column_name=column,
|
|
196
|
+
is_valid=False,
|
|
197
|
+
n_cells=adata.n_obs,
|
|
198
|
+
n_cell_types=0,
|
|
199
|
+
issues=[
|
|
200
|
+
ValidationIssue(
|
|
201
|
+
severity="error",
|
|
202
|
+
code="COLUMN_NOT_FOUND",
|
|
203
|
+
message=f"Column '{column}' not found in adata.obs",
|
|
204
|
+
details={"available_columns": available[:20]},
|
|
205
|
+
)
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
labels = adata.obs[column]
|
|
210
|
+
n_cells = len(labels)
|
|
211
|
+
|
|
212
|
+
# Check 2: Null values
|
|
213
|
+
null_mask = labels.isna() | (labels.astype(str).str.strip() == "")
|
|
214
|
+
n_nulls = int(null_mask.sum())
|
|
215
|
+
null_fraction = n_nulls / n_cells if n_cells > 0 else 0.0
|
|
216
|
+
|
|
217
|
+
if n_nulls > 0:
|
|
218
|
+
if null_fraction > max_null_fraction and not allow_nulls:
|
|
219
|
+
issues.append(
|
|
220
|
+
ValidationIssue(
|
|
221
|
+
severity="error",
|
|
222
|
+
code="EXCESSIVE_NULLS",
|
|
223
|
+
message=f"{n_nulls:,} null values ({null_fraction:.1%} of cells)",
|
|
224
|
+
details={"n_nulls": n_nulls, "fraction": float(null_fraction)},
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
issues.append(
|
|
229
|
+
ValidationIssue(
|
|
230
|
+
severity="warning",
|
|
231
|
+
code="NULL_VALUES",
|
|
232
|
+
message=f"{n_nulls:,} null values ({null_fraction:.1%} of cells)",
|
|
233
|
+
details={"n_nulls": n_nulls, "fraction": float(null_fraction)},
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Filter out nulls for remaining checks
|
|
238
|
+
valid_labels = labels[~null_mask].astype(str)
|
|
239
|
+
cell_type_counts = valid_labels.value_counts()
|
|
240
|
+
n_cell_types = len(cell_type_counts)
|
|
241
|
+
|
|
242
|
+
# Check 3: Cardinality - too few
|
|
243
|
+
if n_cell_types < min_cell_types:
|
|
244
|
+
issues.append(
|
|
245
|
+
ValidationIssue(
|
|
246
|
+
severity="error",
|
|
247
|
+
code="TOO_FEW_TYPES",
|
|
248
|
+
message=f"Only {n_cell_types} cell types found (minimum: {min_cell_types})",
|
|
249
|
+
details={"n_cell_types": n_cell_types, "min_required": min_cell_types},
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check 4: Cardinality - too many
|
|
254
|
+
if n_cell_types > max_cell_types:
|
|
255
|
+
issues.append(
|
|
256
|
+
ValidationIssue(
|
|
257
|
+
severity="warning",
|
|
258
|
+
code="MANY_CELL_TYPES",
|
|
259
|
+
message=f"{n_cell_types} cell types found (may indicate over-annotation)",
|
|
260
|
+
details={"n_cell_types": n_cell_types, "max_typical": max_cell_types},
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Check 5: Minimum cells per type
|
|
265
|
+
low_count_types = cell_type_counts[cell_type_counts < min_cells_per_type]
|
|
266
|
+
if len(low_count_types) > 0:
|
|
267
|
+
issues.append(
|
|
268
|
+
ValidationIssue(
|
|
269
|
+
severity="warning",
|
|
270
|
+
code="LOW_CELL_COUNTS",
|
|
271
|
+
message=f"{len(low_count_types)} cell types have <{min_cells_per_type} cells",
|
|
272
|
+
details={
|
|
273
|
+
"affected_types": dict(low_count_types.head(10)),
|
|
274
|
+
"n_affected": len(low_count_types),
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Check 6: Suspicious patterns
|
|
280
|
+
if check_suspicious_patterns and n_cell_types > 0:
|
|
281
|
+
suspicious_types = []
|
|
282
|
+
for cell_type in cell_type_counts.index:
|
|
283
|
+
for pattern, description in SUSPICIOUS_PATTERNS:
|
|
284
|
+
if re.match(pattern, str(cell_type), re.IGNORECASE):
|
|
285
|
+
suspicious_types.append((str(cell_type), description))
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
if suspicious_types:
|
|
289
|
+
issues.append(
|
|
290
|
+
ValidationIssue(
|
|
291
|
+
severity="warning",
|
|
292
|
+
code="SUSPICIOUS_LABELS",
|
|
293
|
+
message=f"{len(suspicious_types)} cell types have suspicious labels",
|
|
294
|
+
details={
|
|
295
|
+
"suspicious_types": [
|
|
296
|
+
{"label": t, "reason": r} for t, r in suspicious_types[:10]
|
|
297
|
+
],
|
|
298
|
+
"n_total": len(suspicious_types),
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Check 7: Highly imbalanced data
|
|
304
|
+
if n_cell_types >= 2:
|
|
305
|
+
max_count = int(cell_type_counts.max())
|
|
306
|
+
min_count = int(cell_type_counts.min())
|
|
307
|
+
imbalance_ratio = max_count / max(min_count, 1)
|
|
308
|
+
|
|
309
|
+
if imbalance_ratio > 1000:
|
|
310
|
+
issues.append(
|
|
311
|
+
ValidationIssue(
|
|
312
|
+
severity="warning",
|
|
313
|
+
code="HIGHLY_IMBALANCED",
|
|
314
|
+
message=f"Extreme class imbalance: {imbalance_ratio:.0f}x ratio",
|
|
315
|
+
details={
|
|
316
|
+
"largest_type": str(cell_type_counts.idxmax()),
|
|
317
|
+
"largest_count": max_count,
|
|
318
|
+
"smallest_type": str(cell_type_counts.idxmin()),
|
|
319
|
+
"smallest_count": min_count,
|
|
320
|
+
"imbalance_ratio": float(imbalance_ratio),
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Determine overall validity (only errors make it invalid)
|
|
326
|
+
is_valid = not any(i.severity == "error" for i in issues)
|
|
327
|
+
|
|
328
|
+
result = CellTypeValidationResult(
|
|
329
|
+
column_name=column,
|
|
330
|
+
is_valid=is_valid,
|
|
331
|
+
n_cells=n_cells,
|
|
332
|
+
n_cell_types=n_cell_types,
|
|
333
|
+
issues=issues,
|
|
334
|
+
cell_type_counts=cell_type_counts,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Log summary
|
|
338
|
+
if is_valid:
|
|
339
|
+
logger.info(
|
|
340
|
+
f"Validation passed: {column} ({n_cell_types} cell types, {n_cells:,} cells)"
|
|
341
|
+
)
|
|
342
|
+
for issue in issues:
|
|
343
|
+
logger.warning(f" {issue.code}: {issue.message}")
|
|
344
|
+
else:
|
|
345
|
+
logger.error(f"Validation failed: {column}")
|
|
346
|
+
for issue in issues:
|
|
347
|
+
log_fn = logger.error if issue.severity == "error" else logger.warning
|
|
348
|
+
log_fn(f" {issue.code}: {issue.message}")
|
|
349
|
+
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def validate_multiple_columns(
|
|
354
|
+
adatas: List[ad.AnnData],
|
|
355
|
+
columns: List[str],
|
|
356
|
+
raise_on_error: bool = True,
|
|
357
|
+
**kwargs,
|
|
358
|
+
) -> List[CellTypeValidationResult]:
|
|
359
|
+
"""
|
|
360
|
+
Validate cell type columns across multiple AnnData objects.
|
|
361
|
+
|
|
362
|
+
Convenience function for validating multiple references before combining.
|
|
363
|
+
|
|
364
|
+
Parameters
|
|
365
|
+
----------
|
|
366
|
+
adatas : List[AnnData]
|
|
367
|
+
List of AnnData objects to validate.
|
|
368
|
+
columns : List[str]
|
|
369
|
+
Cell type column name for each AnnData. Must have same length as adatas.
|
|
370
|
+
raise_on_error : bool, default True
|
|
371
|
+
If True, raise ValueError when any validation fails with errors.
|
|
372
|
+
**kwargs
|
|
373
|
+
Additional arguments passed to validate_cell_type_column.
|
|
374
|
+
|
|
375
|
+
Returns
|
|
376
|
+
-------
|
|
377
|
+
List[CellTypeValidationResult]
|
|
378
|
+
Validation results for each AnnData.
|
|
379
|
+
|
|
380
|
+
Raises
|
|
381
|
+
------
|
|
382
|
+
ValueError
|
|
383
|
+
If lengths of adatas and columns don't match.
|
|
384
|
+
If any validation fails with errors and raise_on_error=True.
|
|
385
|
+
|
|
386
|
+
Examples
|
|
387
|
+
--------
|
|
388
|
+
>>> from spatialcore.annotation import validate_multiple_columns
|
|
389
|
+
>>> results = validate_multiple_columns(
|
|
390
|
+
... [adata1, adata2],
|
|
391
|
+
... ["cell_type", "annotation"],
|
|
392
|
+
... )
|
|
393
|
+
>>> for r in results:
|
|
394
|
+
... print(r.summary())
|
|
395
|
+
"""
|
|
396
|
+
if len(adatas) != len(columns):
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"Number of adatas ({len(adatas)}) must match columns ({len(columns)})"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
results = []
|
|
402
|
+
all_valid = True
|
|
403
|
+
|
|
404
|
+
for i, (adata, col) in enumerate(zip(adatas, columns)):
|
|
405
|
+
logger.info(f"Validating reference {i + 1}/{len(adatas)}: column '{col}'")
|
|
406
|
+
result = validate_cell_type_column(adata, col, **kwargs)
|
|
407
|
+
results.append(result)
|
|
408
|
+
if not result.is_valid:
|
|
409
|
+
all_valid = False
|
|
410
|
+
|
|
411
|
+
if not all_valid and raise_on_error:
|
|
412
|
+
failed = [r for r in results if not r.is_valid]
|
|
413
|
+
error_msgs = []
|
|
414
|
+
for r in failed:
|
|
415
|
+
for e in r.errors:
|
|
416
|
+
error_msgs.append(f"{r.column_name}: {e.message}")
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"Validation failed for {len(failed)} reference(s):\n"
|
|
419
|
+
+ "\n".join(error_msgs)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return results
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Core utilities for SpatialCore: logging, metadata tracking, caching, gene mapping."""
|
|
2
|
+
|
|
3
|
+
from spatialcore.core.logging import get_logger, setup_logging
|
|
4
|
+
from spatialcore.core.metadata import MetadataTracker, update_metadata
|
|
5
|
+
from spatialcore.core.cache import cache_result, clear_cache, get_cache_path
|
|
6
|
+
from spatialcore.core.utils import (
|
|
7
|
+
# Gene ID mapping (Ensembl → HUGO)
|
|
8
|
+
load_ensembl_to_hugo_mapping,
|
|
9
|
+
normalize_gene_names,
|
|
10
|
+
download_ensembl_mapping,
|
|
11
|
+
is_ensembl_id,
|
|
12
|
+
# Expression normalization detection
|
|
13
|
+
check_normalization_status,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Logging
|
|
18
|
+
"get_logger",
|
|
19
|
+
"setup_logging",
|
|
20
|
+
# Metadata
|
|
21
|
+
"MetadataTracker",
|
|
22
|
+
"update_metadata",
|
|
23
|
+
# Caching
|
|
24
|
+
"cache_result",
|
|
25
|
+
"clear_cache",
|
|
26
|
+
"get_cache_path",
|
|
27
|
+
# Gene ID mapping
|
|
28
|
+
"load_ensembl_to_hugo_mapping",
|
|
29
|
+
"normalize_gene_names",
|
|
30
|
+
"download_ensembl_mapping",
|
|
31
|
+
"is_ensembl_id",
|
|
32
|
+
# Expression normalization
|
|
33
|
+
"check_normalization_status",
|
|
34
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Caching utilities for intermediate results."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import anndata as ad
|
|
9
|
+
|
|
10
|
+
from spatialcore.core.logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger("cache")
|
|
13
|
+
|
|
14
|
+
_CACHE_DIR = Path(".cache")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_cache_path(name: str, cache_dir: Optional[Union[str, Path]] = None) -> Path:
|
|
18
|
+
"""
|
|
19
|
+
Get path for a cached file.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
name
|
|
24
|
+
Name of the cached file (without extension).
|
|
25
|
+
cache_dir
|
|
26
|
+
Optional cache directory. Defaults to '.cache/'.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
Path
|
|
31
|
+
Full path to the cache file.
|
|
32
|
+
"""
|
|
33
|
+
cache_dir = Path(cache_dir) if cache_dir else _CACHE_DIR
|
|
34
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return cache_dir / f"{name}.h5ad"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cache_result(
|
|
39
|
+
name: Optional[str] = None,
|
|
40
|
+
cache_dir: Optional[Union[str, Path]] = None,
|
|
41
|
+
) -> Callable:
|
|
42
|
+
"""
|
|
43
|
+
Decorator to cache AnnData results.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
name
|
|
48
|
+
Optional cache name. If not provided, uses function name + hash of args.
|
|
49
|
+
cache_dir
|
|
50
|
+
Optional cache directory.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
Callable
|
|
55
|
+
Decorated function.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
>>> @cache_result(name="my_analysis")
|
|
60
|
+
... def expensive_computation(adata):
|
|
61
|
+
... # ... computation ...
|
|
62
|
+
... return adata
|
|
63
|
+
"""
|
|
64
|
+
def decorator(func: Callable) -> Callable:
|
|
65
|
+
@wraps(func)
|
|
66
|
+
def wrapper(*args, **kwargs):
|
|
67
|
+
cache_name = name or _generate_cache_name(func.__name__, args, kwargs)
|
|
68
|
+
cache_path = get_cache_path(cache_name, cache_dir)
|
|
69
|
+
|
|
70
|
+
if cache_path.exists():
|
|
71
|
+
logger.info(f"Loading cached result from {cache_path}")
|
|
72
|
+
return ad.read_h5ad(cache_path)
|
|
73
|
+
|
|
74
|
+
result = func(*args, **kwargs)
|
|
75
|
+
|
|
76
|
+
if isinstance(result, ad.AnnData):
|
|
77
|
+
logger.info(f"Caching result to {cache_path}")
|
|
78
|
+
result.write_h5ad(cache_path)
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
return wrapper
|
|
82
|
+
return decorator
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_cache(cache_dir: Optional[Union[str, Path]] = None) -> int:
|
|
86
|
+
"""
|
|
87
|
+
Clear all cached files.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
cache_dir
|
|
92
|
+
Optional cache directory. Defaults to '.cache/'.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
int
|
|
97
|
+
Number of files removed.
|
|
98
|
+
"""
|
|
99
|
+
cache_dir = Path(cache_dir) if cache_dir else _CACHE_DIR
|
|
100
|
+
if not cache_dir.exists():
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
count = 0
|
|
104
|
+
for f in cache_dir.glob("*.h5ad"):
|
|
105
|
+
f.unlink()
|
|
106
|
+
count += 1
|
|
107
|
+
|
|
108
|
+
logger.info(f"Cleared {count} cached files from {cache_dir}")
|
|
109
|
+
return count
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _generate_cache_name(func_name: str, args: tuple, kwargs: dict) -> str:
|
|
113
|
+
"""Generate a unique cache name based on function and arguments."""
|
|
114
|
+
hasher = hashlib.md5()
|
|
115
|
+
hasher.update(func_name.encode())
|
|
116
|
+
hasher.update(str(args).encode())
|
|
117
|
+
hasher.update(str(sorted(kwargs.items())).encode())
|
|
118
|
+
return f"{func_name}_{hasher.hexdigest()[:8]}"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Logging utilities for SpatialCore."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
_LOGGER_NAME = "spatialcore"
|
|
9
|
+
_DEFAULT_FORMAT = "[%(levelname)s] %(name)s: %(message)s"
|
|
10
|
+
_INITIALIZED = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_initialized() -> None:
|
|
14
|
+
"""
|
|
15
|
+
Ensure the root spatialcore logger has at least one handler.
|
|
16
|
+
|
|
17
|
+
Called automatically by get_logger() to prevent silent log drops.
|
|
18
|
+
"""
|
|
19
|
+
global _INITIALIZED
|
|
20
|
+
if _INITIALIZED:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
root_logger = logging.getLogger(_LOGGER_NAME)
|
|
24
|
+
|
|
25
|
+
# Only add default handler if none exist
|
|
26
|
+
if not root_logger.handlers:
|
|
27
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
28
|
+
handler.setFormatter(logging.Formatter(_DEFAULT_FORMAT))
|
|
29
|
+
handler.setLevel(logging.INFO)
|
|
30
|
+
root_logger.addHandler(handler)
|
|
31
|
+
root_logger.setLevel(logging.INFO)
|
|
32
|
+
root_logger.propagate = False
|
|
33
|
+
|
|
34
|
+
_INITIALIZED = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
38
|
+
"""
|
|
39
|
+
Get a logger instance for SpatialCore.
|
|
40
|
+
|
|
41
|
+
Automatically initializes a default stdout handler if none exists,
|
|
42
|
+
preventing silent log message drops.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
name
|
|
47
|
+
Optional submodule name. If provided, returns logger for
|
|
48
|
+
'spatialcore.{name}', otherwise returns the root spatialcore logger.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
logging.Logger
|
|
53
|
+
Configured logger instance with at least one handler.
|
|
54
|
+
"""
|
|
55
|
+
_ensure_initialized()
|
|
56
|
+
|
|
57
|
+
if name:
|
|
58
|
+
return logging.getLogger(f"{_LOGGER_NAME}.{name}")
|
|
59
|
+
return logging.getLogger(_LOGGER_NAME)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def setup_logging(
|
|
63
|
+
level: int = logging.INFO,
|
|
64
|
+
format_string: Optional[str] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Configure logging for SpatialCore.
|
|
68
|
+
|
|
69
|
+
Clears existing handlers and configures fresh logging. Safe to call
|
|
70
|
+
multiple times (idempotent after first call with same parameters).
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
level
|
|
75
|
+
Logging level (e.g., logging.INFO, logging.DEBUG).
|
|
76
|
+
format_string
|
|
77
|
+
Custom format string. Defaults to '[%(levelname)s] %(name)s: %(message)s'.
|
|
78
|
+
"""
|
|
79
|
+
global _INITIALIZED
|
|
80
|
+
|
|
81
|
+
if format_string is None:
|
|
82
|
+
format_string = _DEFAULT_FORMAT
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
85
|
+
|
|
86
|
+
# Clear existing handlers to prevent duplicates
|
|
87
|
+
logger.handlers.clear()
|
|
88
|
+
|
|
89
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
90
|
+
handler.setFormatter(logging.Formatter(format_string))
|
|
91
|
+
handler.setLevel(level)
|
|
92
|
+
|
|
93
|
+
logger.setLevel(level)
|
|
94
|
+
logger.addHandler(handler)
|
|
95
|
+
logger.propagate = False
|
|
96
|
+
|
|
97
|
+
_INITIALIZED = True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def setup_file_logging(
|
|
101
|
+
log_path: Union[str, Path],
|
|
102
|
+
level: int = logging.INFO,
|
|
103
|
+
format_string: Optional[str] = None,
|
|
104
|
+
) -> logging.FileHandler:
|
|
105
|
+
"""
|
|
106
|
+
Add file handler to SpatialCore logger.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
log_path
|
|
111
|
+
Path to log file.
|
|
112
|
+
level
|
|
113
|
+
Logging level.
|
|
114
|
+
format_string
|
|
115
|
+
Custom format string.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
logging.FileHandler
|
|
120
|
+
The file handler (can be removed later with logger.removeHandler).
|
|
121
|
+
"""
|
|
122
|
+
if format_string is None:
|
|
123
|
+
format_string = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
124
|
+
|
|
125
|
+
log_path = Path(log_path)
|
|
126
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
handler = logging.FileHandler(log_path, mode="w")
|
|
129
|
+
handler.setFormatter(logging.Formatter(format_string))
|
|
130
|
+
handler.setLevel(level)
|
|
131
|
+
|
|
132
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
133
|
+
logger.addHandler(handler)
|
|
134
|
+
|
|
135
|
+
return handler
|