Skip to content

Commit 1d7e5c9

Browse files
committed
.
1 parent cda483d commit 1d7e5c9

3 files changed

Lines changed: 317 additions & 360 deletions

File tree

recmpox/recmpox.py

Lines changed: 317 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import argparse
2121
import base64
22+
import json
2223
import logging
2324
import os
2425
import re
@@ -56,6 +57,13 @@
5657
ROOT_TREE_FIGURE_R = _REFERENCES_DIR / "root_tree_figure.R"
5758

5859

60+
LAPIS_MPOX_DETAILS = "https://lapis.pathoplexus.org/mpox/sample/details"
61+
PATHOPLEXUS_FASTA = "https://pathoplexus.org/seq"
62+
MIN_LENGTH_BP = 190_000
63+
PER_GROUP = 5
64+
IA_SH2024_MIN_DATE = "2024-08-19"
65+
66+
5967
def _safe_fasta_id(raw_id: str) -> str:
6068
"""
6169
Make a FASTA ID safe for external tools (notably Squirrel), which rejects
@@ -70,6 +78,306 @@ def _safe_fasta_id(raw_id: str) -> str:
7078
return s or "seq"
7179

7280

81+
def _fetch_url_lapis(url: str, params: str = "") -> str:
82+
full = f"{url}?{params}" if params else url
83+
ctx = ssl.create_default_context()
84+
ctx.check_hostname = False
85+
ctx.verify_mode = ssl.CERT_NONE
86+
req = urllib.request.Request(full, headers={"User-Agent": "RecMpox/1.0"})
87+
with urllib.request.urlopen(req, timeout=90, context=ctx) as resp:
88+
return resp.read().decode("utf-8")
89+
90+
91+
def _lapis_fetch(q: Dict[str, Any], limit: int = 5000) -> List[Dict[str, Any]]:
92+
"""Fetch from LAPIS details; return list of dicts (raw rows)."""
93+
q = dict(q)
94+
q["limit"] = limit
95+
params = urllib.parse.urlencode(q)
96+
try:
97+
data = _fetch_url_lapis(LAPIS_MPOX_DETAILS, params)
98+
obj = json.loads(data)
99+
data_list = obj.get("data") or []
100+
return data_list
101+
except Exception as e:
102+
logger.warning("Pathoplexus LAPIS failed: %s", e)
103+
return []
104+
105+
106+
def _lapis_row_to_tuple(r: Dict[str, Any], date_key_priority: Optional[List[str]] = None) -> Optional[Tuple[str, str, int, Optional[str]]]:
107+
"""(accession_version, date_sort_key, length, insdc) or None."""
108+
acc_ver = (r.get("accessionVersion") or r.get("accession") or "").strip()
109+
length = r.get("length")
110+
if not acc_ver or length is None or int(length) < MIN_LENGTH_BP:
111+
return None
112+
insdc = (r.get("insdcAccessionFull") or "").strip() or None
113+
date = None
114+
for key in date_key_priority or ["sampleCollectionDate", "sampleCollectionDateRangeLower", "sampleCollectionDateRangeUpper"]:
115+
v = r.get(key)
116+
if isinstance(v, str) and v.strip():
117+
date = v.strip()
118+
break
119+
if not date:
120+
date = "9999-99-99"
121+
return (acc_ver, date, int(length), insdc)
122+
123+
124+
def _fetch_ib_kinshasa(limit: int = 5000) -> List[Tuple[str, str, int, Optional[str]]]:
125+
rows = _lapis_fetch(
126+
{
127+
"geoLocCountry": "Democratic Republic of the Congo",
128+
"geoLocAdmin1": "Kinshasa",
129+
"clade": "Ib",
130+
"lengthFrom": MIN_LENGTH_BP,
131+
},
132+
limit=limit,
133+
)
134+
out: List[Tuple[str, str, int, Optional[str]]] = []
135+
for r in rows:
136+
t = _lapis_row_to_tuple(r, ["sampleCollectionDate"])
137+
if t and t[1] != "9999-99-99":
138+
out.append(t)
139+
out.sort(key=lambda x: (x[1], x[0]))
140+
return out[:PER_GROUP]
141+
142+
143+
def _fetch_ia_kinshasa_sh2024(limit: int = 5000) -> List[Tuple[str, str, int, Optional[str]]]:
144+
rows = _lapis_fetch(
145+
{
146+
"geoLocCountry": "Democratic Republic of the Congo",
147+
"geoLocAdmin1": "Kinshasa",
148+
"clade": "Ia",
149+
"outbreak": "sh2024",
150+
"lengthFrom": MIN_LENGTH_BP,
151+
},
152+
limit=limit,
153+
)
154+
out: List[Tuple[str, str, int, Optional[str]]] = []
155+
for r in rows:
156+
t = _lapis_row_to_tuple(r, ["sampleCollectionDate"])
157+
if t and t[1] != "9999-99-99" and t[1] >= IA_SH2024_MIN_DATE:
158+
out.append(t)
159+
out.sort(key=lambda x: (x[1], x[0]))
160+
return out[:PER_GROUP]
161+
162+
163+
def _fetch_sh2017(limit: int = 5000) -> List[Tuple[str, str, int, Optional[str]]]:
164+
rows = _lapis_fetch(
165+
{
166+
"outbreak": "sh2017",
167+
"lengthFrom": MIN_LENGTH_BP,
168+
},
169+
limit=limit,
170+
)
171+
out: List[Tuple[str, str, int, Optional[str]]] = []
172+
for r in rows:
173+
t = _lapis_row_to_tuple(r, ["sampleCollectionDate"])
174+
if t and t[1] != "9999-99-99":
175+
out.append(t)
176+
out.sort(key=lambda x: (x[1], x[0]))
177+
return out[:PER_GROUP]
178+
179+
180+
def _fetch_iia_earliest(limit: int = 5000) -> List[Tuple[str, str, int, Optional[str]]]:
181+
rows = _lapis_fetch(
182+
{
183+
"clade": "IIa",
184+
"lengthFrom": MIN_LENGTH_BP,
185+
},
186+
limit=limit,
187+
)
188+
seen_base: set = set()
189+
out: List[Tuple[str, str, int, Optional[str]]] = []
190+
for r in rows:
191+
if r.get("versionStatus") != "LATEST_VERSION":
192+
continue
193+
t = _lapis_row_to_tuple(
194+
r,
195+
["sampleCollectionDate", "sampleCollectionDateRangeLower", "sampleCollectionDateRangeUpper"],
196+
)
197+
if not t:
198+
continue
199+
base = (t[0].split(".")[0], t[1])
200+
if base in seen_base:
201+
continue
202+
seen_base.add(base)
203+
out.append(t)
204+
out.sort(key=lambda x: (x[1], x[0]))
205+
return out[:PER_GROUP]
206+
207+
208+
def _fetch_fasta_pathoplexus(accession_version: str, out_path: Path) -> bool:
209+
url = f"{PATHOPLEXUS_FASTA}/{accession_version}.fa"
210+
try:
211+
data = _fetch_url_lapis(url, "")
212+
if not data.strip() or "not found" in data.lower()[:200]:
213+
return False
214+
out_path.parent.mkdir(parents=True, exist_ok=True)
215+
out_path.write_text(data)
216+
return True
217+
except Exception:
218+
return False
219+
220+
221+
def _align_consensus_group(combined_fa: Path, out_dir: Path, stem: str, use_clade_ii: bool) -> Optional[Path]:
222+
"""Run Squirrel (cladei or cladeii) or mafft; return path to alignment file or None."""
223+
squirrel_out = out_dir / "squirrel_out" / stem
224+
squirrel_out.mkdir(parents=True, exist_ok=True)
225+
expected_aln = squirrel_out / (combined_fa.stem + ".aln.fasta")
226+
clade = "cladeii" if use_clade_ii else "cladei"
227+
try:
228+
subprocess.run(
229+
["squirrel", "--clade", clade, str(combined_fa), "-o", str(squirrel_out), "--tempdir", str(squirrel_out / "tmp")],
230+
check=True,
231+
capture_output=True,
232+
text=True,
233+
timeout=600,
234+
)
235+
except FileNotFoundError:
236+
pass
237+
except subprocess.CalledProcessError:
238+
pass
239+
if expected_aln.exists():
240+
return expected_aln
241+
aln_files = list(squirrel_out.glob("*.aln.fasta"))
242+
if aln_files:
243+
return aln_files[0]
244+
try:
245+
with open(expected_aln, "w") as out:
246+
subprocess.run(
247+
["mafft", "--auto", "--quiet", str(combined_fa)],
248+
check=True,
249+
stdout=out,
250+
text=True,
251+
timeout=600,
252+
)
253+
return expected_aln
254+
except (FileNotFoundError, subprocess.CalledProcessError):
255+
return None
256+
257+
258+
def _build_consensus_from_aln(aln_path: Path, consensus_stem: str) -> Optional[Tuple[str, int]]:
259+
"""Return (consensus_content_with_header, len_ungapped) or None."""
260+
seqs: Dict[str, str] = {}
261+
with open(aln_path) as f:
262+
current_id: Optional[str] = None
263+
current_seq: List[str] = []
264+
for line in f:
265+
if line.startswith(">"):
266+
if current_id is not None:
267+
seqs[current_id] = "".join(current_seq)
268+
current_id = line[1:].split()[0].strip().replace("/", "_")
269+
current_seq = []
270+
else:
271+
current_seq.append(line.strip())
272+
if current_id is not None:
273+
seqs[current_id] = "".join(current_seq)
274+
if not seqs:
275+
return None
276+
aln_len = len(next(iter(seqs.values())))
277+
consensus: List[str] = []
278+
for col in range(aln_len):
279+
counts = {"A": 0, "C": 0, "G": 0, "T": 0, "N": 0, "-": 0}
280+
for seq in seqs.values():
281+
b = seq[col].upper() if col < len(seq) else "-"
282+
if b in counts:
283+
counts[b] += 1
284+
else:
285+
counts["N"] += 1
286+
acgt = {k: counts[k] for k in "ACGT"}
287+
best = max(acgt.items(), key=lambda x: x[1])
288+
total_acgt = sum(acgt.values())
289+
if total_acgt == 0:
290+
consensus.append("N")
291+
elif best[1] > total_acgt / 2:
292+
consensus.append(best[0])
293+
else:
294+
consensus.append("N")
295+
consensus_ungapped = "".join(consensus).replace("-", "")
296+
body = "\n".join(consensus_ungapped[i : i + 80] for i in range(0, len(consensus_ungapped), 80)) + "\n"
297+
header = f">{consensus_stem}\n"
298+
return (header + body, len(consensus_ungapped))
299+
300+
301+
def _build_lapis_consensus_refs(clades: List[str], consensus_output_dir: Path, work_dir: Path) -> Dict[str, Path]:
302+
"""
303+
Fetch earliest 5 genomes per selected clade from LAPIS/Pathoplexus and build one consensus FASTA per clade.
304+
Returns mapping from clade label (Ia/Ib/IIa/IIb) to FASTA path inside consensus_output_dir.
305+
"""
306+
consensus_output_dir.mkdir(parents=True, exist_ok=True)
307+
tmp_dir = work_dir / "earliest_consensus_tmp"
308+
if tmp_dir.exists():
309+
shutil.rmtree(tmp_dir, ignore_errors=True)
310+
tmp_dir.mkdir(parents=True, exist_ok=True)
311+
try:
312+
fasta_dir = tmp_dir / "fasta"
313+
fasta_dir.mkdir(parents=True, exist_ok=True)
314+
315+
all_groups = [
316+
("Ib_Kinshasa", _fetch_ib_kinshasa, "ib_kinshasa", False, "Ib", "sh2023Ib"),
317+
("Ia_Kinshasa_sh2024", _fetch_ia_kinshasa_sh2024, "ia_kinshasa", False, "Ia", "sh2024Ia"),
318+
("sh2017", _fetch_sh2017, "sh2017", True, "IIb", "sh2017IIb"),
319+
("IIa", _fetch_iia_earliest, "iia", True, "IIa", "iia"),
320+
]
321+
wanted = set(clades)
322+
groups = [g for g in all_groups if g[4] in wanted]
323+
if not groups:
324+
raise RuntimeError(f"No supported clades selected for consensus build: {clades!r}")
325+
326+
out_paths: Dict[str, Path] = {}
327+
for name, fetch_fn, consensus_stem, use_clade_ii, clade_label, header_stem in groups:
328+
logger.info("Consensus refs: fetching %s ...", name)
329+
rows = fetch_fn()
330+
if len(rows) < 2:
331+
logger.warning("Consensus refs: skip %s (need at least 2 samples, got %d)", name, len(rows))
332+
continue
333+
334+
group_fastas: List[Path] = []
335+
for acc_ver, date, length, insdc in rows:
336+
safe_id = acc_ver.replace(".", "_").replace("/", "_")
337+
path = fasta_dir / f"{name}_{safe_id}.fa"
338+
if not _fetch_fasta_pathoplexus(acc_ver, path) and insdc:
339+
fetch_nucleotide_fasta(insdc.split(".")[0], path)
340+
if path.exists():
341+
group_fastas.append(path)
342+
if len(group_fastas) < 2:
343+
logger.warning("Consensus refs: skip %s (could not download enough FASTAs)", name)
344+
continue
345+
346+
combined_fa = tmp_dir / f"samples_combined_{consensus_stem}.fa"
347+
with open(combined_fa, "w") as out_f:
348+
for p in sorted(group_fastas):
349+
text = p.read_text()
350+
out_f.write(text)
351+
if text and not text.endswith("\n"):
352+
out_f.write("\n")
353+
354+
aln_path = _align_consensus_group(combined_fa, tmp_dir, consensus_stem, use_clade_ii)
355+
if not aln_path or not aln_path.exists():
356+
logger.warning("Consensus refs: skip %s (alignment failed; install squirrel and/or mafft)", name)
357+
continue
358+
359+
result = _build_consensus_from_aln(aln_path, header_stem)
360+
if not result:
361+
logger.warning("Consensus refs: skip %s (consensus build failed)", name)
362+
continue
363+
content, length_bp = result
364+
lines = content.split("\n")
365+
if lines and lines[0].startswith(">"):
366+
lines[0] = f">{header_stem}"
367+
content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
368+
out_fa = consensus_output_dir / f"{header_stem}.fa"
369+
out_fa.parent.mkdir(parents=True, exist_ok=True)
370+
out_fa.write_text(content)
371+
logger.info("Consensus refs: wrote %s (length %d bp)", out_fa, length_bp)
372+
out_paths[clade_label] = out_fa
373+
374+
if not out_paths:
375+
raise RuntimeError("Consensus refs: no consensus files produced")
376+
return out_paths
377+
finally:
378+
shutil.rmtree(tmp_dir, ignore_errors=True)
379+
380+
73381
def _sanitize_fasta_ids(in_path: Path, out_path: Path) -> None:
74382
"""Rewrite FASTA with safe IDs; keep sequences unchanged."""
75383
seen: Dict[str, int] = {}
@@ -2147,30 +2455,21 @@ def _normalize_ref_label(s: str) -> str:
21472455
work_dir.mkdir(parents=True, exist_ok=True)
21482456
setup_logging(args.output, verbose=not args.quiet)
21492457

2150-
# When -ref L1,L2: run consensus script (earliest 5 per clade → one consensus per clade) and use those FASTAs as ref1/ref2
2458+
# When -ref L1,L2: build consensus refs from Pathoplexus (earliest 5 per clade → one consensus per clade) and use those FASTAs as ref1/ref2
21512459
if getattr(args, "ref", None):
21522460
L1 = ref1_g_resolved
21532461
L2 = ref2_g_resolved
21542462
consensus_dir = work_dir / "ref_consensus"
2155-
consensus_dir.mkdir(parents=True, exist_ok=True)
2156-
script_path = Path(__file__).resolve().parent / "scripts" / "download_earliest_consensus.py"
2157-
if not script_path.is_file():
2158-
logger.error("Consensus script not found: %s", script_path)
2159-
sys.exit(1)
2160-
cmd = [sys.executable, str(script_path), "--clades", L1, L2, "--out-dir", str(consensus_dir)]
2161-
logger.info("Building consensus references: %s", " ".join(cmd))
2162-
rc = subprocess.run(cmd)
2163-
if rc.returncode != 0:
2164-
logger.error("Consensus script failed (exit %s)", rc.returncode)
2463+
try:
2464+
consensus_map = _build_lapis_consensus_refs([L1, L2], consensus_dir, work_dir)
2465+
except Exception as e:
2466+
logger.error("Consensus refs: failed to build consensus references from Pathoplexus: %s", e)
21652467
sys.exit(1)
2166-
_CONSENSUS_FILENAME = {"Ia": "sh2024Ia.fa", "Ib": "sh2023Ib.fa", "IIa": "iia.fa", "IIb": "sh2017IIb.fa"}
2167-
p1 = consensus_dir / _CONSENSUS_FILENAME[L1]
2168-
p2 = consensus_dir / _CONSENSUS_FILENAME[L2]
2169-
if not p1.is_file() or not p2.is_file():
2170-
logger.error("Consensus files not produced: %s, %s", p1, p2)
2468+
if L1 not in consensus_map or L2 not in consensus_map:
2469+
logger.error("Consensus refs: missing consensus FASTA for %s or %s", L1, L2)
21712470
sys.exit(1)
2172-
args.ref1 = str(p1)
2173-
args.ref2 = str(p2)
2471+
args.ref1 = str(consensus_map[L1])
2472+
args.ref2 = str(consensus_map[L2])
21742473

21752474
ref_ia_path = resolve_ref(args.ref1, work_dir, "1")
21762475
ref_ib_path = resolve_ref(args.ref2, work_dir, "2")

recmpox/scripts/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)