11#!/usr/bin/env python3
22import argparse
3+ import csv
4+ import os
5+ import subprocess
36import sys
4- from datetime import datetime
5- import pandas as pd
6- import numpy as np
7+ import tempfile
78import yaml
89
910INSERT_BEFORE_THIS_COLUMN = "pango_lineage"
@@ -12,8 +13,8 @@ METADATA_JOIN_COLUMN_NAME = 'strain'
1213NEXTCLADE_JOIN_COLUMN_NAME = 'seqName'
1314VALUE_MISSING_DATA = '?'
1415
15- rate_per_day = 0.0007 * 29903 / 365
16- reference_day = datetime ( 2020 , 1 , 1 ). toordinal ( )
16+ # Nextclade mutation lists (e.g. aaSubstitutions) can be long; lift the csv limit.
17+ csv . field_size_limit ( 10 ** 7 )
1718
1819column_map = {
1920 "clade_nextstrain" : "clade_nextstrain" ,
@@ -41,24 +42,6 @@ column_map = {
4142 "aaSubstitutions" : "aaSubstitutions"
4243}
4344
44- # Nextstrain_clade is added later based on clade_nextstrain and a yml mapping
45- new_columns = list (column_map .values ()) + ["Nextstrain_clade" ]
46-
47-
48- def reorder_columns (result : pd .DataFrame ):
49- """
50- Moves COLUMN_TO_REORDER right before INSERT_BEFORE_THIS_COLUMN
51- """
52- if COLUMN_TO_REORDER not in new_columns :
53- raise ValueError (f"Column { COLUMN_TO_REORDER } not found in values of column_map { column_map } " )
54- columns = list (result .columns )
55- if INSERT_BEFORE_THIS_COLUMN not in columns :
56- raise ValueError (f"Column { INSERT_BEFORE_THIS_COLUMN } not found in metadata columns { columns } " )
57- columns .remove (COLUMN_TO_REORDER )
58- insert_at = columns .index (INSERT_BEFORE_THIS_COLUMN )
59- columns .insert (insert_at , COLUMN_TO_REORDER )
60- return result [columns ]
61-
6245
6346def parse_args ():
6447 parser = argparse .ArgumentParser (
@@ -70,73 +53,110 @@ def parse_args():
7053 parser .add_argument ("-o" , default = sys .stdout )
7154 return parser .parse_args ()
7255
73- def datestr_to_ordinal (x ):
74- try :
75- return datetime .strptime (x ,"%Y-%m-%d" ).toordinal ()
76- except :
77- return np .nan
7856
79- def isfloat (value ):
80- try :
81- float (value )
82- return True
83- except ValueError :
84- return False
57+ def read_header (path ):
58+ with open (path , newline = '' ) as fh :
59+ return next (csv .reader (fh , delimiter = '\t ' ))
60+
61+
62+ def sort_tsv_by_column (path , key_index , out_dir ):
63+ """
64+ Sort a TSV by its (0-based) `key_index` column, keeping the header first,
65+ using an external `LC_ALL=C sort` (bytewise, spills to disk → flat memory).
66+ Returns the path to a temp file the caller must unlink.
67+ """
68+ fd , out_path = tempfile .mkstemp (suffix = '.sorted.tsv' , dir = out_dir )
69+ os .close (fd )
70+ with open (path , newline = '' ) as fin , open (out_path , 'w' , newline = '' ) as fout :
71+ fout .write (fin .readline ()) # header
72+ with open (out_path , 'a' , newline = '' ) as fout :
73+ tail = subprocess .Popen (["tail" , "-n" , "+2" , path ], stdout = subprocess .PIPE )
74+ subprocess .run (
75+ ["sort" , "-t" , "\t " , f"-k{ key_index + 1 } ,{ key_index + 1 } " ],
76+ stdin = tail .stdout , stdout = fout ,
77+ env = {** os .environ , "LC_ALL" : "C" }, check = True ,
78+ )
79+ tail .stdout .close ()
80+ tail .wait ()
81+ return out_path
82+
8583
8684def main ():
8785 args = parse_args ()
8886
89- metadata = pd . read_csv (args .metadata , index_col = METADATA_JOIN_COLUMN_NAME ,
90- sep = ' \t ' , low_memory = False , na_filter = False )
87+ out_is_path = isinstance (args .o , str )
88+ out_dir = os . path . dirname ( os . path . abspath ( args . o )) if out_is_path else "."
9189
92- clades = pd .read_csv (args .nextclade_tsv , index_col = NEXTCLADE_JOIN_COLUMN_NAME ,
93- usecols = [NEXTCLADE_JOIN_COLUMN_NAME , * column_map .keys ()],
94- sep = '\t ' , low_memory = True , dtype = "object" , na_filter = False ) \
95- .rename (columns = column_map )
96-
97- # Add clade_legacy column as Nextstrain_clade
98- # Use yml mapping
99- with open (args .clade_legacy_mapping , 'r' ) as legacy_mapping_file :
100- clade_legacy_mapping_dict : dict [str , str ] = yaml .safe_load (legacy_mapping_file )
90+ metadata_header = read_header (args .metadata )
91+ nextclade_header = read_header (args .nextclade_tsv )
10192
102- def clade_legacy_mapping ( clade_nextstrain : str ) -> str :
103- return clade_legacy_mapping_dict . get ( clade_nextstrain , f" { clade_nextstrain } (Omicron)" )
104-
105- clades [ "Nextstrain_clade" ] = clades [ "clade_nextstrain" ]. map ( clade_legacy_mapping )
93+ strain_idx = metadata_header . index ( METADATA_JOIN_COLUMN_NAME )
94+ seqname_idx = nextclade_header . index ( NEXTCLADE_JOIN_COLUMN_NAME )
95+ # Source column indices of the mapped Nextclade columns, in column_map order.
96+ clade_src_idx = [ nextclade_header . index ( k ) for k in column_map . keys ()]
10697
107- clades = clades [new_columns ]
98+ # Metadata columns (header order, minus the strain index column) and where to
99+ # read each from a metadata row.
100+ metadata_cols = [c for c in metadata_header if c != METADATA_JOIN_COLUMN_NAME ]
101+ metadata_src_idx = [i for i , c in enumerate (metadata_header ) if c != METADATA_JOIN_COLUMN_NAME ]
102+ # Nextstrain_clade is spliced into the metadata block, right before pango_lineage.
103+ splice_at = metadata_cols .index (INSERT_BEFORE_THIS_COLUMN )
108104
109- # Concatenate on columns
110- result = pd .merge (
111- metadata , clades ,
112- left_index = True ,
113- right_index = True ,
114- how = 'left'
105+ output_header = (
106+ [METADATA_JOIN_COLUMN_NAME ]
107+ + metadata_cols [:splice_at ] + [COLUMN_TO_REORDER ] + metadata_cols [splice_at :]
108+ + list (column_map .values ())
115109 )
116110
117- all_clades = result .Nextstrain_clade .unique ()
118- t = result ["date" ].apply (datestr_to_ordinal )
119- div_array = np .array ([float (x ) if isfloat (x ) else np .nan for x in result .divergence ])
120- offset_by_clade = {}
121- for clade in all_clades :
122- ind = result .Nextstrain_clade == clade
123- if ind .sum ()> 100 :
124- deviation = div_array [ind ] - (t [ind ] - reference_day )* rate_per_day
125- offset_by_clade [clade ] = np .mean (deviation [~ np .isnan (deviation )])
126-
127- # extract divergence, time and offset information into vectors or series
128- offset = result ["Nextstrain_clade" ].apply (lambda x : offset_by_clade .get (x , 2.0 ))
129- # calculate divergence
130- result ["clock_deviation" ] = np .array (div_array - ((t - reference_day )* rate_per_day + offset ), dtype = int )
131- result .loc [np .isnan (div_array )| np .isnan (t ), "clock_deviation" ] = np .nan
132-
133- for col in new_columns + ["clock_deviation" ]:
134- result [col ] = result [col ].fillna (VALUE_MISSING_DATA )
135-
136- # Move the new column so that it's next to other clade columns
137- result = reorder_columns (result )
138-
139- result .to_csv (args .o , index_label = METADATA_JOIN_COLUMN_NAME , sep = '\t ' )
111+ with open (args .clade_legacy_mapping , 'r' ) as legacy_mapping_file :
112+ clade_legacy_mapping_dict = yaml .safe_load (legacy_mapping_file )
113+
114+ missing_clades = [VALUE_MISSING_DATA ] * len (column_map )
115+
116+ metadata_sorted = sort_tsv_by_column (args .metadata , strain_idx , out_dir )
117+ nextclade_sorted = sort_tsv_by_column (args .nextclade_tsv , seqname_idx , out_dir )
118+ try :
119+ out_fh = open (args .o , 'w' , newline = '' ) if out_is_path else args .o
120+ try :
121+ writer = csv .writer (out_fh , delimiter = '\t ' , lineterminator = '\n ' )
122+ writer .writerow (output_header )
123+
124+ with open (metadata_sorted , newline = '' ) as mfh , open (nextclade_sorted , newline = '' ) as nfh :
125+ metadata_rows = csv .reader (mfh , delimiter = '\t ' )
126+ nextclade_rows = csv .reader (nfh , delimiter = '\t ' )
127+ next (metadata_rows ) # skip header
128+ next (nextclade_rows ) # skip header
129+
130+ # Streaming left merge-join: both inputs sorted by their join key,
131+ # unique keys on each side (transform dedups strain; nextclade is
132+ # tsv-uniq'd on seqName), so it's a 1:1 / 1:0 match with no fan-out.
133+ clade_row = next (nextclade_rows , None )
134+ for metadata_row in metadata_rows :
135+ strain = metadata_row [strain_idx ]
136+ while clade_row is not None and clade_row [seqname_idx ] < strain :
137+ clade_row = next (nextclade_rows , None )
138+
139+ if clade_row is not None and clade_row [seqname_idx ] == strain :
140+ clade_values = [clade_row [i ] for i in clade_src_idx ]
141+ clade_nextstrain = clade_values [0 ]
142+ nextstrain_clade = clade_legacy_mapping_dict .get (
143+ clade_nextstrain , f"{ clade_nextstrain } (Omicron)" )
144+ else :
145+ clade_values = missing_clades
146+ nextstrain_clade = VALUE_MISSING_DATA
147+
148+ metadata_values = [metadata_row [i ] for i in metadata_src_idx ]
149+ writer .writerow (
150+ [strain ]
151+ + metadata_values [:splice_at ] + [nextstrain_clade ] + metadata_values [splice_at :]
152+ + clade_values
153+ )
154+ finally :
155+ if out_is_path :
156+ out_fh .close ()
157+ finally :
158+ os .unlink (metadata_sorted )
159+ os .unlink (nextclade_sorted )
140160
141161
142162if __name__ == '__main__' :
0 commit comments