Files
SimurgArray/src/simurg/core/errors.py
T
kemal 63d2cc56c8 PGx CN: scan-date batch warning (PCN-006), CN-consistent BAF evidence (PCN-010, --bafs-in-cn),
noise-outlier exclusion + robustness test (PCN-017/EDGE-026), platform from the CSV manifest (CLI-032)

Also: star-allele calls are checked for one manifest/build per run (PGX-011), duplications at the CN cap are
named xN (PGX-022), and tandem names attach supporting variants to their full copy.
2026-09-29 17:11:34 +01:00

259 lines
16 KiB
Python

"""Stable message catalogue and exception types (spec CLI-014, CLI-016).
Every error or warning SimurgArray emits has a stable ``code``. The human-readable text may change between
versions; the code may not. ``docs/messages.md`` is generated from :data:`CATALOGUE`
(``python -m simurg.core.errors``).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from typing import Literal
Level = Literal["error", "warning", "info"]
class ExitCode(IntEnum):
"""Process exit codes (spec CLI-014)."""
OK = 0
INTERNAL = 1
USAGE = 2 # usage or pre-flight validation error; no per-sample work started
PARTIAL = 3 # >= 1 sample failed and >= 1 succeeded
ALL_FAILED = 4 # run completed but no sample succeeded
SIGINT = 130
SIGPIPE = 141 # the reader of stdout went away (e.g. `simurg inspect … | head`); nothing is reported
SIGTERM = 143
@dataclass(frozen=True)
class MessageDef:
code: str
level: Level
summary: str
spec: str
def _m(code: str, level: Level, summary: str, spec: str) -> tuple[str, MessageDef]:
return code, MessageDef(code, level, summary, spec)
CATALOGUE: dict[str, MessageDef] = dict(
[
# --- CLI / run control -------------------------------------------------------------------
_m("E_NO_INPUT", "error", "Neither an input folder nor a sample sheet was given", "CLI-006"),
_m("E_NO_INPUT_FOLDER", "error", "Sample sheet has no RootFolder and no folder was given", "CLI-006"),
_m("E_OUTPUT_EXISTS", "error", "Outputs already exist; use --force or --resume", "CLI-010"),
_m("E_CONFIG_KEY", "error", "Unknown key in --config YAML", "CLI-019"),
_m("E_CONFIG_PARSE", "error", "--config file is not valid YAML", "CLI-019"),
_m("E_FILE_NOT_FOUND", "error", "Input file or folder does not exist", "CLI-016"),
_m("E_FILE_UNREADABLE", "error", "Input file cannot be read", "CLI-016"),
_m("E_INTERNAL", "error", "Internal error (bug); rerun with --debug and report", "CLI-014"),
# --- sample sheet (IN-001 .. IN-017) -------------------------------------------------------
_m("E_SHEET_DELIMITER", "error", "Sample sheet is not comma-delimited CSV", "IN-001"),
_m("E_SHEET_EMPTY", "error", "Sample sheet has no data rows", "IN-001"),
_m("E_SHEET_NO_KEY_COLUMNS", "error", "Sample sheet lacks SentrixBarcode_A/SentrixPosition_A", "IN-002"),
_m("E_SHEET_DUP_COLUMN", "error", "Duplicate column (column names are case-insensitive)", "IN-003"),
_m("W_SHEET_EMPTY_COLUMN", "warning", "Columns with empty headers are ignored", "IN-004"),
_m("E_SHEET_RESERVED", "error", "Sample sheet column collides with an output column", "IN-005"),
_m("E_SHEET_TOO_LONG", "error", "Sample sheet name or value longer than 500 characters", "IN-006"),
_m("E_SHEET_BARCODE_FORMAT", "error", "Invalid SentrixBarcode_A / SentrixPosition_A value", "IN-007"),
_m("W_SHEET_BARCODE_REPAIRED", "warning", "Barcode written as a decimal was repaired", "IN-007"),
_m("W_SHEET_ROOTFOLDER_OVERRIDDEN", "warning", "CLI folder overrides the sheet RootFolder", "IN-008"),
_m("E_SHEET_DUP_SAMPLE", "error", "The same barcode/position appears in several rows", "IN-009"),
_m("E_SHEET_NAME_EMPTY", "error", "Sample_Name is empty in at least one row", "IN-010"),
_m("E_SHEET_NAME_COLLISION", "error", "Sample names are not unique or clash with sample IDs", "IN-010"),
_m("W_SHEET_SEX_CONFLICT", "warning", "Reported-sex columns disagree", "IN-014"),
# --- IDAT / GTC discovery & parsing (IN-020 .. IN-033) --------------------------------------
_m("E_DUP_IDAT", "error", "Several IDAT pairs resolve to the same sample", "IN-022"),
_m("W_DUP_IDAT_RESOLVED", "warning", "Duplicate IDAT pair resolved by --on-duplicate-idat", "IN-022"),
_m("W_IDAT_UNPAIRED", "warning", "IDAT channel file without its partner", "IN-021"),
_m("E_IDAT_FORMAT", "error", "IDAT file is malformed", "IN-023"),
_m("E_IDAT_ENCRYPTED", "error", "Encrypted (version 1) IDATs are not supported", "IN-023"),
_m("E_IDAT_PAIR_MISMATCH", "error", "Red and Grn IDATs of a sample do not match", "IN-023"),
_m("W_IDAT_NAME_MISMATCH", "warning", "IDAT barcode/position disagrees with its file name", "IN-023"),
_m("E_IDAT_MANIFEST_MISMATCH", "error", "IDATs and manifest belong to different products", "IN-027"),
_m("W_IDAT_LOW_ADDRESS_COVERAGE", "warning", "Some manifest addresses are absent from the IDAT", "IN-027"),
_m("W_IDAT_MANIFEST_NAME", "warning", "IDAT names a different manifest than the BPM", "IN-027"),
_m("W_IDAT_NO_SCAN_TIME", "warning", "IDAT has no scan entry in its run info", "IN-024"),
_m("I_UNLISTED_IDATS", "info", "IDAT pairs not listed in the sample sheet are ignored", "IN-016"),
_m("E_DUP_GTC", "error", "Several GTC files resolve to the same sample", "IN-030"),
_m("E_GTC_CORRUPT", "warning", "GTC file failed validation; sample skipped", "IN-032"),
_m("W_GTC_FOREIGN", "warning", "GTC was produced by another tool", "IN-031"),
_m("E_GTC_UNSUITABLE", "error", "GTC lacks fields required by this command", "IN-031"),
# --- manifests & cluster files (IN-040 .. IN-047) -------------------------------------------
_m("E_BPM_FORMAT", "error", "BPM manifest is malformed or unsupported", "IN-040"),
_m("E_CSV_MANIFEST_FORMAT", "error", "CSV manifest is malformed", "IN-041"),
_m("E_BPM_CSV_MISMATCH", "error", "BPM and CSV manifests describe different loci", "IN-042"),
_m("E_EGT_MANIFEST_MISMATCH", "error", "Cluster file lacks loci of the manifest", "IN-043"),
_m("E_EGT_LOAD", "error", "Cluster (EGT) file cannot be loaded", "IN-044"),
_m("W_EGT_NAME", "warning", "Cluster file name is not portable", "IN-045"),
_m("W_MANIFEST_NO_REFSTRAND", "warning", "Manifest lacks RefStrand annotation", "IN-040"),
# --- reference genome and VCF export (IN-060 .. IN-065, OUT-010 .. OUT-043) --------------------------
_m("E_FASTA_FORMAT", "error", "FASTA file is malformed", "IN-060"),
_m("E_FAI_MISMATCH", "error", "FASTA index does not match the FASTA", "IN-060"),
_m("E_BUILD_MISMATCH", "error", "Manifest genome build differs from the FASTA build", "IN-063"),
_m("W_SKIP_UNMAPPED", "warning", "Locus without a genomic position was skipped", "OUT-019"),
_m("W_SKIP_INTENSITY_ONLY", "warning", "Intensity-only locus was skipped", "OUT-019"),
_m("W_SKIP_INDEL_CONTEXT", "warning", "Indel sequence cannot be determined (no CSV SourceSeq)", "OUT-019"),
_m("W_SKIP_INDEL_REF", "warning", "Indel reference allele cannot be determined from the FASTA", "OUT-019"),
_m("W_INDEL_PARTIAL_MATCH", "warning", "Indel source sequence matches the genome only partially", "OUT-019"),
_m("W_SKIP_EXCLUDED", "warning", "Locus excluded by --exclude-loci/--include-loci", "OUT-019"),
_m("W_SKIP_CONTIG", "warning", "Manifest chromosome absent from the FASTA", "OUT-019"),
_m("W_SKIP_NO_STRAND", "warning", "Locus has neither RefStrand nor a sequence to align", "OUT-042"),
_m("W_SKIP_AMBIGUOUS", "warning", "Ambiguous-strand locus dropped by --drop-ambiguous", "OUT-042"),
_m("W_REF_NOT_QUERIED", "warning", "The reference allele is not among the probe's alleles", "OUT-015"),
_m("W_MERGE_INCONSISTENT", "warning", "Probes at a site disagree; genotype set missing", "OUT-031"),
_m("W_MERGE_AMBIGUOUS", "warning", "Probes at a site allow several genotypes; set missing", "OUT-031"),
_m("W_MERGE_ALL_NC", "warning", "Every probe at a merged site is a no-call", "OUT-031"),
_m("W_INF1_NC_CONFLICT", "warning", "Infinium I no-call conflicts with other assays", "OUT-037"),
_m("W_NO_BGZIP", "warning", "Uncompressed VCF written; downstream commands need bgzip + index", "OUT-010"),
_m("W_UNKNOWN_LOCI", "warning", "Names in a locus list are not in the manifest", "OUT-036"),
_m("E_AUX_INVALID", "error", "Auxiliary-loci record is invalid", "OUT-035"),
_m("W_LARGE_OUTPUT", "warning", "Output will be very large", "EDGE-036"),
# --- QC (QC-001 .. QC-064) -----------------------------------------------------------------
_m("W_CTRL_MISSING", "warning", "Controls needed by a metric are absent; metric not evaluable", "QC-007"),
_m("W_CTRL_ZERO_DIVISOR", "warning", "Control metric has a zero denominator (NaN)", "QC-008"),
_m("W_CTRL_DEFS_DIFFER", "warning", "BPM and CSV control definitions differ; the BPM wins", "QC-001"),
_m("E_QC_NO_DATASETS", "error", "No QC dataset folder found", "QC-041"),
_m("E_QC_DATASET_INCOMPLETE", "error", "Dataset folder lacks a required file", "QC-041"),
_m("E_QC_LABEL_COUNT", "error", "Number of labels does not match number of datasets", "QC-042"),
_m("E_QC_LABEL_DUPLICATE", "error", "Dataset labels are not unique", "QC-042"),
_m("W_QC_LABEL_SANITIZED", "warning", "Dataset label was sanitized", "QC-042"),
_m("E_QC_MIXED_TYPES", "error", "Genotyping and methylation datasets cannot be mixed", "QC-043"),
_m("W_QC_UNMATCHED_SAMPLES", "warning", "Sample IDs present in one QC file but not the other", "QC-040"),
_m("E_QC_FORMAT", "error", "Invalid --output-format value", "QC-063"),
_m("E_QC_CONFIG_MISSING", "error", "QC config file does not exist", "QC-021"),
_m("E_QC_CONFIG_EXT", "error", "QC config file must be .yaml or .yml", "QC-021"),
_m("E_QC_CONFIG_PARSE", "error", "QC config file is not valid YAML", "QC-021"),
_m("E_QC_CONFIG_STRUCTURE", "error", "QC config lacks the qc/report (QC/Report) wrapper", "QC-021"),
_m("E_QC_THRESHOLD_KEY", "error", "Unknown QC threshold key", "QC-021"),
_m("E_QC_THRESHOLD_VALUE", "error", "QC threshold value is not numeric or null", "QC-021"),
_m("E_QC_THRESHOLD_RANGE", "error", "QC threshold value out of range", "QC-021"),
_m("E_QC_PROFILE", "error", "Unknown QC profile", "QC-024"),
_m("W_QC_PEDIGREE", "warning", "Parent1/Parent2/Replicate value does not name a sample in the set", "IN-018"),
_m("W_QC_NO_BAF", "warning", "GTC has no B-allele frequencies; contamination not estimated", "QC-064"),
_m("W_QC_TOO_FEW", "warning", "Too few samples or sites for a cohort QC statistic", "QC-026"),
# --- cytogenetics CNV/LOH (CYTO-001 .. CYTO-040) -------------------------------------------------
_m("E_MODEL_MANIFEST_MISMATCH", "error", "Model was built for a different manifest probe set", "IN-051"),
_m("E_MODEL_VERSION", "error", "Model schema is too old; rebuild it", "IN-051"),
_m("W_CN_PROBE_FLOOR", "warning", "Minimum probe count below 10 raised to 10", "CYTO-008"),
_m(
"W_CN_THRESHOLD_EXCEEDS_GENOME",
"warning",
"Size threshold exceeds the effective genome; no events",
"CYTO-009",
),
_m("I_CN_SEX_RESOLVED", "info", "Sample sex updated from U by the caller", "CYTO-024"),
_m("E_CN_SEX_UNRESOLVED", "error", "Sample sex unresolved and --unknown-sex-policy error", "CYTO-024"),
_m("W_CN_NO_FASTA", "warning", "No FASTA: VCF REF padding bases written as N", "CYTO-029"),
_m("E_CN_SIDECAR_MISSING", "error", "cn_call_manifest.json not found in the cn call output", "CYTO-032"),
_m("E_CN_VCF_UNINDEXED", "error", "CNV VCFs must be bgzipped and indexed for annotate", "CYTO-032"),
_m("E_CN_FAILED", "error", "CN calling failed for the sample", "CYTO-040"),
# --- targeted PGx copy number (PCN-001 .. PCN-017) ---------------------------------------------------
_m("E_PGX_BATCH_TOO_SMALL", "error", "Too few (passing) samples in the batch for PGx CN calling", "PCN-004"),
_m("W_PGX_BATCH_BELOW_RECOMMENDED", "warning", "Fewer passing samples than recommended", "PCN-004"),
_m("E_PGX_TRAIN_TOO_SMALL", "error", "Too few (passing) samples to train a PGx CN model", "PCN-005"),
_m(
"E_MODEL_CLUSTER_MISMATCH",
"error",
"Input GTCs were called with another cluster file than the model",
"IN-050",
),
_m(
"W_MODEL_CLUSTER_MISMATCH",
"warning",
"Input GTCs were called with another cluster file than the model",
"IN-050",
),
_m("W_PGX_REGION_FEW_PROBES", "warning", "PGx CN region has fewer than 10 unmasked probes", "PCN-008"),
_m("W_PGX_NO_ALT_CONTIG", "warning", "FASTA lacks a contig carrying a PGx region; REF written as N", "IN-062"),
_m("W_PGX_BATCH_COMPOSITION", "warning", "Batch spans several lots/batches or > 14 days of scans", "PCN-006"),
_m("E_PGX_CN_FAILED", "error", "PGx CN calling failed for the sample", "PCN-002"),
_m("E_PGX_CN_MISSING", "error", "A sample has no PGx CN VCF (give --no-cn to run without)", "PGX-011"),
_m("E_PGX_VCF_UNINDEXED", "error", "SNV VCFs for PGx must be bgzipped and indexed", "PGX-011"),
_m("E_PGX_VCF_UNSUITABLE", "error", "SNV VCF unsuitable for PGx (squashing/locus filters/foreign)", "PGX-011"),
_m("E_PGX_GUIDELINE", "error", "Guideline tables not present in the PGx DB", "PGX-025"),
_m("W_PGX_NO_PROBE_MAP", "warning", "The PGx DB has no probe map for this manifest", "PGX-035"),
_m("E_PGX_PLATFORM_UNKNOWN", "error", "PGx CN platform (LCG/EX) unknown: give --csv or --platform", "CLI-032"),
_m("E_PGX_SOURCE", "error", "PGx source tables missing; run scripts/pgx/fetch_sources.sh", "PGX-001"),
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
_m("E_METH_NO_CONTROLS", "error", "Missing control probes in manifest", "MET-002"),
_m("E_METH_MISSING_COLUMNS", "error", "Missing required columns in the methylation manifest", "MET-002"),
_m(
"E_METH_CONTROLS_FORMAT",
"error",
"Controls not formatted correctly. Must have 4 columns (Address,Type,Color_Channel,Name)",
"MET-002",
),
_m("E_METH_NO_IDATS", "error", "No IDATs found", "MET-004"),
_m(
"W_METH_MISSING_SAMPLE",
"warning",
"Missing sample: IDATs absent; the sample fails, the run continues",
"MET-015",
),
_m("E_METH_PREP_FAILED", "error", "Prep failed (sample)", "MET-010"),
_m(
"W_METH_MISSING_CONTROL_TYPES",
"warning",
"Control types missing from the manifest; metric set to NA",
"MET-009",
),
_m(
"W_METH_MISSING_CONTROL_NAMES",
"warning",
"Control names missing from the manifest; metric set to NA",
"MET-009",
),
_m("W_METH_NO_FEATURES", "warning", "No features, skipping PCA plot", "MET-013"),
]
)
class SimurgError(Exception):
"""An error with a stable catalogue code and an exit code."""
def __init__(
self,
code: str,
message: str,
*,
exit_code: ExitCode = ExitCode.USAGE,
hint: str | None = None,
path: str | None = None,
) -> None:
if code not in CATALOGUE:
raise ValueError(f"unknown message code {code!r}")
self.code = code
self.message = message
self.exit_code = exit_code
self.hint = hint
self.path = path
super().__init__(f"[{code}] {message}" + (f" (hint: {hint})" if hint else ""))
class FormatError(SimurgError):
"""Malformed binary/text input. ``code`` names the file type (E_IDAT_FORMAT, E_GTC_CORRUPT, ...)."""
def render_markdown() -> str:
"""Render the catalogue as ``docs/messages.md``."""
lines = [
"# SimurgArray message catalogue",
"",
"Generated from `src/simurg/core/errors.py` — do not edit by hand.",
"",
"| Code | Level | Meaning | Spec |",
"|---|---|---|---|",
]
for d in sorted(CATALOGUE.values(), key=lambda m: m.code):
lines.append(f"| `{d.code}` | {d.level} | {d.summary} | {d.spec} |")
return "\n".join(lines) + "\n"
if __name__ == "__main__": # pragma: no cover
print(render_markdown(), end="")