psyclaw 0.28.1 → 0.28.2
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.
- package/README.md +5 -5
- package/agents/recommended/catalog.json +1 -1
- package/dist/src/adapters/pi/extension.js +43 -9
- package/dist/src/adapters/pi/extension.js.map +1 -1
- package/dist/src/ars/mode-editor.d.ts +20 -0
- package/dist/src/ars/mode-editor.js +57 -0
- package/dist/src/ars/mode-editor.js.map +1 -0
- package/dist/src/ars/profile.d.ts +4 -1
- package/dist/src/ars/profile.js +23 -6
- package/dist/src/ars/profile.js.map +1 -1
- package/package.json +10 -3
- package/vendor/academic-paper-skills/LICENSE +21 -0
- package/vendor/academic-paper-skills/NOTICE.md +7 -0
- package/vendor/academic-paper-skills/PSYCLAW_SOURCE.json +14 -0
- package/vendor/academic-paper-skills/academic-paper-composer/SKILL.md +840 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/section_guides.md +675 -0
- package/vendor/academic-paper-skills/academic-paper-composer/references/writing_standards.md +629 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/chapter_quality_check.py +470 -0
- package/vendor/academic-paper-skills/academic-paper-composer/scripts/final_evaluation.py +550 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/SKILL.md +670 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/quality_standards.md +336 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/references/search_strategy.md +459 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/evaluate_samples.py +300 -0
- package/vendor/academic-paper-skills/academic-paper-strategist/scripts/gap_analysis.py +399 -0
- package/vendor/ars/pi/wrapper.js +9 -0
- package/vendor/nature-skills/LICENSE +201 -0
- package/vendor/nature-skills/NOTICE.md +7 -0
- package/vendor/nature-skills/PSYCLAW_SOURCE.json +19 -0
- package/vendor/nature-skills/skills/nature-figure/README.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/README_EN.md +107 -0
- package/vendor/nature-skills/skills/nature-figure/SKILL.md +154 -0
- package/vendor/nature-skills/skills/nature-figure/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-figure/evals/evals.json +325 -0
- package/vendor/nature-skills/skills/nature-figure/manifest.yaml +103 -0
- package/vendor/nature-skills/skills/nature-figure/references/ai-graphical-abstract-workflow.md +129 -0
- package/vendor/nature-skills/skills/nature-figure/references/api.md +576 -0
- package/vendor/nature-skills/skills/nature-figure/references/asset-adaptation.md +72 -0
- package/vendor/nature-skills/skills/nature-figure/references/backend-selection.md +118 -0
- package/vendor/nature-skills/skills/nature-figure/references/chart-types.md +348 -0
- package/vendor/nature-skills/skills/nature-figure/references/common-patterns.md +371 -0
- package/vendor/nature-skills/skills/nature-figure/references/demos.md +58 -0
- package/vendor/nature-skills/skills/nature-figure/references/design-theory.md +467 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-contract.md +122 -0
- package/vendor/nature-skills/skills/nature-figure/references/figure-legend-conventions.md +90 -0
- package/vendor/nature-skills/skills/nature-figure/references/multipanel-evidence-architecture.md +268 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-2026-observations.md +124 -0
- package/vendor/nature-skills/skills/nature-figure/references/nature-article-requirements.md +149 -0
- package/vendor/nature-skills/skills/nature-figure/references/openrouter-image-generation.md +141 -0
- package/vendor/nature-skills/skills/nature-figure/references/qa-contract.md +344 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-template-index.md +66 -0
- package/vendor/nature-skills/skills/nature-figure/references/r-workflow.md +234 -0
- package/vendor/nature-skills/skills/nature-figure/references/template-catalog.md +33 -0
- package/vendor/nature-skills/skills/nature-figure/references/tutorials.md +260 -0
- package/vendor/nature-skills/skills/nature-figure/requirements.txt +1 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_figure_collisions.py +742 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_panel_alignment.py +933 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/audit_pdf_text.py +152 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/figure_safety.py +50 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/generate_openrouter_schematic.py +260 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/nature_figure_backend.py +93 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/panel_alignment.R +188 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/plot_templates.py +604 -0
- package/vendor/nature-skills/skills/nature-figure/scripts/validate_figure.py +835 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/contract.md +55 -0
- package/vendor/nature-skills/skills/nature-figure/static/core/stance.md +41 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/python.md +56 -0
- package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/r.md +63 -0
- package/vendor/nature-skills/skills/nature-figure/tests/test_figure_safety.py +209 -0
- package/vendor/nature-skills/skills/nature-polishing/README.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/README_EN.md +58 -0
- package/vendor/nature-skills/skills/nature-polishing/SKILL.md +111 -0
- package/vendor/nature-skills/skills/nature-polishing/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-polishing/manifest.yaml +101 -0
- package/vendor/nature-skills/skills/nature-polishing/references/latex-layout.md +211 -0
- package/vendor/nature-skills/skills/nature-polishing/references/nat-comms-2025-diction.md +73 -0
- package/vendor/nature-skills/skills/nature-polishing/references/phrasebank-playbook.md +175 -0
- package/vendor/nature-skills/skills/nature-polishing/references/published-article-patterns.md +126 -0
- package/vendor/nature-skills/skills/nature-polishing/references/section-moves.md +252 -0
- package/vendor/nature-skills/skills/nature-polishing/references/style-guardrails.md +94 -0
- package/vendor/nature-skills/skills/nature-polishing/references/writing-strategy.md +160 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/failure-modes.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/output-format.md +21 -0
- package/vendor/nature-skills/skills/nature-polishing/static/core/stance.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/generic.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-comms.md +38 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-mach-intell.md +102 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nature.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/zh-to-en.md +19 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/algorithmic.md +18 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/hypothesis.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/methods.md +35 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/research.md +29 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/review.md +17 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/abstract.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/conclusion.md +15 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/discussion.md +48 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/intro.md +33 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/methods.md +26 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/results.md +61 -0
- package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/title.md +17 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/README_EN.md +44 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/SKILL.md +196 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/agents/openai.yaml +4 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/manifest.yaml +19 -0
- package/vendor/nature-skills/skills/nature-ref-verifier/references/common-patterns.md +96 -0
- package/vendor/nature-skills/skills/nature-shared/README.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/README_EN.md +43 -0
- package/vendor/nature-skills/skills/nature-shared/SKILL.md +33 -0
- package/vendor/nature-skills/skills/nature-shared/agents/openai.yaml +6 -0
- package/vendor/nature-skills/skills/nature-shared/core/consistency-sweep.md +122 -0
- package/vendor/nature-skills/skills/nature-shared/core/discussion-argument-language.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/ethics.md +88 -0
- package/vendor/nature-skills/skills/nature-shared/core/main-text-discipline.md +179 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-abstract.md +171 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-introduction.md +164 -0
- package/vendor/nature-skills/skills/nature-shared/core/nature-results-discussion.md +215 -0
- package/vendor/nature-skills/skills/nature-shared/core/paper-type-taxonomy.md +41 -0
- package/vendor/nature-skills/skills/nature-shared/core/reader-workflow.md +21 -0
- package/vendor/nature-skills/skills/nature-shared/core/research-compliance.md +192 -0
- package/vendor/nature-skills/skills/nature-shared/core/terminology-ledger.md +58 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nat-comms.md +139 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature-machine-intelligence.md +431 -0
- package/vendor/nature-skills/skills/nature-shared/journal-formats/nature.md +313 -0
- package/vendor/nature-skills/skills/nature-shared/manifest.yaml +52 -0
- package/vendor/nature-skills/skills/nature-shared/scripts/check_consistency.py +273 -0
- package/vendor/nature-skills/skills/nature-shared/tests/test_check_consistency.py +63 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validated Python templates for five common manuscript-figure families.
|
|
3
|
+
|
|
4
|
+
Subcommands: volcano, roc, dotplot, marginal, and paired. Production runs
|
|
5
|
+
require a CSV input. Simulated data is available only through the explicit
|
|
6
|
+
--demo flag and is marked as such in the generated QA record.
|
|
7
|
+
|
|
8
|
+
The template families and adaptation safeguards were informed by the
|
|
9
|
+
Apache-2.0 academic-figure-skill asset collection. This implementation is
|
|
10
|
+
portable, path-independent, and designed for the nature-figure contract.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import csv
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_WIDTH_MM = 183.0
|
|
25
|
+
DEFAULT_HEIGHT_MM = 120.0
|
|
26
|
+
MM_PER_INCH = 25.4
|
|
27
|
+
PALETTE = ["#2166AC", "#B2182B", "#1B7837", "#F1A340", "#762A83", "#666666"]
|
|
28
|
+
DEPENDENCY_ERROR: Exception | None = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import numpy as np
|
|
32
|
+
import matplotlib as mpl
|
|
33
|
+
|
|
34
|
+
mpl.use("Agg")
|
|
35
|
+
import matplotlib.pyplot as plt
|
|
36
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
37
|
+
from matplotlib.lines import Line2D
|
|
38
|
+
except ImportError as exc: # Keep --help usable in a minimal environment.
|
|
39
|
+
DEPENDENCY_ERROR = exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_dependencies() -> None:
|
|
43
|
+
if DEPENDENCY_ERROR is not None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"plot_templates.py requires numpy and matplotlib in the selected Python environment"
|
|
46
|
+
) from DEPENDENCY_ERROR
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configure_style() -> None:
|
|
50
|
+
mpl.rcParams.update(
|
|
51
|
+
{
|
|
52
|
+
"font.family": "sans-serif",
|
|
53
|
+
"font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans", "Liberation Sans"],
|
|
54
|
+
"font.size": 7,
|
|
55
|
+
"axes.labelsize": 7,
|
|
56
|
+
"axes.titlesize": 8,
|
|
57
|
+
"xtick.labelsize": 6,
|
|
58
|
+
"ytick.labelsize": 6,
|
|
59
|
+
"legend.fontsize": 6,
|
|
60
|
+
"axes.spines.top": False,
|
|
61
|
+
"axes.spines.right": False,
|
|
62
|
+
"axes.linewidth": 0.6,
|
|
63
|
+
"xtick.direction": "out",
|
|
64
|
+
"ytick.direction": "out",
|
|
65
|
+
"xtick.major.width": 0.6,
|
|
66
|
+
"ytick.major.width": 0.6,
|
|
67
|
+
"legend.frameon": False,
|
|
68
|
+
"svg.fonttype": "none",
|
|
69
|
+
"pdf.fonttype": 42,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_prefix(raw: Path) -> Path:
|
|
75
|
+
if raw.suffix.lower() in {".svg", ".pdf", ".tif", ".tiff", ".png"}:
|
|
76
|
+
raw = raw.with_suffix("")
|
|
77
|
+
raw.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
return raw
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_bundle(fig: Any, output: Path) -> dict[str, str]:
|
|
82
|
+
output = normalize_prefix(output)
|
|
83
|
+
paths = {
|
|
84
|
+
"svg": str(output.with_suffix(".svg")),
|
|
85
|
+
"pdf": str(output.with_suffix(".pdf")),
|
|
86
|
+
"tiff": str(output.with_suffix(".tiff")),
|
|
87
|
+
}
|
|
88
|
+
fig.savefig(paths["svg"], bbox_inches="tight", facecolor="white")
|
|
89
|
+
fig.savefig(paths["pdf"], bbox_inches="tight", facecolor="white")
|
|
90
|
+
fig.savefig(paths["tiff"], dpi=600, bbox_inches="tight", facecolor="white")
|
|
91
|
+
plt.close(fig)
|
|
92
|
+
return paths
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_qa(output: Path, qa: dict[str, Any]) -> Path:
|
|
96
|
+
path = normalize_prefix(output).with_suffix(".qa.json")
|
|
97
|
+
path.write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_csv_rows(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
|
|
102
|
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
|
103
|
+
reader = csv.DictReader(handle)
|
|
104
|
+
if reader.fieldnames is None:
|
|
105
|
+
raise ValueError(f"CSV has no header: {path}")
|
|
106
|
+
rows = [dict(row) for row in reader]
|
|
107
|
+
return rows, list(reader.fieldnames)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def require_columns(fieldnames: Iterable[str], required: Iterable[str]) -> None:
|
|
111
|
+
available = set(fieldnames)
|
|
112
|
+
missing = [name for name in required if name not in available]
|
|
113
|
+
if missing:
|
|
114
|
+
raise ValueError(f"missing required CSV columns: {', '.join(missing)}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def coerce_numeric_rows(
|
|
118
|
+
rows: list[dict[str, Any]],
|
|
119
|
+
columns: Iterable[str],
|
|
120
|
+
drop_incomplete: bool,
|
|
121
|
+
) -> tuple[list[dict[str, Any]], list[int]]:
|
|
122
|
+
columns = list(columns)
|
|
123
|
+
valid: list[dict[str, Any]] = []
|
|
124
|
+
invalid: list[int] = []
|
|
125
|
+
for row_number, row in enumerate(rows, start=2):
|
|
126
|
+
converted = dict(row)
|
|
127
|
+
try:
|
|
128
|
+
for column in columns:
|
|
129
|
+
value = float(row[column])
|
|
130
|
+
if not math.isfinite(value):
|
|
131
|
+
raise ValueError
|
|
132
|
+
converted[column] = value
|
|
133
|
+
except (KeyError, TypeError, ValueError):
|
|
134
|
+
invalid.append(row_number)
|
|
135
|
+
continue
|
|
136
|
+
valid.append(converted)
|
|
137
|
+
if invalid and not drop_incomplete:
|
|
138
|
+
shown = ", ".join(str(value) for value in invalid[:10])
|
|
139
|
+
suffix = " ..." if len(invalid) > 10 else ""
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"non-finite or missing numeric values at CSV rows {shown}{suffix}; "
|
|
142
|
+
"fix the data or rerun with explicit --drop-incomplete"
|
|
143
|
+
)
|
|
144
|
+
if not valid:
|
|
145
|
+
raise ValueError("no valid observations remain")
|
|
146
|
+
return valid, invalid
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def first_seen(values: Iterable[str]) -> list[str]:
|
|
150
|
+
result: list[str] = []
|
|
151
|
+
seen: set[str] = set()
|
|
152
|
+
for value in values:
|
|
153
|
+
if value not in seen:
|
|
154
|
+
seen.add(value)
|
|
155
|
+
result.append(value)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_order(raw: str | None, observed: Iterable[str], label: str) -> list[str]:
|
|
160
|
+
observed_order = first_seen(str(value) for value in observed)
|
|
161
|
+
if not raw:
|
|
162
|
+
return observed_order
|
|
163
|
+
requested = [value.strip() for value in raw.split(",") if value.strip()]
|
|
164
|
+
missing = [value for value in observed_order if value not in requested]
|
|
165
|
+
extra = [value for value in requested if value not in observed_order]
|
|
166
|
+
if missing or extra:
|
|
167
|
+
details = []
|
|
168
|
+
if missing:
|
|
169
|
+
details.append(f"missing observed {label}: {missing}")
|
|
170
|
+
if extra:
|
|
171
|
+
details.append(f"unknown {label}: {extra}")
|
|
172
|
+
raise ValueError("; ".join(details))
|
|
173
|
+
return requested
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def nice_reference_values(maximum: float) -> list[float]:
|
|
177
|
+
if maximum <= 0:
|
|
178
|
+
return []
|
|
179
|
+
magnitude = 10 ** math.floor(math.log10(maximum))
|
|
180
|
+
quantum = magnitude / 2
|
|
181
|
+
values = [round((maximum * fraction) / quantum) * quantum for fraction in (0.25, 0.5, 1.0)]
|
|
182
|
+
return sorted({value for value in values if value > 0})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def base_qa(args: argparse.Namespace, kind: str, rows_input: int) -> dict[str, Any]:
|
|
186
|
+
return {
|
|
187
|
+
"template": kind,
|
|
188
|
+
"backend": "python",
|
|
189
|
+
"demo": bool(args.demo),
|
|
190
|
+
"input": "<explicit-demo-data>" if args.demo else args.input.name,
|
|
191
|
+
"output_prefix": normalize_prefix(args.output).name,
|
|
192
|
+
"rows_input": rows_input,
|
|
193
|
+
"rows_plotted": rows_input,
|
|
194
|
+
"excluded_rows": 0,
|
|
195
|
+
"excluded_entities": 0,
|
|
196
|
+
"mapping": {},
|
|
197
|
+
"parameters": {},
|
|
198
|
+
"notes": [
|
|
199
|
+
"All supplied observations are used unless excluded through explicit --drop-incomplete.",
|
|
200
|
+
"The QA record stores input basenames rather than private absolute paths.",
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_rows(args: argparse.Namespace, kind: str) -> tuple[list[dict[str, Any]], list[str]]:
|
|
206
|
+
if args.demo:
|
|
207
|
+
rows = demo_rows(kind)
|
|
208
|
+
return rows, list(rows[0])
|
|
209
|
+
if args.input is None:
|
|
210
|
+
raise ValueError("production runs require --input CSV; use --demo only for an explicit example")
|
|
211
|
+
return read_csv_rows(args.input)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def demo_rows(kind: str) -> list[dict[str, Any]]:
|
|
215
|
+
rng = np.random.default_rng(20260715)
|
|
216
|
+
if kind == "volcano":
|
|
217
|
+
rows = []
|
|
218
|
+
for index in range(1200):
|
|
219
|
+
effect = float(rng.normal(0, 0.65))
|
|
220
|
+
pvalue = float(rng.uniform(0.02, 1.0))
|
|
221
|
+
if index < 55:
|
|
222
|
+
effect = float(rng.choice([-1, 1]) * rng.uniform(1.2, 3.2))
|
|
223
|
+
pvalue = float(10 ** (-rng.uniform(2, 9)))
|
|
224
|
+
rows.append({"gene": f"Gene_{index + 1}", "log2fc": effect, "padj": pvalue})
|
|
225
|
+
return rows
|
|
226
|
+
if kind == "roc":
|
|
227
|
+
rows = []
|
|
228
|
+
for fpr in np.linspace(0, 1, 101):
|
|
229
|
+
rows.append(
|
|
230
|
+
{
|
|
231
|
+
"fpr": float(fpr),
|
|
232
|
+
"model_a": float(np.clip(fpr ** 0.42, 0, 1)),
|
|
233
|
+
"model_b": float(np.clip(fpr ** 0.58, 0, 1)),
|
|
234
|
+
"model_c": float(np.clip(fpr ** 0.72, 0, 1)),
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
return rows
|
|
238
|
+
if kind == "dotplot":
|
|
239
|
+
rows = []
|
|
240
|
+
row_names = ["VCT", "EVT", "SCT", "FB", "T", "dNK"]
|
|
241
|
+
column_names = ["TP63", "HLA-G", "ERVW-1", "VIM", "ACTA2", "CD3D", "GNLY", "NKG7"]
|
|
242
|
+
for row_index, row_name in enumerate(row_names):
|
|
243
|
+
for col_index, column_name in enumerate(column_names):
|
|
244
|
+
distance = abs((row_index * 1.3) - (col_index * 0.7))
|
|
245
|
+
rows.append(
|
|
246
|
+
{
|
|
247
|
+
"cell_type": row_name,
|
|
248
|
+
"gene": column_name,
|
|
249
|
+
"pct_exp": float(np.clip(70 - 14 * distance + rng.normal(0, 5), 1, 90)),
|
|
250
|
+
"avg_exp_scaled": float(np.clip(1 - distance / 6 + rng.normal(0, 0.08), 0, 1)),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
return rows
|
|
254
|
+
if kind == "marginal":
|
|
255
|
+
rows = []
|
|
256
|
+
for group_index, group in enumerate(("Control", "Treatment A", "Treatment B")):
|
|
257
|
+
x = rng.normal(group_index * 0.7, 0.85, 220)
|
|
258
|
+
y = 0.65 * x + rng.normal(group_index * 0.35, 0.65, 220)
|
|
259
|
+
rows.extend({"x": float(a), "y": float(b), "group": group} for a, b in zip(x, y))
|
|
260
|
+
return rows
|
|
261
|
+
if kind == "paired":
|
|
262
|
+
rows = []
|
|
263
|
+
for index in range(28):
|
|
264
|
+
before = float(rng.normal(1.0, 0.18))
|
|
265
|
+
after = float(before + rng.normal(0.20, 0.10))
|
|
266
|
+
rows.extend(
|
|
267
|
+
[
|
|
268
|
+
{"subject": f"S{index + 1:02d}", "condition": "Before", "value": before},
|
|
269
|
+
{"subject": f"S{index + 1:02d}", "condition": "After", "value": after},
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
return rows
|
|
273
|
+
raise ValueError(f"unknown demo kind: {kind}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def plot_volcano(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
|
|
277
|
+
rows, fields = load_rows(args, "volcano")
|
|
278
|
+
required = [args.gene_col, args.effect_col, args.p_col]
|
|
279
|
+
require_columns(fields, required)
|
|
280
|
+
rows, invalid = coerce_numeric_rows(rows, [args.effect_col, args.p_col], args.drop_incomplete)
|
|
281
|
+
bad_p = [index for index, row in enumerate(rows, start=1) if not 0 < row[args.p_col] <= 1]
|
|
282
|
+
if bad_p:
|
|
283
|
+
raise ValueError("adjusted p values must be strictly positive and no greater than 1")
|
|
284
|
+
|
|
285
|
+
effects = np.asarray([row[args.effect_col] for row in rows], dtype=float)
|
|
286
|
+
pvalues = np.asarray([row[args.p_col] for row in rows], dtype=float)
|
|
287
|
+
genes = [str(row[args.gene_col]) for row in rows]
|
|
288
|
+
scores = -np.log10(pvalues)
|
|
289
|
+
up = (pvalues < args.p_threshold) & (effects >= args.effect_threshold)
|
|
290
|
+
down = (pvalues < args.p_threshold) & (effects <= -args.effect_threshold)
|
|
291
|
+
neutral = ~(up | down)
|
|
292
|
+
|
|
293
|
+
fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
|
|
294
|
+
rasterized = len(rows) > 50000
|
|
295
|
+
ax.scatter(effects[neutral], scores[neutral], s=5, color="#B3B3B3", alpha=0.45, edgecolors="none", rasterized=rasterized, label=f"Not significant ({neutral.sum()})")
|
|
296
|
+
ax.scatter(effects[down], scores[down], s=7, color="#2166AC", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Down ({down.sum()})")
|
|
297
|
+
ax.scatter(effects[up], scores[up], s=7, color="#B2182B", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Up ({up.sum()})")
|
|
298
|
+
ax.axhline(-math.log10(args.p_threshold), color="#666666", linestyle="--", linewidth=0.6)
|
|
299
|
+
ax.axvline(-args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
|
|
300
|
+
ax.axvline(args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
|
|
301
|
+
significant = np.flatnonzero(up | down)
|
|
302
|
+
if args.top_labels > 0 and len(significant):
|
|
303
|
+
selected = significant[np.argsort(pvalues[significant])[: args.top_labels]]
|
|
304
|
+
for is_positive in (False, True):
|
|
305
|
+
side = [index for index in selected if (effects[index] >= 0) == is_positive]
|
|
306
|
+
last_label_y = -math.inf
|
|
307
|
+
for index in sorted(side, key=lambda value: scores[value]):
|
|
308
|
+
label_y = max(float(scores[index]) + 0.12, last_label_y + 0.30)
|
|
309
|
+
last_label_y = label_y
|
|
310
|
+
direction = 1 if is_positive else -1
|
|
311
|
+
ax.annotate(
|
|
312
|
+
genes[index],
|
|
313
|
+
(effects[index], scores[index]),
|
|
314
|
+
xytext=(effects[index] + 0.07 * direction, label_y),
|
|
315
|
+
textcoords="data",
|
|
316
|
+
ha="left" if is_positive else "right",
|
|
317
|
+
va="bottom",
|
|
318
|
+
fontsize=5,
|
|
319
|
+
color="#333333",
|
|
320
|
+
arrowprops={"arrowstyle": "-", "color": "#777777", "linewidth": 0.3},
|
|
321
|
+
)
|
|
322
|
+
ax.set_xlabel(args.effect_label)
|
|
323
|
+
# Keep the subscript as Unicode so the rendered glyph does not shrink below
|
|
324
|
+
# the journal's 5 pt floor when the surrounding label is already compact.
|
|
325
|
+
ax.set_ylabel("−log₁₀(adjusted p value)")
|
|
326
|
+
if args.title:
|
|
327
|
+
ax.set_title(args.title)
|
|
328
|
+
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.19), ncol=3)
|
|
329
|
+
paths = save_bundle(fig, args.output)
|
|
330
|
+
|
|
331
|
+
qa = base_qa(args, "volcano", len(rows) + len(invalid))
|
|
332
|
+
qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
|
|
333
|
+
qa["mapping"] = {"gene": args.gene_col, "effect": args.effect_col, "adjusted_p": args.p_col}
|
|
334
|
+
qa["parameters"] = {"p_threshold": args.p_threshold, "effect_threshold": args.effect_threshold, "top_labels": args.top_labels, "rasterized_marks": rasterized, "category_counts": {"up": int(up.sum()), "down": int(down.sum()), "neutral": int(neutral.sum())}}
|
|
335
|
+
return paths, qa
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def plot_roc(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
|
|
339
|
+
rows, fields = load_rows(args, "roc")
|
|
340
|
+
tpr_columns = [value.strip() for value in args.tpr_cols.split(",") if value.strip()] if args.tpr_cols else [field for field in fields if field != args.fpr_col]
|
|
341
|
+
if not tpr_columns:
|
|
342
|
+
raise ValueError("no TPR columns selected")
|
|
343
|
+
require_columns(fields, [args.fpr_col, *tpr_columns])
|
|
344
|
+
rows, invalid = coerce_numeric_rows(rows, [args.fpr_col, *tpr_columns], args.drop_incomplete)
|
|
345
|
+
for row in rows:
|
|
346
|
+
values = [row[args.fpr_col], *(row[column] for column in tpr_columns)]
|
|
347
|
+
if any(value < 0 or value > 1 for value in values):
|
|
348
|
+
raise ValueError("FPR and TPR values must stay within [0, 1]")
|
|
349
|
+
|
|
350
|
+
fpr = np.asarray([row[args.fpr_col] for row in rows], dtype=float)
|
|
351
|
+
order = np.argsort(fpr, kind="stable")
|
|
352
|
+
sorted_input = bool(np.all(order == np.arange(len(fpr))))
|
|
353
|
+
fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
|
|
354
|
+
aucs: dict[str, float] = {}
|
|
355
|
+
for index, column in enumerate(tpr_columns):
|
|
356
|
+
tpr = np.asarray([row[column] for row in rows], dtype=float)[order]
|
|
357
|
+
auc = float(np.trapezoid(tpr, fpr[order]))
|
|
358
|
+
aucs[column] = auc
|
|
359
|
+
ax.plot(fpr[order], tpr, color=PALETTE[index % len(PALETTE)], linewidth=1.3, label=f"{column} (AUC={auc:.3f})")
|
|
360
|
+
ax.plot([0, 1], [0, 1], color="#888888", linestyle="--", linewidth=0.7, label="Chance")
|
|
361
|
+
ax.set(xlim=(0, 1), ylim=(0, 1), xlabel="False-positive rate", ylabel="True-positive rate")
|
|
362
|
+
ax.set_aspect("equal", adjustable="box")
|
|
363
|
+
if args.title:
|
|
364
|
+
ax.set_title(args.title)
|
|
365
|
+
ax.legend(loc="lower right")
|
|
366
|
+
paths = save_bundle(fig, args.output)
|
|
367
|
+
|
|
368
|
+
qa = base_qa(args, "roc", len(rows) + len(invalid))
|
|
369
|
+
qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
|
|
370
|
+
qa["mapping"] = {"fpr": args.fpr_col, "tpr_series": tpr_columns}
|
|
371
|
+
qa["parameters"] = {"auc_method": "trapezoidal", "auc": aucs, "input_already_sorted_by_fpr": sorted_input}
|
|
372
|
+
return paths, qa
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def plot_dotplot(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
|
|
376
|
+
rows, fields = load_rows(args, "dotplot")
|
|
377
|
+
required = [args.row_col, args.column_col, args.size_col, args.color_col]
|
|
378
|
+
require_columns(fields, required)
|
|
379
|
+
rows, invalid = coerce_numeric_rows(rows, [args.size_col, args.color_col], args.drop_incomplete)
|
|
380
|
+
if any(row[args.size_col] < 0 for row in rows):
|
|
381
|
+
raise ValueError("dot size values must be non-negative")
|
|
382
|
+
row_order = parse_order(args.row_order, (str(row[args.row_col]) for row in rows), "row categories")
|
|
383
|
+
column_order = parse_order(args.column_order, (str(row[args.column_col]) for row in rows), "column categories")
|
|
384
|
+
row_map = {value: index for index, value in enumerate(row_order)}
|
|
385
|
+
column_map = {value: index for index, value in enumerate(column_order)}
|
|
386
|
+
|
|
387
|
+
size_values = np.asarray([row[args.size_col] for row in rows], dtype=float)
|
|
388
|
+
color_values = np.asarray([row[args.color_col] for row in rows], dtype=float)
|
|
389
|
+
size_max = float(size_values.max())
|
|
390
|
+
sizes = np.full_like(size_values, 30.0) if size_max == 0 else 12 + 160 * np.sqrt(size_values / size_max)
|
|
391
|
+
color_min = float(color_values.min())
|
|
392
|
+
color_max = float(color_values.max())
|
|
393
|
+
color_norm = np.full_like(color_values, 0.5) if color_max == color_min else (color_values - color_min) / (color_max - color_min)
|
|
394
|
+
xs = np.asarray([column_map[str(row[args.column_col])] for row in rows])
|
|
395
|
+
ys = np.asarray([row_map[str(row[args.row_col])] for row in rows])
|
|
396
|
+
cmap = LinearSegmentedColormap.from_list("nature_expression", ["#2166AC", "#F7F7F7", "#B2182B"])
|
|
397
|
+
|
|
398
|
+
fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
|
|
399
|
+
scatter = ax.scatter(xs, ys, s=sizes, c=color_norm, cmap=cmap, vmin=0, vmax=1, edgecolors="#666666", linewidths=0.35)
|
|
400
|
+
ax.set_xticks(
|
|
401
|
+
range(len(column_order)),
|
|
402
|
+
column_order,
|
|
403
|
+
rotation=90,
|
|
404
|
+
rotation_mode="anchor",
|
|
405
|
+
ha="right",
|
|
406
|
+
)
|
|
407
|
+
ax.set_yticks(range(len(row_order)), row_order)
|
|
408
|
+
ax.set_xlim(-0.7, len(column_order) - 0.3)
|
|
409
|
+
ax.set_ylim(-0.7, len(row_order) - 0.3)
|
|
410
|
+
ax.invert_yaxis()
|
|
411
|
+
ax.spines["top"].set_visible(True)
|
|
412
|
+
ax.spines["right"].set_visible(True)
|
|
413
|
+
ax.grid(color="#E6E6E6", linewidth=0.35)
|
|
414
|
+
ax.set_axisbelow(True)
|
|
415
|
+
colorbar = fig.colorbar(scatter, ax=ax, fraction=0.025, pad=0.02)
|
|
416
|
+
colorbar.set_label(args.color_label)
|
|
417
|
+
reference_values = nice_reference_values(size_max)
|
|
418
|
+
handles = [Line2D([], [], marker="o", linestyle="", markerfacecolor="#999999", markeredgecolor="#666666", markersize=math.sqrt(12 + 160 * math.sqrt(value / size_max))) for value in reference_values]
|
|
419
|
+
if handles:
|
|
420
|
+
ax.legend(handles, [f"{value:g}" for value in reference_values], title=args.size_label, bbox_to_anchor=(1.18, 1), loc="upper left")
|
|
421
|
+
if args.title:
|
|
422
|
+
ax.set_title(args.title)
|
|
423
|
+
paths = save_bundle(fig, args.output)
|
|
424
|
+
|
|
425
|
+
qa = base_qa(args, "dotplot", len(rows) + len(invalid))
|
|
426
|
+
qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
|
|
427
|
+
qa["mapping"] = {"row_category": args.row_col, "column_category": args.column_col, "dot_size": args.size_col, "dot_color": args.color_col}
|
|
428
|
+
qa["parameters"] = {"row_order": row_order, "column_order": column_order, "size_range": [float(size_values.min()), size_max], "color_range": [color_min, color_max]}
|
|
429
|
+
return paths, qa
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def plot_marginal(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
|
|
433
|
+
rows, fields = load_rows(args, "marginal")
|
|
434
|
+
required = [args.x_col, args.y_col] + ([args.group_col] if args.group_col else [])
|
|
435
|
+
require_columns(fields, required)
|
|
436
|
+
rows, invalid = coerce_numeric_rows(rows, [args.x_col, args.y_col], args.drop_incomplete)
|
|
437
|
+
groups = [str(row[args.group_col]) if args.group_col else "All observations" for row in rows]
|
|
438
|
+
group_order = parse_order(args.group_order, groups, "groups")
|
|
439
|
+
|
|
440
|
+
fig = plt.figure(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
|
|
441
|
+
grid = fig.add_gridspec(4, 4, hspace=0.05, wspace=0.05)
|
|
442
|
+
ax_joint = fig.add_subplot(grid[1:, :3])
|
|
443
|
+
ax_top = fig.add_subplot(grid[0, :3], sharex=ax_joint)
|
|
444
|
+
ax_right = fig.add_subplot(grid[1:, 3], sharey=ax_joint)
|
|
445
|
+
counts: dict[str, int] = {}
|
|
446
|
+
for index, group in enumerate(group_order):
|
|
447
|
+
mask = np.asarray([value == group for value in groups])
|
|
448
|
+
x = np.asarray([row[args.x_col] for row in rows], dtype=float)[mask]
|
|
449
|
+
y = np.asarray([row[args.y_col] for row in rows], dtype=float)[mask]
|
|
450
|
+
counts[group] = len(x)
|
|
451
|
+
color = PALETTE[index % len(PALETTE)]
|
|
452
|
+
ax_joint.scatter(x, y, s=8, alpha=0.42, color=color, edgecolors="none", rasterized=len(rows) > 50000, label=f"{group} (n={len(x)})")
|
|
453
|
+
ax_top.hist(x, bins=args.bins, density=True, histtype="stepfilled", alpha=0.20, color=color)
|
|
454
|
+
ax_top.hist(x, bins=args.bins, density=True, histtype="step", linewidth=0.8, color=color)
|
|
455
|
+
ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="stepfilled", alpha=0.20, color=color)
|
|
456
|
+
ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="step", linewidth=0.8, color=color)
|
|
457
|
+
ax_joint.set_xlabel(args.x_label or args.x_col)
|
|
458
|
+
ax_joint.set_ylabel(args.y_label or args.y_col)
|
|
459
|
+
ax_joint.legend(loc="best")
|
|
460
|
+
ax_top.tick_params(labelbottom=False)
|
|
461
|
+
ax_right.tick_params(labelleft=False)
|
|
462
|
+
ax_top.set_ylabel("Density")
|
|
463
|
+
ax_right.set_xlabel("Density")
|
|
464
|
+
if args.title:
|
|
465
|
+
ax_top.set_title(args.title)
|
|
466
|
+
paths = save_bundle(fig, args.output)
|
|
467
|
+
|
|
468
|
+
qa = base_qa(args, "marginal", len(rows) + len(invalid))
|
|
469
|
+
qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
|
|
470
|
+
qa["mapping"] = {"x": args.x_col, "y": args.y_col, "group": args.group_col}
|
|
471
|
+
qa["parameters"] = {"group_order": group_order, "group_counts": counts, "histogram_bins": args.bins, "rasterized_marks": len(rows) > 50000}
|
|
472
|
+
return paths, qa
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def plot_paired(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
|
|
476
|
+
rows, fields = load_rows(args, "paired")
|
|
477
|
+
required = [args.id_col, args.condition_col, args.value_col]
|
|
478
|
+
require_columns(fields, required)
|
|
479
|
+
rows, invalid = coerce_numeric_rows(rows, [args.value_col], args.drop_incomplete)
|
|
480
|
+
condition_order = parse_order(args.condition_order, (str(row[args.condition_col]) for row in rows), "conditions")
|
|
481
|
+
if len(condition_order) != 2:
|
|
482
|
+
raise ValueError(f"paired template requires exactly two conditions, found {len(condition_order)}")
|
|
483
|
+
|
|
484
|
+
subjects: dict[str, dict[str, list[float]]] = {}
|
|
485
|
+
for row in rows:
|
|
486
|
+
subject = str(row[args.id_col])
|
|
487
|
+
condition = str(row[args.condition_col])
|
|
488
|
+
subjects.setdefault(subject, {}).setdefault(condition, []).append(float(row[args.value_col]))
|
|
489
|
+
duplicates = [subject for subject, values in subjects.items() if any(len(values.get(condition, [])) > 1 for condition in condition_order)]
|
|
490
|
+
if duplicates:
|
|
491
|
+
raise ValueError("multiple values per subject-condition pair found; aggregate only with an explicit scientific rule before plotting")
|
|
492
|
+
incomplete = [subject for subject, values in subjects.items() if any(condition not in values for condition in condition_order)]
|
|
493
|
+
if incomplete and not args.drop_incomplete:
|
|
494
|
+
raise ValueError(f"{len(incomplete)} incomplete subject pairs found; fix the data or rerun with explicit --drop-incomplete")
|
|
495
|
+
complete = [subject for subject in subjects if subject not in incomplete]
|
|
496
|
+
if not complete:
|
|
497
|
+
raise ValueError("no complete subject pairs remain")
|
|
498
|
+
values_a = np.asarray([subjects[subject][condition_order[0]][0] for subject in complete])
|
|
499
|
+
values_b = np.asarray([subjects[subject][condition_order[1]][0] for subject in complete])
|
|
500
|
+
|
|
501
|
+
fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
|
|
502
|
+
for value_a, value_b in zip(values_a, values_b):
|
|
503
|
+
ax.plot([0, 1], [value_a, value_b], color="#B0B0B0", linewidth=0.55, alpha=0.65, zorder=1)
|
|
504
|
+
boxes = ax.boxplot([values_a, values_b], positions=[0, 1], widths=0.34, patch_artist=True, showfliers=False, medianprops={"color": "#222222", "linewidth": 1.0}, whiskerprops={"linewidth": 0.7}, capprops={"linewidth": 0.7})
|
|
505
|
+
for patch, color in zip(boxes["boxes"], ("#9ECAE1", "#FC9272")):
|
|
506
|
+
patch.set(facecolor=color, edgecolor="#555555", alpha=0.55, linewidth=0.7)
|
|
507
|
+
ax.scatter(np.zeros_like(values_a), values_a, s=13, color="#2166AC", edgecolors="white", linewidths=0.35, zorder=3)
|
|
508
|
+
ax.scatter(np.ones_like(values_b), values_b, s=13, color="#B2182B", edgecolors="white", linewidths=0.35, zorder=3)
|
|
509
|
+
ax.set_xticks([0, 1], condition_order)
|
|
510
|
+
ax.set_ylabel(args.value_label or args.value_col)
|
|
511
|
+
if args.title:
|
|
512
|
+
ax.set_title(args.title)
|
|
513
|
+
paths = save_bundle(fig, args.output)
|
|
514
|
+
|
|
515
|
+
qa = base_qa(args, "paired", len(rows) + len(invalid))
|
|
516
|
+
qa.update({"rows_plotted": len(complete) * 2, "excluded_rows": len(invalid), "excluded_entities": len(incomplete)})
|
|
517
|
+
qa["mapping"] = {"pair_id": args.id_col, "condition": args.condition_col, "value": args.value_col}
|
|
518
|
+
qa["parameters"] = {"condition_order": condition_order, "complete_pairs": len(complete), "incomplete_pairs_excluded": len(incomplete), "statistical_test": None}
|
|
519
|
+
qa["notes"].append("No inferential test is computed; add one only after choosing a justified paired analysis.")
|
|
520
|
+
return paths, qa
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def add_common_arguments(parser: argparse.ArgumentParser, width_mm: float, height_mm: float) -> None:
|
|
524
|
+
source = parser.add_mutually_exclusive_group()
|
|
525
|
+
source.add_argument("--input", type=Path, help="production CSV input")
|
|
526
|
+
source.add_argument("--demo", action="store_true", help="render explicit deterministic demo data")
|
|
527
|
+
parser.add_argument("--output", type=Path, required=True, help="output prefix for SVG/PDF/TIFF/QA JSON")
|
|
528
|
+
parser.add_argument("--width-mm", type=float, default=width_mm)
|
|
529
|
+
parser.add_argument("--height-mm", type=float, default=height_mm)
|
|
530
|
+
parser.add_argument("--title")
|
|
531
|
+
parser.add_argument("--drop-incomplete", action="store_true", help="explicitly exclude rows with missing/non-finite required values and record counts")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
535
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
536
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
537
|
+
|
|
538
|
+
volcano = subparsers.add_parser("volcano", help="effect size versus adjusted p value")
|
|
539
|
+
add_common_arguments(volcano, 89.0, 82.0)
|
|
540
|
+
volcano.add_argument("--gene-col", default="gene")
|
|
541
|
+
volcano.add_argument("--effect-col", default="log2fc")
|
|
542
|
+
volcano.add_argument("--p-col", default="padj")
|
|
543
|
+
volcano.add_argument("--effect-label", default="log₂(fold change)")
|
|
544
|
+
volcano.add_argument("--p-threshold", type=float, default=0.05)
|
|
545
|
+
volcano.add_argument("--effect-threshold", type=float, default=1.0)
|
|
546
|
+
volcano.add_argument("--top-labels", type=int, default=10)
|
|
547
|
+
volcano.set_defaults(plotter=plot_volcano)
|
|
548
|
+
|
|
549
|
+
roc = subparsers.add_parser("roc", help="one FPR column and one or more TPR columns")
|
|
550
|
+
add_common_arguments(roc, 89.0, 89.0)
|
|
551
|
+
roc.add_argument("--fpr-col", default="fpr")
|
|
552
|
+
roc.add_argument("--tpr-cols", help="comma-separated TPR columns; defaults to every non-FPR column")
|
|
553
|
+
roc.set_defaults(plotter=plot_roc)
|
|
554
|
+
|
|
555
|
+
dotplot = subparsers.add_parser("dotplot", help="marker-gene or other size/color matrix dot plot")
|
|
556
|
+
add_common_arguments(dotplot, 183.0, 105.0)
|
|
557
|
+
dotplot.add_argument("--row-col", default="cell_type")
|
|
558
|
+
dotplot.add_argument("--column-col", default="gene")
|
|
559
|
+
dotplot.add_argument("--size-col", default="pct_exp")
|
|
560
|
+
dotplot.add_argument("--color-col", default="avg_exp_scaled")
|
|
561
|
+
dotplot.add_argument("--row-order", help="comma-separated complete row order")
|
|
562
|
+
dotplot.add_argument("--column-order", help="comma-separated complete column order")
|
|
563
|
+
dotplot.add_argument("--size-label", default="Fraction (%)")
|
|
564
|
+
dotplot.add_argument("--color-label", default="Mean expression")
|
|
565
|
+
dotplot.set_defaults(plotter=plot_dotplot)
|
|
566
|
+
|
|
567
|
+
marginal = subparsers.add_parser("marginal", help="2D scatter with marginal distributions")
|
|
568
|
+
add_common_arguments(marginal, 120.0, 105.0)
|
|
569
|
+
marginal.add_argument("--x-col", default="x")
|
|
570
|
+
marginal.add_argument("--y-col", default="y")
|
|
571
|
+
marginal.add_argument("--group-col", default="group")
|
|
572
|
+
marginal.add_argument("--group-order", help="comma-separated complete group order")
|
|
573
|
+
marginal.add_argument("--x-label")
|
|
574
|
+
marginal.add_argument("--y-label")
|
|
575
|
+
marginal.add_argument("--bins", type=int, default=24)
|
|
576
|
+
marginal.set_defaults(plotter=plot_marginal)
|
|
577
|
+
|
|
578
|
+
paired = subparsers.add_parser("paired", help="paired box-and-point plot for exactly two conditions")
|
|
579
|
+
add_common_arguments(paired, 89.0, 86.0)
|
|
580
|
+
paired.add_argument("--id-col", default="subject")
|
|
581
|
+
paired.add_argument("--condition-col", default="condition")
|
|
582
|
+
paired.add_argument("--value-col", default="value")
|
|
583
|
+
paired.add_argument("--condition-order", help="comma-separated two-condition order")
|
|
584
|
+
paired.add_argument("--value-label")
|
|
585
|
+
paired.set_defaults(plotter=plot_paired)
|
|
586
|
+
return parser
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def main(argv: list[str] | None = None) -> int:
|
|
590
|
+
args = build_parser().parse_args(argv)
|
|
591
|
+
try:
|
|
592
|
+
require_dependencies()
|
|
593
|
+
configure_style()
|
|
594
|
+
paths, qa = args.plotter(args)
|
|
595
|
+
qa_path = write_qa(args.output, qa)
|
|
596
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
597
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
598
|
+
return 2
|
|
599
|
+
print(json.dumps({"outputs": paths, "qa": str(qa_path)}, indent=2, ensure_ascii=False))
|
|
600
|
+
return 0
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
if __name__ == "__main__":
|
|
604
|
+
raise SystemExit(main())
|