1919
2020import argparse
2121import base64
22+ import json
2223import logging
2324import os
2425import re
5657ROOT_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+
5967def _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+
73381def _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" )
0 commit comments