annotuneagent 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 (38) hide show
  1. annotuneagent/__init__.py +68 -0
  2. annotuneagent/__main__.py +6 -0
  3. annotuneagent/_version.py +19 -0
  4. annotuneagent/annotate.py +93 -0
  5. annotuneagent/arbitrate.py +141 -0
  6. annotuneagent/cli.py +366 -0
  7. annotuneagent/decide.py +725 -0
  8. annotuneagent/evaluate.py +121 -0
  9. annotuneagent/evidence.py +606 -0
  10. annotuneagent/granularity/__init__.py +28 -0
  11. annotuneagent/granularity/cl_dag.py +179 -0
  12. annotuneagent/granularity/ontology_map.py +108 -0
  13. annotuneagent/granularity/relations.py +96 -0
  14. annotuneagent/hypotheses.py +38 -0
  15. annotuneagent/local_lora.py +706 -0
  16. annotuneagent/marker_plot.py +172 -0
  17. annotuneagent/markers.py +206 -0
  18. annotuneagent/markers.tsv +378 -0
  19. annotuneagent/mltools.py +128 -0
  20. annotuneagent/ontology/cl_isa.json +1 -0
  21. annotuneagent/ontology/granularity_policy.yaml +8 -0
  22. annotuneagent/ontology/ontology_map.jsonl +198 -0
  23. annotuneagent/prepare.py +1385 -0
  24. annotuneagent/py.typed +0 -0
  25. annotuneagent/read.py +76 -0
  26. annotuneagent/tokenizer/LICENSE +202 -0
  27. annotuneagent/tokenizer/merges.txt +247587 -0
  28. annotuneagent/tokenizer/tokenizer.json +495920 -0
  29. annotuneagent/tokenizer/tokenizer_config.json +305 -0
  30. annotuneagent/tokenizer/vocab.json +248046 -0
  31. annotuneagent/votes.py +83 -0
  32. annotuneagent/workflow.py +2037 -0
  33. annotuneagent-0.1.0.dist-info/METADATA +208 -0
  34. annotuneagent-0.1.0.dist-info/RECORD +38 -0
  35. annotuneagent-0.1.0.dist-info/WHEEL +5 -0
  36. annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
  37. annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,68 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """AnnoTuneAgent public API.
5
+
6
+ Recommended usage::
7
+
8
+ import annotuneagent as ata
9
+
10
+ records = ata.annotate(adata, cluster_key="leiden", hypotheses=[...])
11
+ adata = ata.read_10x(base_dir, ["sample_a", "sample_b"])
12
+ prepared = ata.prepare(adata, species="human", batch_key="sample")
13
+ report = ata.run(
14
+ prepared, "leiden", species="human",
15
+ models=ata.local_models(model_dir),
16
+ )
17
+
18
+ ``annotate`` scores caller-supplied hypotheses for each cluster. ``run``
19
+ collects three results, the marker score, the name model, and the ML model,
20
+ then a judgment writes the title to ``annotune_label``. The title is a
21
+ dictionary name. A cluster can stay unnamed. Uncertain clusters are
22
+ subclustered once. The name and ML models run when a ``ModelChain`` is
23
+ supplied. The name model copies a cell-type name from the marker dictionary.
24
+ The ML model is trained on machine-learning measurements of the cluster,
25
+ differential expression and marker-panel statistics, and copies a name from
26
+ those candidates.
27
+ ``local_models`` takes the directory containing the Q6_K base and the name and
28
+ ml adapters. The tokenizer is installed with the package. The server is
29
+ ``http://127.0.0.1:8080`` unless ``url`` or ``ANNOTUNE_URL`` says otherwise.
30
+ A matching local server is reused. Remote name and ML services use separate
31
+ ``TaskEndpoint(url=...)`` values. ``attach`` connects to an existing service
32
+ without loading weights.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from ._version import __version__
38
+ from .annotate import annotate
39
+ from .evaluate import evaluate
40
+ from .hypotheses import Hypothesis
41
+ from .local_lora import attach, local_server
42
+ from .markers import hypotheses_from_names, marker_names, suggest_tissues, tissue_pages
43
+ from .mltools import conformal_quantile, fit_ranker
44
+ from .prepare import prepare
45
+ from .read import read_10x
46
+ from .workflow import ModelChain, TaskEndpoint, dictionary_hypotheses, local_models, run
47
+
48
+ __all__ = [
49
+ "Hypothesis",
50
+ "__version__",
51
+ "annotate",
52
+ "attach",
53
+ "conformal_quantile",
54
+ "dictionary_hypotheses",
55
+ "evaluate",
56
+ "fit_ranker",
57
+ "hypotheses_from_names",
58
+ "ModelChain",
59
+ "TaskEndpoint",
60
+ "local_models",
61
+ "local_server",
62
+ "marker_names",
63
+ "prepare",
64
+ "read_10x",
65
+ "run",
66
+ "suggest_tissues",
67
+ "tissue_pages",
68
+ ]
@@ -0,0 +1,6 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ from .cli import main
5
+
6
+ raise SystemExit(main())
@@ -0,0 +1,19 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Package version."""
5
+
6
+ from __future__ import annotations
7
+
8
+ __all__ = ["__version__", "version", "version_tuple"]
9
+
10
+ __version__ = "0.1.0"
11
+ version = __version__
12
+
13
+ _parts: list[int | str] = []
14
+ for _seg in __version__.split("."):
15
+ try:
16
+ _parts.append(int(_seg))
17
+ except ValueError:
18
+ _parts.append(_seg)
19
+ version_tuple: tuple[int | str, ...] = tuple(_parts)
@@ -0,0 +1,93 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Fixed cluster annotation loop."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Collection
9
+
10
+ from anndata import AnnData
11
+
12
+ from .arbitrate import DEFAULT_MIN_MARGIN, arbitrate, score_hypothesis
13
+ from .evidence import (
14
+ cluster_ids,
15
+ cluster_mask,
16
+ evidence_from_stats,
17
+ marker_statistics,
18
+ undeclared_groups,
19
+ validate_expression,
20
+ )
21
+ from .hypotheses import Hypothesis
22
+
23
+
24
+ def annotate(
25
+ adata: AnnData,
26
+ cluster_key: str,
27
+ hypotheses: list[Hypothesis],
28
+ *,
29
+ min_margin: float = DEFAULT_MIN_MARGIN,
30
+ ranker: object | None = None,
31
+ conformal_q: float | None = None,
32
+ clusters: Collection[str] | None = None,
33
+ ) -> list[dict[str, object]]:
34
+ """Score each cluster against ``hypotheses`` and return one record per cluster.
35
+
36
+ ``clusters`` limits the records to those cluster ids. Each cluster is still
37
+ compared with every other cell, so its record is the same either way.
38
+
39
+ Expression is read from ``adata.X``. Genes named by a hypothesis must be in
40
+ ``var_names``. Declared markers are scored together across clusters, using
41
+ every CPU. Mann-Whitney p-values are reported and do not change the score.
42
+ """
43
+ if adata.n_obs == 0:
44
+ raise ValueError("AnnData has no cells")
45
+ if not hypotheses:
46
+ raise ValueError("at least one hypothesis is required")
47
+ validate_expression(adata)
48
+ ids = cluster_ids(adata, cluster_key)
49
+ if clusters is not None:
50
+ wanted = {str(item) for item in clusters}
51
+ ids = [item for item in ids if item in wanted]
52
+ masks = [cluster_mask(adata, cluster_key, cluster) for cluster in ids]
53
+ genes = list(
54
+ dict.fromkeys(
55
+ gene
56
+ for hypothesis in hypotheses
57
+ for gene in (*hypothesis.positive, *hypothesis.negative, *hypothesis.support)
58
+ )
59
+ )
60
+ measured = marker_statistics(adata, masks, genes)
61
+ declared = set(genes)
62
+ extras = undeclared_groups(adata, masks, declared)
63
+ records: list[dict[str, object]] = []
64
+ for cluster, cluster_stats, extra in zip(ids, measured, extras, strict=True):
65
+ scored = []
66
+ for hypothesis in hypotheses:
67
+ positive = [
68
+ evidence_from_stats(gene, "positive", cluster_stats[gene])
69
+ for gene in hypothesis.positive
70
+ ]
71
+ negative = [
72
+ evidence_from_stats(gene, "negative", cluster_stats[gene])
73
+ for gene in hypothesis.negative
74
+ ]
75
+ support = [
76
+ evidence_from_stats(gene, "positive", cluster_stats[gene])
77
+ for gene in hypothesis.support
78
+ ]
79
+ scored.append(score_hypothesis(hypothesis, positive, negative, support))
80
+ if ranker is not None:
81
+ from .mltools import apply_ranker
82
+
83
+ scored = apply_ranker(scored, ranker)
84
+ records.append(
85
+ arbitrate(
86
+ cluster,
87
+ scored,
88
+ min_margin=min_margin,
89
+ de_not_in_markers=extra,
90
+ conformal_q=conformal_q,
91
+ )
92
+ )
93
+ return records
@@ -0,0 +1,141 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Turn gene evidence into one cluster record. This is not a model tool."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from .evidence import NEGATIVE_PRESENT_PCT, GeneEvidence, negative_penalty, positive_contribution
11
+ from .hypotheses import Hypothesis
12
+
13
+ # Score gap below this refuses a hard label.
14
+ DEFAULT_MIN_MARGIN = 0.15
15
+ # A positive panel is on when at least half of its genes are detected in the cluster.
16
+ _PROGRAM_PCT = 0.5
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ScoredHypothesis:
21
+ hypothesis: Hypothesis
22
+ positive: tuple[GeneEvidence, ...]
23
+ negative: tuple[GeneEvidence, ...]
24
+ score: float
25
+ penalty: float
26
+
27
+ def as_evidence(self) -> dict[str, object]:
28
+ return {
29
+ "positive": [gene.as_dict() for gene in self.positive],
30
+ "negative": [gene.as_dict() for gene in self.negative],
31
+ }
32
+
33
+
34
+ def score_hypothesis(
35
+ hypothesis: Hypothesis,
36
+ positive: list[GeneEvidence],
37
+ negative: list[GeneEvidence],
38
+ support: list[GeneEvidence] | None = None,
39
+ ) -> ScoredHypothesis:
40
+ # A panel gene detected in fewer than 25% of the cells is absent here.
41
+ # It stays out of the mean so it cannot dilute a gene that is actually high.
42
+ detected = [gene for gene in positive if gene.pct_in >= NEGATIVE_PRESENT_PCT]
43
+ # Two detected genes can be averaged without the absent ones.
44
+ # One detected gene must not stand in for the whole panel.
45
+ used = detected if len(detected) >= 2 else positive
46
+ parts = [positive_contribution(gene) for gene in used]
47
+ score = float(sum(parts) / len(parts)) if parts else 0.0
48
+ score += float(sum(positive_contribution(gene) for gene in support or []))
49
+ fired = [value for value in (negative_penalty(gene) for gene in negative) if value > 0]
50
+ # One opposing gene weighs like one positive gene. Subtracting the whole
51
+ # logfc let a single shared gene erase a specific panel.
52
+ penalty = float(sum(fired) / len(parts)) if fired and parts else float(sum(fired))
53
+ return ScoredHypothesis(
54
+ hypothesis=hypothesis,
55
+ positive=tuple(positive),
56
+ negative=tuple(negative),
57
+ score=score - penalty,
58
+ penalty=penalty,
59
+ )
60
+
61
+
62
+ def program_detected(genes: tuple[GeneEvidence, ...] | list[GeneEvidence]) -> bool:
63
+ """True when two positive genes, and at least half the panel, are detected.
64
+
65
+ One detected gene is not a second program. A two-gene panel therefore needs
66
+ both genes, including when the other gene is ambient.
67
+ """
68
+ if len(genes) < 2:
69
+ return False
70
+ detected = sum(gene.pct_in >= _PROGRAM_PCT for gene in genes)
71
+ return detected >= 2 and detected * 2 >= len(genes)
72
+
73
+
74
+ def arbitrate(
75
+ cluster: str,
76
+ scored: list[ScoredHypothesis],
77
+ *,
78
+ min_margin: float = DEFAULT_MIN_MARGIN,
79
+ de_not_in_markers: list[str] | None = None,
80
+ conformal_q: float | None = None,
81
+ ) -> dict[str, object]:
82
+ if len(scored) < 1:
83
+ raise ValueError("no hypotheses to arbitrate")
84
+ ranked = sorted(scored, key=lambda item: (-item.score, item.hypothesis.name))
85
+ top = ranked[0]
86
+ runner = ranked[1] if len(ranked) > 1 else None
87
+ margin = top.score if runner is None else top.score - runner.score
88
+ accepted: list[str] | None = None
89
+ if conformal_q is not None:
90
+ from .mltools import prediction_set
91
+
92
+ accepted = prediction_set(ranked, conformal_q)
93
+ status = "label"
94
+ if top.penalty > 0 or not any(positive_contribution(gene) > 0 for gene in top.positive):
95
+ status = "uncertain"
96
+ elif accepted is not None:
97
+ status = "label" if accepted == [top.hypothesis.name] else "uncertain"
98
+ elif runner is not None and margin < min_margin:
99
+ status = "uncertain"
100
+ cell_type = top.hypothesis.name if status == "label" else None
101
+ reasoning = _reasoning(top, status)
102
+ return {
103
+ "cluster": str(cluster),
104
+ "status": status,
105
+ "cell_type": cell_type,
106
+ "margin": margin,
107
+ "score": top.score,
108
+ "runner_up": None if runner is None else runner.hypothesis.name,
109
+ "reasoning": reasoning,
110
+ "evidence": {
111
+ **top.as_evidence(),
112
+ "de_not_in_markers": list(de_not_in_markers or []),
113
+ },
114
+ "ranking": [
115
+ {
116
+ "name": item.hypothesis.name,
117
+ "score": item.score,
118
+ "program_on": program_detected(item.positive),
119
+ }
120
+ for item in ranked
121
+ ],
122
+ "prediction_set": accepted,
123
+ "hypotheses_tested": [item.hypothesis.name for item in ranked],
124
+ "used_tools": ["score_positive_markers", "score_negative_markers", "run_de"],
125
+ }
126
+
127
+
128
+ def _reasoning(top: ScoredHypothesis, status: str) -> str:
129
+ up = [gene.gene for gene in top.positive if positive_contribution(gene) > 0]
130
+ against = [gene.gene for gene in top.negative if negative_penalty(gene) > 0]
131
+ parts: list[str] = []
132
+ if up:
133
+ parts.append(f"{', '.join(up)} enriched in the cluster")
134
+ if against:
135
+ parts.append(f"{', '.join(against)} enriched despite being negative markers")
136
+ if not parts:
137
+ parts.append("no positive enrichment")
138
+ sentence = "; ".join(parts) + "."
139
+ if status == "uncertain":
140
+ return sentence + " No hard label."
141
+ return sentence
annotuneagent/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ # Copyright 2026 Zhao Li (李钊)
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Read an h5ad, score clusters, write JSON."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import anndata as ad
15
+ import scanpy as sc
16
+
17
+ from .evaluate import evaluate
18
+ from .hypotheses import Hypothesis
19
+ from .local_lora import packaged_tokenizer
20
+ from .markers import hypotheses_from_names, parents_for, suggest_tissues, tissue_pages
21
+ from .prepare import install_hint, prepare
22
+ from .workflow import ModelChain, TaskEndpoint, dictionary_hypotheses, run
23
+
24
+
25
+ def _endpoint(
26
+ url: str | None,
27
+ adapter: str | None,
28
+ base: str,
29
+ maxlen: int,
30
+ tokenizer: str | None,
31
+ server: str | None,
32
+ ) -> TaskEndpoint:
33
+ return TaskEndpoint(
34
+ url=url, adapter=adapter, base=base, maxlen=maxlen, tokenizer=tokenizer, server=server
35
+ )
36
+
37
+
38
+ def _models(args, parser) -> ModelChain | None:
39
+ specified = [
40
+ args.name_url,
41
+ args.ml_url,
42
+ args.name_adapter,
43
+ args.ml_adapter,
44
+ ]
45
+ if not any(specified):
46
+ return None
47
+ if not (args.name_url or args.name_adapter):
48
+ parser.error("name needs --name-url or --name-adapter")
49
+ if not (args.ml_url or args.ml_adapter):
50
+ parser.error("ml needs --ml-url or --ml-adapter")
51
+ if any((args.name_adapter, args.ml_adapter)):
52
+ if args.name_url or args.ml_url:
53
+ parser.error("local adapters cannot be combined with --name-url or --ml-url")
54
+ if not (args.name_adapter and args.ml_adapter):
55
+ parser.error("local adapters require both --name-adapter and --ml-adapter")
56
+ if args.base is None:
57
+ parser.error("local adapters require --base GGUF")
58
+ return ModelChain(
59
+ name=_endpoint(
60
+ args.name_url,
61
+ args.name_adapter,
62
+ args.base or "",
63
+ 2048,
64
+ packaged_tokenizer(),
65
+ args.server,
66
+ ),
67
+ ml=_endpoint(
68
+ args.ml_url, args.ml_adapter, args.base or "", 8192, packaged_tokenizer(), args.server
69
+ ),
70
+ )
71
+
72
+
73
+ def _hypotheses_from_file(path: Path) -> list[Hypothesis]:
74
+ payload = json.loads(path.read_text())
75
+ if not isinstance(payload, list):
76
+ raise ValueError("hypotheses file must be a JSON list")
77
+ hypotheses: list[Hypothesis] = []
78
+ for item in payload:
79
+ hypotheses.append(
80
+ Hypothesis(
81
+ str(item["name"]),
82
+ positive=tuple(item["positive"]),
83
+ negative=tuple(item.get("negative", ())),
84
+ )
85
+ )
86
+ return hypotheses
87
+
88
+
89
+ def main(argv: list[str] | None = None) -> int:
90
+ parser = argparse.ArgumentParser(prog="annotuneagent")
91
+ parser.add_argument("--input", type=Path, help="count or clustered .h5ad")
92
+ parser.add_argument(
93
+ "--cluster-key", help="obs column of clusters; default leiden after --prepare"
94
+ )
95
+ parser.add_argument(
96
+ "--output",
97
+ type=Path,
98
+ help="JSON report; a .markers.pdf is written beside it when markers pass validation",
99
+ )
100
+ parser.add_argument(
101
+ "--prepare", action="store_true", help="filter, select genes, and cluster before annotation"
102
+ )
103
+ parser.add_argument(
104
+ "--batch-key", help="obs column for per-sample QC, HVG, and ambient subtraction"
105
+ )
106
+ parser.add_argument(
107
+ "--batch-correction",
108
+ choices=["none", "harmony", "combat", "scanorama", "scvi", "scvi+harmony"],
109
+ default="none",
110
+ help=(
111
+ "neighbor representation for Leiden and UMAP; "
112
+ "scvi and scvi+harmony use raw counts and can use GPU"
113
+ ),
114
+ )
115
+ parser.add_argument("--doublet-method", choices=["none", "scrublet", "solo"], default="none")
116
+ parser.add_argument(
117
+ "--doublet-chip",
118
+ choices=["10k", "20k"],
119
+ default="10k",
120
+ help=(
121
+ "multiplet rate for Scrublet and SOLO from recovered cells: "
122
+ "10k is 0.8%% per 1,000, 20k is 0.4%% per 1,000"
123
+ ),
124
+ )
125
+ parser.add_argument(
126
+ "--solo-expected-rates",
127
+ type=Path,
128
+ help="JSON map of sample to expected doublet fraction; requires --doublet-method solo",
129
+ )
130
+ parser.add_argument(
131
+ "--scvi-model-dir", type=Path, help="save the trained scVI integration model"
132
+ )
133
+ parser.add_argument(
134
+ "--denoise", action="store_true", help="subtract ambient RNA with AmbiDose; requires --raw"
135
+ )
136
+ parser.add_argument(
137
+ "--raw",
138
+ type=Path,
139
+ help=(
140
+ "unfiltered counts for AmbiDose: one matrix, or a parent folder "
141
+ "with one sample subfolder each (raw_feature_bc_matrix.h5)"
142
+ ),
143
+ )
144
+ parser.add_argument("--resolution", type=float, help="Leiden resolution; default 0.4")
145
+ parser.add_argument(
146
+ "--n-pcs",
147
+ type=int,
148
+ help="PCA count, or the scVI latent size; default is the variance elbow, or 30 for scvi and scvi+harmony",
149
+ )
150
+ parser.add_argument("--n-top-genes", type=int, help="highly variable genes; default 2000")
151
+ parser.add_argument(
152
+ "--norm",
153
+ choices=["log1p", "shifted"],
154
+ default="log1p",
155
+ help="log1p scales to 10000 counts; shifted scales to the median library size",
156
+ )
157
+ parser.add_argument(
158
+ "--scfair", action="store_true", help="use scFair for variable genes and the resolution"
159
+ )
160
+ parser.add_argument(
161
+ "--max-pct-mt",
162
+ type=float,
163
+ help="mitochondrial percent cap from 0 to 100; 10 means 10%%",
164
+ )
165
+ parser.add_argument("--output-h5ad", type=Path, help="write workflow labels to this .h5ad")
166
+ parser.add_argument(
167
+ "--types",
168
+ action="append",
169
+ help="built-in dictionary name; repeat for each candidate",
170
+ )
171
+ parser.add_argument("--hypotheses", type=Path, help="JSON list of {name, positive, negative}")
172
+ parser.add_argument(
173
+ "--catalog",
174
+ type=Path,
175
+ help="extra dictionary types scored when a cluster is unresolved; with --species, the species dictionary",
176
+ )
177
+ parser.add_argument(
178
+ "--species",
179
+ choices=["human", "mouse"],
180
+ help="human or mouse; required for --prepare, --tissue, and --types",
181
+ )
182
+ parser.add_argument("--tissue", help="adds that tissue page to the core dictionary")
183
+ parser.add_argument(
184
+ "--list-tissues",
185
+ action="store_true",
186
+ help="print tissue pages and exit; with --author-tissue, print the matching pages",
187
+ )
188
+ parser.add_argument(
189
+ "--author-tissue",
190
+ help="author tissue phrase, such as DRG; choose one printed page with --tissue",
191
+ )
192
+ parser.add_argument("--label-key", help="author labels in obs, used only for evaluation")
193
+ parser.add_argument("--min-margin", type=float, default=None)
194
+ parser.add_argument(
195
+ "--server",
196
+ help="server root, default http://127.0.0.1:8080; reuse requires matching GGUF and adapters",
197
+ )
198
+ parser.add_argument("--name-url", help="name adapter server root")
199
+ parser.add_argument("--ml-url", help="ml adapter server root")
200
+ parser.add_argument("--name-adapter", help="name LoRA GGUF; loaded with ml on one base")
201
+ parser.add_argument("--ml-adapter", help="ml LoRA GGUF; enabled only for the ml request")
202
+ parser.add_argument("--base", help="GGUF base model for local adapters")
203
+ args = parser.parse_args(argv)
204
+ if args.list_tissues:
205
+ pages = (
206
+ suggest_tissues(args.author_tissue, args.species)
207
+ if args.author_tissue
208
+ else tissue_pages(args.species)
209
+ )
210
+ sys.stdout.write("\n".join(pages) + ("\n" if pages else ""))
211
+ return 0
212
+ if args.input is None or args.output is None:
213
+ parser.error("--input and --output are required")
214
+ if args.tissue is not None:
215
+ if args.species is None:
216
+ parser.error("--tissue requires --species")
217
+ if args.tissue not in tissue_pages(args.species):
218
+ parser.error(
219
+ f"unknown {args.species} tissue {args.tissue!r}; "
220
+ f"choose one from --list-tissues --species {args.species}"
221
+ )
222
+ if not args.prepare:
223
+ prepare_options = [
224
+ name
225
+ for name, used in (
226
+ ("--batch-key", args.batch_key is not None),
227
+ ("--batch-correction", args.batch_correction != "none"),
228
+ ("--doublet-method", args.doublet_method != "none"),
229
+ ("--doublet-chip", args.doublet_chip != "10k"),
230
+ ("--solo-expected-rates", args.solo_expected_rates is not None),
231
+ ("--scvi-model-dir", args.scvi_model_dir is not None),
232
+ ("--denoise", args.denoise),
233
+ ("--raw", args.raw is not None),
234
+ ("--resolution", args.resolution is not None),
235
+ ("--n-pcs", args.n_pcs is not None),
236
+ ("--n-top-genes", args.n_top_genes is not None),
237
+ ("--norm", args.norm != "log1p"),
238
+ ("--scfair", args.scfair),
239
+ ("--max-pct-mt", args.max_pct_mt is not None),
240
+ )
241
+ if used
242
+ ]
243
+ if prepare_options:
244
+ parser.error(f"{', '.join(prepare_options)} require --prepare")
245
+ if args.raw is not None and not args.denoise:
246
+ parser.error("--raw requires --denoise")
247
+ if args.author_tissue and args.tissue is None:
248
+ pages = suggest_tissues(args.author_tissue, args.species)
249
+ parser.error("choose one tissue page with --tissue: " + ", ".join(pages))
250
+ if args.author_tissue and args.tissue is not None:
251
+ pages = suggest_tissues(args.author_tissue, args.species)
252
+ full = tissue_pages(args.species)
253
+ if pages != full and args.tissue not in pages:
254
+ print(
255
+ f"author source {args.author_tissue!r} matches {', '.join(pages)}; "
256
+ f"this run uses --tissue {args.tissue!r}",
257
+ file=sys.stderr,
258
+ )
259
+
260
+ if (args.types is None) == (args.hypotheses is None):
261
+ parser.error("pass exactly one of --types or --hypotheses")
262
+ models = _models(args, parser)
263
+ if models is not None and args.output_h5ad is None:
264
+ parser.error("model endpoints require --output-h5ad")
265
+ # Fail now. Prepare and the models can run for a long time before anything is written.
266
+ for path in (args.output, args.output_h5ad):
267
+ if path is None:
268
+ continue
269
+ try:
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ except OSError as exc:
272
+ parser.error(f"cannot create {path.parent}: {exc}")
273
+ if not os.access(path.parent, os.W_OK):
274
+ parser.error(f"cannot write to {path.parent}")
275
+
276
+ if args.types is not None:
277
+ if args.species is None:
278
+ parser.error("--species is required with --types")
279
+ names = [part.strip() for part in args.types if part.strip()]
280
+ hypotheses = hypotheses_from_names(names, args.species, args.tissue)
281
+ parents = parents_for(names, species=args.species, tissue=args.tissue)
282
+ else:
283
+ hypotheses = _hypotheses_from_file(args.hypotheses)
284
+ parents = {}
285
+ if args.catalog is not None:
286
+ catalog = _hypotheses_from_file(args.catalog)
287
+ elif args.species is not None:
288
+ catalog = dictionary_hypotheses(args.species, args.tissue)
289
+ else:
290
+ catalog = None
291
+
292
+ adata = sc.read_h5ad(args.input)
293
+ if args.prepare:
294
+ if args.species is None:
295
+ parser.error("--prepare requires --species")
296
+ if args.denoise and args.raw is None:
297
+ parser.error("No unfiltered droplet matrix. Pass --raw, or omit --denoise.")
298
+ if install_hint(
299
+ batch_correction=args.batch_correction,
300
+ doublet_method=args.doublet_method,
301
+ scfair=args.scfair,
302
+ denoise=args.denoise,
303
+ ):
304
+ return 0
305
+ rates = None
306
+ if args.solo_expected_rates is not None:
307
+ rates = json.loads(args.solo_expected_rates.read_text())
308
+ if not isinstance(rates, dict):
309
+ parser.error("--solo-expected-rates must contain a JSON object")
310
+ adata = prepare(
311
+ adata,
312
+ species=args.species,
313
+ batch_key=args.batch_key,
314
+ batch_correction=args.batch_correction,
315
+ doublet_method=args.doublet_method,
316
+ doublet_chip=args.doublet_chip,
317
+ solo_expected_rates=rates,
318
+ scvi_model_dir=args.scvi_model_dir,
319
+ denoise=args.denoise,
320
+ raw=args.raw,
321
+ resolution=args.resolution,
322
+ n_pcs=args.n_pcs,
323
+ n_top_genes=args.n_top_genes,
324
+ norm=args.norm,
325
+ scfair=args.scfair,
326
+ max_pct_mt=args.max_pct_mt,
327
+ cluster_key=args.cluster_key or "leiden",
328
+ )
329
+ cluster_key = args.cluster_key or "leiden"
330
+ elif args.cluster_key is None:
331
+ parser.error("--cluster-key is required")
332
+ else:
333
+ cluster_key = args.cluster_key
334
+ kwargs = {}
335
+ if args.min_margin is not None:
336
+ kwargs["min_margin"] = args.min_margin
337
+ payload = run(
338
+ adata,
339
+ cluster_key,
340
+ species=args.species,
341
+ tissue=args.tissue,
342
+ hypotheses=hypotheses,
343
+ catalog=catalog,
344
+ models=models,
345
+ source=args.input.name,
346
+ dotplot_path=args.output.with_name(args.output.stem + ".markers.pdf"),
347
+ **kwargs,
348
+ )
349
+ if args.label_key is not None:
350
+ payload["evaluation"] = evaluate(
351
+ payload["records"],
352
+ adata,
353
+ cluster_key,
354
+ args.label_key,
355
+ parents=parents,
356
+ prediction_key="annotune_label",
357
+ )
358
+ # The report goes first so a failed h5ad write does not lose the run.
359
+ args.output.write_text(json.dumps(payload, indent=2) + "\n")
360
+ if args.output_h5ad is not None:
361
+ saved = adata.copy()
362
+ if "annotune_prep" in saved.uns:
363
+ saved.uns["annotune_prep"] = json.dumps(saved.uns["annotune_prep"])
364
+ with ad.settings.override(allow_write_nullable_strings=True):
365
+ saved.write_h5ad(args.output_h5ad)
366
+ return 0