Skip to content

Commit 349fbad

Browse files
committed
.
1 parent f7e68e8 commit 349fbad

4 files changed

Lines changed: 164 additions & 72 deletions

File tree

docs/docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ FROM condaforge/miniforge3:latest
22

33
LABEL maintainer="DaanJansen94" \
44
description="Virasign: Viral taxonomic classification for nanopore sequencing data" \
5-
version="0.0.7" \
5+
version="0.0.8" \
66
url="https://github.com/DaanJansen94/virasign"
77

88
COPY . /opt/virasign

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "virasign"
7-
version = "0.0.7"
7+
version = "0.0.8"
88
description = "Virasign: Viral Read ASSIGNment from nanopore sequencing"
99
readme = "README.md"
1010
license = { text = "GPL-3.0-or-later" }

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
setup(
99
name="virasign",
10-
version="0.0.7",
10+
version="0.0.8",
1111
author="Virasign Team",
1212
description="Virasign: Viral Read ASSIGNment from nanopore sequencing",
1313
long_description=long_description,

virasign/virasign.py

Lines changed: 161 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import gzip
2121
import zipfile
2222
import sqlite3
23-
from typing import Optional, Dict, List, Any, Tuple
23+
from typing import Optional, Dict, List, Any, Tuple, Iterable
2424

2525
def 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+
617725
def 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

Comments
 (0)