33import re
44
55from pmultiqc .modules .base import BasePMultiqcModule
6- from pmultiqc .modules .fragpipe import fragpipe_io
6+ from pmultiqc .modules .fragpipe .fragpipe_io import (
7+ get_fragpipe_files ,
8+ psm_reader ,
9+ ion_reader ,
10+ get_ion_intensity_data ,
11+ )
712from pmultiqc .modules .common .stats import (
813 cal_delta_mass_dict ,
914 nanmedian ,
3742)
3843from pmultiqc .modules .common .plots .general import (
3944 plot_html_check ,
45+ plot_data_check ,
4046 stat_pep_intensity ,
4147 search_engine_score_bins ,
4248 draw_search_engine_scores ,
4753from pmultiqc .modules .common .histogram import Histogram
4854
4955from multiqc import config
50- from multiqc .plots import bargraph
56+ from multiqc .plots import bargraph , box
5157
5258from pmultiqc .modules .common .logging import get_logger
5359
@@ -76,14 +82,22 @@ def __init__(self, find_log_files_func, sub_sections, heatmap_colors):
7682 self .mods = []
7783 self .hm_data = []
7884
85+ # Ion-level intensity data from ion.tsv
86+ self .ion_intensity_data = None
87+ self .ion_sample_cols = []
88+
7989
8090 def get_data (self ):
8191
8292 log .info ("Starting data recognition and processing..." )
8393
84- self .fragpipe_files = fragpipe_io .get_fragpipe_files (self .find_log_files )
94+ self .fragpipe_files = get_fragpipe_files (self .find_log_files )
95+
96+ if self .fragpipe_files is None :
97+ log .warning ("No FragPipe files found." )
98+ return False
8599
86- if self .fragpipe_files [ "psm" ] :
100+ if self .fragpipe_files . get ( "psm" ) :
87101 (
88102 self .delta_masses ,
89103 self .charge_states ,
@@ -102,6 +116,12 @@ def get_data(self):
102116 log .warning ("Required input not found: psm.tsv" )
103117 return False
104118
119+ # Parse ion.tsv for ion-level intensity data
120+ if self .fragpipe_files .get ("ion" ):
121+ self .ion_intensity_data , self .ion_sample_cols = self .parse_ion (
122+ fragpipe_files = self .fragpipe_files
123+ )
124+
105125 return True
106126
107127 def draw_plots (self ):
@@ -211,6 +231,13 @@ def draw_plots(self):
211231 retentions = self .retentions
212232 )
213233
234+ # Ion-level intensity plots from ion.tsv
235+ if self .ion_intensity_data :
236+ self .draw_ion_intensity_distribution (
237+ sub_section = self .sub_sections ["quantification" ],
238+ intensity_data = self .ion_intensity_data
239+ )
240+
214241 section_group_dict = {
215242 "summary_sub_section" : self .sub_sections ["summary" ],
216243 "identification_sub_section" : self .sub_sections ["identification" ],
@@ -243,7 +270,7 @@ def parse_psm(fragpipe_files):
243270
244271 for psm in fragpipe_files .get ("psm" , []):
245272
246- psm_df = fragpipe_io . psm_reader (psm )
273+ psm_df = psm_reader (psm )
247274
248275 if psm_df is None or psm_df .empty :
249276 log .warning (f"Skipping unreadable/empty FragPipe PSM file: { psm } " )
@@ -703,6 +730,130 @@ def draw_ids_over_rt(sub_section, retentions: list):
703730 report_type = "fragpipe"
704731 )
705732
733+ @staticmethod
734+ def parse_ion (fragpipe_files ):
735+ """
736+ Parse ion.tsv files for ion-level intensity data.
737+
738+ Parameters
739+ ----------
740+ fragpipe_files : dict
741+ Dictionary of FragPipe file paths.
742+
743+ Returns
744+ -------
745+ tuple
746+ (ion_intensity_data, sample_cols) where:
747+ - ion_intensity_data: Dictionary with intensity distribution and CV data
748+ - sample_cols: List of sample intensity column names
749+ """
750+ ion_files = fragpipe_files .get ("ion" , [])
751+
752+ if not ion_files :
753+ log .info ("No ion.tsv files found." )
754+ return None , []
755+
756+ all_intensity_data = {
757+ 'intensity_distribution' : {},
758+ }
759+ all_sample_cols = []
760+
761+ for ion_file in ion_files :
762+ try :
763+ ion_df , sample_cols = ion_reader (ion_file )
764+
765+ if ion_df is None or ion_df .empty :
766+ log .warning (f"Skipping unreadable/empty ion.tsv file: { ion_file } " )
767+ continue
768+
769+ log .info (f"Loaded ion.tsv with { len (ion_df )} rows and { len (sample_cols )} samples" )
770+
771+ intensity_data = get_ion_intensity_data (ion_df , sample_cols )
772+
773+ if intensity_data :
774+ # Merge intensity distributions
775+ for sample , values in intensity_data .get ('intensity_distribution' , {}).items ():
776+ if sample in all_intensity_data ['intensity_distribution' ]:
777+ all_intensity_data ['intensity_distribution' ][sample ].extend (values )
778+ else :
779+ all_intensity_data ['intensity_distribution' ][sample ] = values
780+
781+ all_sample_cols .extend ([c for c in sample_cols if c not in all_sample_cols ])
782+
783+ except Exception as e :
784+ log .warning (f"Error parsing ion.tsv file { ion_file } : { e } " )
785+ continue
786+
787+ if not all_intensity_data ['intensity_distribution' ]:
788+ log .info ("No valid intensity data found in ion.tsv files." )
789+ return None , []
790+
791+ log .info (f"Ion intensity data parsed for { len (all_sample_cols )} samples" )
792+
793+ return all_intensity_data , all_sample_cols
794+
795+ @staticmethod
796+ def draw_ion_intensity_distribution (sub_section , intensity_data : dict ):
797+ """
798+ Draw ion-level intensity distribution box plot.
799+
800+ Parameters
801+ ----------
802+ sub_section : dict
803+ Section to add the plot to.
804+ intensity_data : dict
805+ Dictionary containing 'intensity_distribution' data.
806+ """
807+ distribution = intensity_data .get ('intensity_distribution' , {})
808+
809+ if not distribution :
810+ log .info ("No ion intensity distribution data available." )
811+ return
812+
813+ log .info (f"Drawing ion intensity distribution for { len (distribution )} samples" )
814+
815+ draw_config = {
816+ "id" : "ion_intensity_distribution_box" ,
817+ "cpswitch" : False ,
818+ "cpswitch_c_active" : False ,
819+ "title" : "Ion Intensity Distribution" ,
820+ "tt_decimals" : 2 ,
821+ "xlab" : "log2(Intensity)" ,
822+ "sort_samples" : False ,
823+ "save_data_file" : False ,
824+ }
825+
826+ box_html = box .plot (list_of_data_by_sample = distribution , pconfig = draw_config )
827+
828+ box_html = plot_data_check (
829+ plot_data = distribution ,
830+ plot_html = box_html ,
831+ log_text = "pmultiqc.modules.fragpipe.fragpipe" ,
832+ function_name = "draw_ion_intensity_distribution"
833+ )
834+ box_html = plot_html_check (box_html )
835+
836+ add_sub_section (
837+ sub_section = sub_section ,
838+ plot = box_html ,
839+ order = 6 ,
840+ description = "Ion-level intensity distribution per sample from ion.tsv." ,
841+ helptext = """
842+ [FragPipe: ion.tsv] This plot shows the log2-transformed ion intensity
843+ distribution for each sample/channel. The ion.tsv file contains precursor-level
844+ quantification data from IonQuant.
845+
846+ For TMT experiments, each box represents a TMT channel.
847+ For label-free experiments, each box represents a sample/run.
848+
849+ A higher median intensity and narrower distribution typically indicate
850+ better quantification quality. Large differences between samples may
851+ indicate normalization issues or batch effects.
852+ """ ,
853+ )
854+
855+ log .info ("Ion intensity distribution plot generated." )
856+
706857
707858def _calculate_statistics (pipeline_stats : list ):
708859
0 commit comments