@@ -140,20 +140,42 @@ pub fn flatten_qpx_batch(batch: &RecordBatch) -> Result<Vec<QpxFeatureRecord>> {
140140 . transpose ( ) ?
141141 . flatten ( ) ;
142142
143- for entry in intensity_entries ( intensities, row) ? {
143+ let entries = intensity_entries ( intensities, row) ?;
144+ // In quantms.io LFQ the per-run intensities are labeled by run file (the
145+ // row's anchor run appears among them); in isobaric (TMT/iTRAQ) the labels
146+ // are reporter channels and never equal a run file. When the labels are
147+ // runs, the run/sample for each intensity is its own `label`, not the row's
148+ // anchor `run_file_name` -- otherwise every run collapses onto the anchor
149+ // sample and all other runs are dropped (see mokume-io qpx tests).
150+ let row_key = crate :: sdrf:: normalize_file_key ( & run_file_name) ;
151+ let labels_are_runs = entries. iter ( ) . any ( |entry| {
152+ entry
153+ . label
154+ . as_deref ( )
155+ . is_some_and ( |label| crate :: sdrf:: normalize_file_key ( label) == row_key)
156+ } ) ;
157+ for entry in entries {
144158 if entry. intensity . is_finite ( ) {
159+ let ( entry_run_file_name, entry_label) = if labels_are_runs {
160+ (
161+ entry. label . clone ( ) . unwrap_or_else ( || run_file_name. clone ( ) ) ,
162+ None ,
163+ )
164+ } else {
165+ ( run_file_name. clone ( ) , entry. label )
166+ } ;
145167 records. push ( QpxFeatureRecord {
146168 sequence : sequence. clone ( ) ,
147169 peptidoform : peptidoform. clone ( ) ,
148170 charge,
149- run_file_name : run_file_name . clone ( ) ,
171+ run_file_name : entry_run_file_name ,
150172 sample_accession : entry. sample_accession ,
151173 protein_accessions : protein_accessions. clone ( ) ,
152174 anchor_protein : anchor_protein. clone ( ) ,
153175 unique,
154176 is_decoy,
155177 pg_global_qvalue,
156- label : entry . label ,
178+ label : entry_label ,
157179 intensity : entry. intensity ,
158180 } ) ;
159181 }
@@ -507,6 +529,50 @@ mod tests {
507529 Ok ( ( ) )
508530 }
509531
532+ #[ test]
533+ fn routes_lfq_run_labels_to_distinct_runs ( ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
534+ // LFQ: the intensity labels are run files (the row's anchor run appears
535+ // among them), so each intensity must map to its own run/sample instead of
536+ // collapsing onto the anchor `run_file_name`.
537+ let intensities = lfq_run_intensities ( ) ?;
538+ let proteins = new_qpx_proteins ( ) ?;
539+ let schema = Arc :: new ( Schema :: new ( vec ! [
540+ Field :: new( "sequence" , DataType :: Utf8 , false ) ,
541+ Field :: new( "peptidoform" , DataType :: Utf8 , false ) ,
542+ Field :: new( "charge" , DataType :: Int16 , false ) ,
543+ Field :: new( "run_file_name" , DataType :: Utf8 , false ) ,
544+ Field :: new( "anchor_protein" , DataType :: Utf8 , false ) ,
545+ Field :: new( "unique" , DataType :: Boolean , false ) ,
546+ Field :: new( "is_decoy" , DataType :: Boolean , false ) ,
547+ Field :: new( "intensities" , intensities. data_type( ) . clone( ) , true ) ,
548+ Field :: new( "pg_accessions" , proteins. data_type( ) . clone( ) , true ) ,
549+ ] ) ) ;
550+ let batch = arrow:: record_batch:: RecordBatch :: try_new (
551+ schema,
552+ vec ! [
553+ Arc :: new( StringArray :: from( vec![ "PEPTIDEK" ] ) ) as ArrayRef ,
554+ Arc :: new( StringArray :: from( vec![ "PEPTIDEK" ] ) ) as ArrayRef ,
555+ Arc :: new( Int16Array :: from( vec![ 2 ] ) ) as ArrayRef ,
556+ Arc :: new( StringArray :: from( vec![ "Run_A_01.mzML" ] ) ) as ArrayRef ,
557+ Arc :: new( StringArray :: from( vec![ "sp|P12345|PROT_HUMAN" ] ) ) as ArrayRef ,
558+ Arc :: new( BooleanArray :: from( vec![ true ] ) ) as ArrayRef ,
559+ Arc :: new( BooleanArray :: from( vec![ false ] ) ) as ArrayRef ,
560+ intensities,
561+ proteins,
562+ ] ,
563+ ) ?;
564+
565+ let records = flatten_qpx_batch ( & batch) ?;
566+
567+ assert_eq ! ( records. len( ) , 3 ) ;
568+ let runs: Vec < _ > = records. iter ( ) . map ( |r| r. run_file_name . as_str ( ) ) . collect ( ) ;
569+ assert_eq ! ( runs, [ "Run_A_01.mzML" , "Run_B_01.mzML" , "Run_B_02.mzML" ] ) ;
570+ // LFQ has no reporter channel; run-file labels must not leak into `label`.
571+ assert ! ( records. iter( ) . all( |record| record. label. is_none( ) ) ) ;
572+ assert_eq ! ( records[ 1 ] . intensity, 20.0 ) ;
573+ Ok ( ( ) )
574+ }
575+
510576 #[ test]
511577 fn reads_integer_unique_and_decoy_flags ( ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
512578 let intensities = new_qpx_intensities ( ) ?;
@@ -597,6 +663,33 @@ mod tests {
597663 Ok ( Arc :: new ( builder. finish ( ) ) as ArrayRef )
598664 }
599665
666+ fn lfq_run_intensities ( ) -> Result < ArrayRef , Box < dyn std:: error:: Error > > {
667+ // Labels are run files; the anchor run (`Run_A_01.mzML`) appears among them.
668+ let fields = Fields :: from ( vec ! [
669+ Field :: new( "label" , DataType :: Utf8 , false ) ,
670+ Field :: new( "intensity" , DataType :: Float32 , false ) ,
671+ ] ) ;
672+ let struct_builder = StructBuilder :: new (
673+ fields,
674+ vec ! [
675+ Box :: new( StringBuilder :: new( ) ) ,
676+ Box :: new( Float32Builder :: new( ) ) ,
677+ ] ,
678+ ) ;
679+ let mut builder = ListBuilder :: new ( struct_builder) ;
680+ for ( label, value) in [
681+ ( "Run_A_01.mzML" , 10.0f32 ) ,
682+ ( "Run_B_01.mzML" , 20.0 ) ,
683+ ( "Run_B_02.mzML" , 30.0 ) ,
684+ ] {
685+ append_string_field ( builder. values ( ) , 0 , label) ?;
686+ append_f32_field ( builder. values ( ) , 1 , value) ?;
687+ builder. values ( ) . append ( true ) ;
688+ }
689+ builder. append ( true ) ;
690+ Ok ( Arc :: new ( builder. finish ( ) ) as ArrayRef )
691+ }
692+
600693 fn new_qpx_proteins ( ) -> Result < ArrayRef , Box < dyn std:: error:: Error > > {
601694 let fields = Fields :: from ( vec ! [
602695 Field :: new( "accession" , DataType :: Utf8 , false ) ,
0 commit comments