2020import gzip
2121import zipfile
2222import sqlite3
23- from typing import Optional, Dict, List, Any, Tuple
23+ from typing import Optional, Dict, List, Any, Tuple, Iterable
2424
2525def setup_logging(output_dir, verbose=True):
2626 """Set up logging configuration.
@@ -614,6 +614,114 @@ def is_water_sample_name(name: str) -> bool:
614614 return True
615615 return bool(_WATER_NAME_NC_CN_RE.search(n))
616616
617+
618+ def _fastq_stem(name: str) -> str:
619+ """Strip common FASTQ suffixes from a path basename or sample-like token."""
620+ n = (name or "").strip()
621+ lower = n.lower()
622+ for suf in (".fastq.gz", ".fq.gz", ".fastq", ".fq"):
623+ if lower.endswith(suf):
624+ return n[: -len(suf)]
625+ return n
626+
627+
628+ def parse_zscore_controls_tokens(raw: Optional[str]) -> List[str]:
629+ """
630+ Split --zscore-controls into tokens.
631+
632+ Accepts a comma-separated string, or a file path with one token per line.
633+ Tokens may be sample IDs (BG_1) or FASTQ paths.
634+ """
635+ if not raw:
636+ return []
637+ text = str(raw).strip()
638+ if not text:
639+ return []
640+ try:
641+ p = Path(text).expanduser()
642+ if p.exists() and p.is_file():
643+ out: List[str] = []
644+ with open(p, "r", encoding="utf-8", errors="replace") as fh:
645+ for line in fh:
646+ line = line.strip()
647+ if not line or line.startswith("#"):
648+ continue
649+ out.append(line)
650+ return out
651+ except Exception:
652+ pass
653+ return [x.strip() for x in text.split(",") if x.strip()]
654+
655+
656+ def resolve_zscore_control_sample_names(
657+ raw: Optional[str],
658+ *,
659+ known_sample_names: Optional[Iterable[str]] = None,
660+ sample_fastq_by_name: Optional[Dict[str, str]] = None,
661+ ) -> List[str]:
662+ """
663+ Resolve --zscore-controls to sample names (stable order, de-duplicated).
664+
665+ Accepts, in priority order per token:
666+ 1) exact sample ID present in known_sample_names (e.g. H20_1,BG_1)
667+ 2) exact FASTQ path matching a value in sample_fastq_by_name
668+ 3) FASTQ basename / stem matching a known sample name
669+ """
670+ tokens = parse_zscore_controls_tokens(raw)
671+ if not tokens:
672+ return []
673+
674+ known = {str(s) for s in (known_sample_names or []) if s}
675+ # Also accept names from the FASTQ map.
676+ for s in (sample_fastq_by_name or {}).keys():
677+ if s:
678+ known.add(str(s))
679+
680+ inv: Dict[str, str] = {}
681+ for s, fp in (sample_fastq_by_name or {}).items():
682+ if not fp:
683+ continue
684+ try:
685+ inv[str(Path(fp).expanduser().resolve())] = str(s)
686+ except Exception:
687+ continue
688+
689+ controls: List[str] = []
690+ seen = set()
691+
692+ def _add(sname: str) -> None:
693+ if not sname or sname in seen:
694+ return
695+ seen.add(sname)
696+ controls.append(sname)
697+
698+ for tok in tokens:
699+ # 1) Sample ID (samplesheet names work for FASTQ / fastq_pass / POD5).
700+ if tok in known:
701+ _add(tok)
702+ continue
703+
704+ # 2) Exact FASTQ path → sample name.
705+ try:
706+ p_res = str(Path(tok).expanduser().resolve())
707+ except Exception:
708+ p_res = tok
709+ if p_res in inv:
710+ _add(inv[p_res])
711+ continue
712+
713+ # 3) Basename / stem match (e.g. /path/BG_1.fastq.gz → BG_1).
714+ stem = _fastq_stem(Path(tok).name)
715+ if stem in known:
716+ _add(stem)
717+ continue
718+
719+ logger.warning(
720+ f"Z-score control '{tok}' not found as sample ID or input FASTQ path (skipping)"
721+ )
722+
723+ return controls
724+
617725def get_taxonomy_dir(base_dir: Path, create: bool = False) -> Path:
618726 """
619727 Return taxonomy support directory under a database directory.
@@ -8255,7 +8363,7 @@ def _compute_and_write_zscores(
82558363 - Z-score is computed on log10(mapped_reads + 1) per virus label.
82568364 - Requires >=2 controls; otherwise no Z-scores are written.
82578365 - Controls are either:
8258- - manually specified by --zscore-controls (CSV of exact FASTQ paths), or
8366+ - manually specified by --zscore-controls (sample IDs and/or FASTQ paths), or
82598367 - auto-detected by sample name: water/h2o/h20, or NC/CN + digits (NC1, CN2, …).
82608368
82618369 Returns:
@@ -8265,43 +8373,16 @@ def _compute_and_write_zscores(
82658373 if not zscore_enabled:
82668374 return set(), {}
82678375
8268- # Resolve controls.
8269- controls: List[str] = []
8376+ # Resolve controls: sample IDs and/or FASTQ paths (manual overrides auto-detect) .
8377+ known_names = [d.name for d in all_sample_dirs ]
82708378 if zscore_control_fastqs_csv:
8271- raw = (zscore_control_fastqs_csv or "").strip()
8272- requested_raw: List[str] = []
8273- try:
8274- p = Path(raw).expanduser()
8275- if raw and p.exists() and p.is_file():
8276- # File of paths (one per line).
8277- with open(p, "r", encoding="utf-8", errors="replace") as fh:
8278- for line in fh:
8279- line = line.strip()
8280- if not line or line.startswith("#"):
8281- continue
8282- requested_raw.append(line)
8283- else:
8284- # Comma-separated paths.
8285- requested_raw = [x.strip() for x in raw.split(",") if x.strip()]
8286- except Exception as e:
8287- logger.warning(f"Z-score controls could not be parsed from '{raw}': {e}")
8288- requested_raw = []
8289-
8290- requested = [str(Path(x).expanduser().resolve()) for x in requested_raw if x]
8291- # Match exact fastq paths to sample names.
8292- inv = {str(Path(fp).expanduser().resolve()): s for s, fp in (sample_fastq_by_name or {}).items() if fp}
8293- for p in requested:
8294- if p in inv:
8295- controls.append(inv[p])
8296- else:
8297- logger.warning(f"Z-score control FASTQ not found in inputs: {p} (skipping)")
8379+ controls = resolve_zscore_control_sample_names(
8380+ zscore_control_fastqs_csv,
8381+ known_sample_names=known_names,
8382+ sample_fastq_by_name=(sample_fastq_by_name or {}),
8383+ )
82988384 else:
82998385 controls = [d.name for d in all_sample_dirs if _is_water_sample_name(d.name)]
8300-
8301- # De-duplicate, stable order.
8302- seen = set()
8303- controls = [c for c in controls if not (c in seen or seen.add(c))]
8304-
83058386 if len(controls) < 2:
83068387 logger.info("Z-score: <2 water controls available; skipping Z-score computation.")
83078388 return set(), {}
@@ -8379,6 +8460,44 @@ def _compute_and_write_zscores(
83798460
83808461 for sample_name, hits in list(sample_hits.items()):
83818462 if sample_name in controls_set:
8463+ # Controls themselves should not carry Z-scores; clear any stale values.
8464+ cleared = False
8465+ for h in hits:
8466+ if not isinstance(h, dict):
8467+ continue
8468+ if "zscore" in h or "zscore_controls" in h:
8469+ h.pop("zscore", None)
8470+ h.pop("zscore_controls", None)
8471+ cleared = True
8472+ if cleared:
8473+ try:
8474+ for jf in final_json_files:
8475+ if jf.parent.name != sample_name:
8476+ continue
8477+ with open(jf, "w") as f:
8478+ json.dump(hits, f, indent=2)
8479+ base_dir = jf.parent
8480+ for h in hits:
8481+ if not isinstance(h, dict):
8482+ continue
8483+ acc = (h.get("accession") or "").strip()
8484+ if not acc:
8485+ continue
8486+ sidecar = base_dir / acc / f"{acc}.json"
8487+ if not sidecar.exists():
8488+ continue
8489+ try:
8490+ with open(sidecar, "r") as sf:
8491+ payload = json.load(sf)
8492+ if isinstance(payload, dict):
8493+ payload.pop("zscore", None)
8494+ payload.pop("zscore_controls", None)
8495+ with open(sidecar, "w") as sf:
8496+ json.dump(payload, sf, indent=2)
8497+ except Exception:
8498+ pass
8499+ except Exception as e:
8500+ logger.debug(f"Z-score: could not clear control JSON for {sample_name}: {e}")
83828501 continue
83838502 changed = False
83848503 for h in hits:
@@ -11359,7 +11478,7 @@ def main(args=None):
1135911478 type=str,
1136011479 default=None,
1136111480 metavar="",
11362- help="Override auto-detected water controls with exact FASTQ paths (>=2 waters ): comma-separated or a file (one path per line).",
11481+ help="Override auto-detected water controls (>=2): sample IDs (e.g. H20_1,BG_1) and/or FASTQ paths; comma-separated or a file (one per line).",
1136311482 )
1136411483
1136511484 # Nextclade runs by default when the CLI is installed; flags omitted from --help.
@@ -11770,38 +11889,11 @@ def _is_water_sample_name(name: str) -> bool:
1177011889 return is_water_sample_name(name)
1177111890
1177211891 def _parse_zscore_controls_to_sample_names(raw: Optional[str]) -> List[str]:
11773- if not raw:
11774- return []
11775- raw = str(raw).strip()
11776- requested_raw: List[str] = []
11777- try:
11778- p = Path(raw).expanduser()
11779- if raw and p.exists() and p.is_file():
11780- with open(p, "r", encoding="utf-8", errors="replace") as fh:
11781- for line in fh:
11782- line = line.strip()
11783- if not line or line.startswith("#"):
11784- continue
11785- requested_raw.append(line)
11786- else:
11787- requested_raw = [x.strip() for x in raw.split(",") if x.strip()]
11788- except Exception:
11789- requested_raw = []
11790-
11791- requested = [str(Path(x).expanduser().resolve()) for x in requested_raw if x]
11792- inv = {str(Path(fp).expanduser().resolve()): s for s, fp in (sample_fastq_by_name or {}).items() if fp}
11793- controls: List[str] = []
11794- seen = set()
11795- for pth in requested:
11796- sname = inv.get(pth)
11797- if not sname:
11798- logger.warning(f"Z-score control FASTQ not found in inputs: {pth} (skipping)")
11799- continue
11800- if sname in seen:
11801- continue
11802- seen.add(sname)
11803- controls.append(sname)
11804- return controls
11892+ return resolve_zscore_control_sample_names(
11893+ raw,
11894+ known_sample_names=list(sample_fastq_by_name.keys()),
11895+ sample_fastq_by_name=sample_fastq_by_name,
11896+ )
1180511897
1180611898 zscore_controls_raw = getattr(args, "zscore_controls", None)
1180711899 manual_controls = _parse_zscore_controls_to_sample_names(zscore_controls_raw)
0 commit comments